diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..22de63a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,50 @@
+name: "🐛 Bug report"
+description: Report a reproducible AmoDocsEngine bug.
+title: "bug: "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Do not paste real amoCRM tokens, `config/config.php`, customer data, or generated private documents.
+ - type: textarea
+ id: summary
+ attributes:
+ label: Summary
+ description: What broke?
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Reproduction steps
+ description: Provide minimal steps with sanitized values.
+ placeholder: |
+ 1. Open public/ui.html?lead_id=...
+ 2. Call ...
+ 3. See ...
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ validations:
+ required: true
+ - type: input
+ id: php-version
+ attributes:
+ label: PHP version
+ placeholder: "8.1"
+ - type: textarea
+ id: logs
+ attributes:
+ label: Sanitized logs
+ description: Paste only sanitized log excerpts.
+ render: text
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..fbbdfe0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security-sensitive report
+ url: https://github.com/AmaLS367
+ about: Do not publish secrets, tokens, customer data, or exploit details in public issues.
+ - name: Documentation
+ url: https://github.com/AmaLS367/AmoDocsEngine/tree/main/docs
+ about: Check the documentation before opening setup or deployment questions.
diff --git a/.github/ISSUE_TEMPLATE/config_deploy_help.yml b/.github/ISSUE_TEMPLATE/config_deploy_help.yml
new file mode 100644
index 0000000..8c6bedd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config_deploy_help.yml
@@ -0,0 +1,42 @@
+name: "⚙️ Config or deploy help"
+description: Ask for help with sanitized configuration or hosting setup.
+title: "support: "
+labels: ["support", "configuration"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Do not upload `config/config.php`, `config/token.json`, `.env`, real tokens, or customer data.
+ - type: dropdown
+ id: environment
+ attributes:
+ label: Environment
+ options:
+ - Shared hosting
+ - VPS
+ - Local Windows
+ - Local Linux/macOS
+ - Other
+ validations:
+ required: true
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What are you trying to configure or deploy?
+ validations:
+ required: true
+ - type: textarea
+ id: sanitized-config
+ attributes:
+ label: Sanitized config excerpt
+ description: Replace all secrets, domains, tokens, and customer values.
+ render: php
+ - type: textarea
+ id: checks
+ attributes:
+ label: Checks already run
+ placeholder: |
+ - composer install
+ - vendor/bin/phpunit
+ - opened public/ui.html?lead_id=...
diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml
new file mode 100644
index 0000000..bd4b8db
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/docs.yml
@@ -0,0 +1,23 @@
+name: "📚 Documentation issue"
+description: Report unclear or outdated docs.
+title: "docs: "
+labels: ["documentation"]
+body:
+ - type: input
+ id: page
+ attributes:
+ label: Page
+ description: Link or path to the affected doc page.
+ placeholder: docs/en/configuration.md
+ validations:
+ required: true
+ - type: textarea
+ id: issue
+ attributes:
+ label: What is unclear or outdated?
+ validations:
+ required: true
+ - type: textarea
+ id: suggested
+ attributes:
+ label: Suggested wording or structure
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..b7ad148
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,37 @@
+name: "✨ Feature request"
+description: Suggest a focused improvement.
+title: "feat: "
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What workflow should this improve?
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: Describe the smallest useful version.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area
+ options:
+ - API
+ - UI
+ - amoCRM integration
+ - DOCX templates
+ - Security
+ - Documentation
+ - Tests
+ validations:
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..1adb783
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,27 @@
+## 📌 Summary
+
+-
+
+## 🧭 Change Type
+
+- [ ] Docs
+- [ ] Bug fix
+- [ ] Feature
+- [ ] Refactor
+- [ ] Tests
+- [ ] Security/configuration
+
+## ✅ Test Plan
+
+- [ ] `.\vendor\bin\phpunit`
+- [ ] Docs links checked, if documentation changed
+- [ ] Config/security impact described, if applicable
+- [ ] No secrets, tokens, logs, generated documents, or customer data committed
+
+## 🔐 Security / Config Impact
+
+-
+
+## 📝 Notes
+
+-
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..76c4ee6
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,47 @@
+
📜 Code of Conduct
+
+
+ Clear, respectful, engineering-focused collaboration for AmoDocsEngine.
+
+
+
+ 🏠 README ·
+ 🤝 Contributing ·
+ 🛡️ Security ·
+ 📚 Docs
+
+
+---
+
+## ✅ Our Standard
+
+This project expects direct, practical, and professional collaboration. Keep feedback focused on the work: code, docs, tests, security, deployment behavior, and maintainability.
+
+| Expected | Why it matters |
+| --- | --- |
+| Use reproducible evidence | Maintainers can verify and fix issues faster |
+| Ask clear questions | Ambiguity is cheaper to resolve early |
+| Stay scoped to the project | Issues and PRs remain useful |
+| Protect secrets and CRM data | Public repositories are not safe for private data |
+
+## 👍 Good Behavior
+
+- Use clear technical arguments.
+- Provide sanitized reproduction steps.
+- Assume good intent when asking for clarification.
+- Respect security boundaries.
+- Keep issue and PR threads focused.
+
+## 🚫 Unacceptable Behavior
+
+- Harassment, insults, threats, or discriminatory language.
+- Publishing private information.
+- Sharing tokens, secrets, customer data, or private CRM data.
+- Repeated off-topic comments that make maintenance harder.
+- Posting exploit details in public issues.
+
+## 🛠️ Enforcement
+
+Maintainers may edit, hide, or remove comments and issues that violate this code. Repeated or severe violations may lead to blocking from the repository.
+
+Security-sensitive concerns must follow [SECURITY.md](SECURITY.md) instead of public issue discussion.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d4dc454
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,79 @@
+🤝 Contributing
+
+
+ Small, focused, verifiable changes for a shared-hosting friendly PHP document engine.
+
+
+
+ 🏠 README ·
+ 🧪 Development Docs ·
+ 🛡️ Security ·
+ 📜 Code of Conduct
+
+
+---
+
+## ⚡ Fast Path
+
+```powershell
+composer install
+Copy-Item config/config.example.php config/config.php
+.\vendor\bin\phpunit
+```
+
+## 🧭 Contribution Map
+
+| Change type | Start here | Required check |
+| --- | --- | --- |
+| Docs | `README.md`, `docs/en`, `docs/ru` | Link check + PHPUnit |
+| API behavior | `api/`, `src/` | PHPUnit test for behavior |
+| Templates | `templates/`, docs | Manual DOCX check if template changes |
+| Security | `src/Security`, `SECURITY.md` | Token/HMAC tests |
+| amoCRM integration | `src/AmoCrm` | Mocked client tests |
+
+## 🌿 Branch Names
+
+Use short, scoped branch names:
+
+- `docs/readme-polish`
+- `fix/generate-token-validation`
+- `refactor/template-registry`
+- `test/quote-service`
+
+## ✅ Pull Request Checklist
+
+Before opening a PR:
+
+- [ ] Run `.\vendor\bin\phpunit`.
+- [ ] Keep the PR focused on one concern.
+- [ ] Check changed docs links.
+- [ ] Explain config, deployment, or security impact.
+- [ ] Include sanitized examples only.
+
+## 🔒 Never Commit
+
+Use real secrets only in ignored local files. Never commit:
+
+- `config/config.php`
+- `config/token.json`
+- `.env`
+- `data/`
+- `documents/`
+- `logs/`
+- generated DOCX files
+- real amoCRM/customer data
+
+## 🧾 Commit Style
+
+Prefer concise concern-based commits:
+
+- `docs: update configuration guide`
+- `fix(frontend): use api base path`
+- `refactor(api): isolate note service`
+- `test: cover field id mapping`
+
+## 📚 Related Docs
+
+- [English development guide](docs/en/development.md)
+- [Russian development guide](docs/ru/development.md)
+- [Security policy](SECURITY.md)
diff --git a/README.md b/README.md
index 822cc1d..55674d6 100644
--- a/README.md
+++ b/README.md
@@ -1,53 +1,138 @@
-# AmoDocsEngine
+
+
+
-This repository contains a lightweight PHP integration that pulls amoCRM deal/contact data and renders `.docx` documents (orders, acts, etc.) using PhpWord templates. Everything (OAuth, API requests, template filling) runs inside a shared-hosting friendly project without background workers or queues.
+⚡ AmoDocsEngine
-## Features
+
+ Shared-hosting friendly amoCRM document generation engine for DOCX orders, acts, and custom templates.
+
-- amoCRM OAuth flow handled via `oauth.php`, refresh logic built into `api/generate.php`.
-- PhpWord `TemplateProcessor` replaces all placeholders inside `templates/*.docx`.
-- Prefill cache (`api/prefill.php`) stores the last list of products per deal to speed up repeat document creation.
-- Filesystem layout suits FTP/shared hosting: generated docs live in `documents/`, cached data in `data/`, logs in `logs/`.
+
+
+
+
+
+
+
-## Project structure
+
+ 🇬🇧 English docs ·
+ 🇷🇺 Документация ·
+ 🔌 API ·
+ 🛡️ Security ·
+ 🤝 Contributing ·
+ 📜 Code of Conduct
+
-- `api/` — HTTP endpoints that talk to amoCRM and produce docs or cached payloads.
-- `public/` — small UI to trigger generation (`ui.html`, `app.js`, `main.css`).
-- `src/` — reusable helpers (builders, formatters) tested with PHPUnit.
-- `templates/` — `.docx` Word templates with placeholders.
-- `documents/`, `logs/`, `data/` — writable directories for runtime artifacts.
-- `tests/` — PHPUnit suite (smoke + pure logic tests).
+---
-## Requirements
+## ✨ What It Does
-- PHP 7.4+ with `curl`, `intl`, `mbstring`, `zip`.
-- Composer (system or local `composer.phar`).
-- Writable directories: `documents/`, `logs/`, `data/`, `data/cache/`.
+AmoDocsEngine connects a small browser UI to amoCRM and generates `.docx` documents from Word templates. It keeps the deployment simple enough for shared hosting while still separating OAuth, security, field mapping, quote calculation, template rendering, cache, notes, and logging into focused PHP services.
-## Configuration checklist
+| Area | What is included |
+| --- | --- |
+| 🔐 Security | Server-issued `generate_token` for browser flow, optional HMAC mode for trusted server clients |
+| 🧾 Documents | PhpWord template rendering for orders, acts, and config-driven custom templates |
+| 📊 Totals | Backend-only quote calculation through `POST /api/quote.php` |
+| 🔗 amoCRM | OAuth token refresh, lead/contact loading, document note replacement |
+| 🗂️ Runtime | Prefill cache, generated documents, JSON logs, token storage |
-1. Edit `config/config.php`: fill amoCRM `client_id`, `client_secret`, `redirect_uri`, `base_domain`, paths if you deploy outside repo root, and optionally `hmac_secret`.
-2. Run the amoCRM auth flow (`oauth.php?code=...`) to create `config/token.json`.
-3. Ensure `public/app.js` points to your domain (update the `API` constant if necessary).
-4. Verify that `public/ui.html?lead_id=` opens and hits your API endpoints.
+## 🚀 Quick Start
-## Deployment / usage
+```powershell
+composer install
+Copy-Item config/config.example.php config/config.php
+.\vendor\bin\phpunit
+```
+
+Then fill `config/config.php`, run the amoCRM OAuth flow through `oauth.php?code=...`, and open:
+
+```text
+public/ui.html?lead_id=
+```
+
+## 🧭 Docs Map
+
+| Topic | English | Русский |
+| --- | --- | --- |
+| Start and deploy | [Getting started](docs/en/getting-started.md) | [Запуск](docs/ru/getting-started.md) |
+| Configuration | [Configuration](docs/en/configuration.md) | [Конфигурация](docs/ru/configuration.md) |
+| API contracts | [API](docs/en/api.md) | [API](docs/ru/api.md) |
+| Word templates | [Templates](docs/en/templates.md) | [Шаблоны](docs/ru/templates.md) |
+| Logs and migration | [Operations](docs/en/operations.md) | [Эксплуатация](docs/ru/operations.md) |
+| Development | [Development](docs/en/development.md) | [Разработка](docs/ru/development.md) |
+
+## 🏗️ Request Flow
+
+```mermaid
+sequenceDiagram
+ participant UI as Browser UI
+ participant Prefill as GET /api/prefill.php
+ participant Quote as POST /api/quote.php
+ participant Generate as POST /api/generate.php
+ participant Amo as amoCRM API
+ participant Docx as DOCX storage
+
+ UI->>Prefill: lead_id
+ Prefill-->>UI: cached form + generate_token
+ UI->>Quote: products + discount
+ Quote-->>UI: rows + totals + total_words
+ UI->>Generate: lead_id + template + products + generate_token
+ Generate->>Amo: fetch lead/contact
+ Generate->>Docx: render PhpWord template
+ Generate->>Amo: replace document note
+ Generate-->>UI: generated document URL
+```
-- Upload the repo to hosting, keep `config/`, `documents/`, `logs/`, `data/` out of public reach or tighten web-server rules.
-- Serve the UI from `public/` or link `ui.html` inside your amoCRM widget with `lead_id` query arg.
-- `POST /api/generate.php` expects `lead_id`, `template`, `discount`, `products[]`; it downloads deal/contact details, fills the template, saves `.docx` to `documents/` and posts a note back to amoCRM.
-- `GET /api/prefill.php?lead_id=` returns cached payload so the UI can restore the previous basket.
+Reusable diagram sources:
-## Local run & tests
+- [Architecture overview](docs/assets/architecture-overview.mmd)
+- [Workflow overview](docs/assets/workflow-overview.mmd)
-Install dependencies and run PHPUnit (commands below are for Windows PowerShell; on Linux/macOS drop the `.\` prefix).
+## 🧱 Module Boundaries
+
+```mermaid
+flowchart LR
+ API["api/
HTTP entrypoints"]
+ Amo["src/AmoCrm/
client, notes, fields"]
+ Docs["src/Documents/
quotes, registry, generation"]
+ Security["src/Security/
tokens, authentication"]
+ Storage["src/Storage/
prefill cache"]
+ Support["src/Support/
formatting, logging"]
+
+ API --> Amo
+ API --> Docs
+ API --> Security
+ API --> Storage
+ API --> Support
+```
+
+## 🧩 Main Endpoints
+
+| Endpoint | Purpose |
+| --- | --- |
+| `GET /api/prefill.php?lead_id=` | Restore cached form data and issue `generate_token` |
+| `POST /api/quote.php` | Calculate rows, totals, and amount in words |
+| `POST /api/generate.php` | Validate request, fetch amoCRM data, generate DOCX, update note |
+
+## 🧪 Verification
```powershell
composer install
.\vendor\bin\phpunit
```
-## Documentation
+Current suite covers URL routing, quote calculations, amoCRM client behavior, field ID mapping, template registry, security token validation, cache, notes, and logging.
+
+## 🏷️ Repository Setup Tips
+
+- **Description:** amoCRM document generation engine with OAuth, DOCX templates, secure browser flow, quote preview, and shared-hosting deployment.
+- **Topics:** `php`, `amocrm`, `docx`, `phpword`, `crm`, `document-generation`.
+- **Social preview:** upload `docs/assets/github-social-preview.png` in GitHub repository settings.
+- **Community:** keep [Security](SECURITY.md), [Contributing](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and GitHub issue templates visible.
+
+---
-For more detailed documentation (setup walkthrough, API examples, template placeholders) see `docs/README_en.md`.
-Для более подробной документации см. `docs/README_ru.md`.
+Made for pragmatic CRM document automation: small surface, clear modules, practical docs.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..cfb3870
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,81 @@
+🛡️ Security Policy
+
+
+ Protect amoCRM tokens, document data, and generation endpoints.
+
+
+
+ 🏠 README ·
+ 🤝 Contributing ·
+ ⚙️ Configuration ·
+ 📜 Code of Conduct
+
+
+---
+
+## ✅ Supported Branches
+
+| Branch | Status |
+| --- | --- |
+| `main` | Supported for security fixes |
+| older branches | Not supported |
+
+## 🚫 Sensitive Data
+
+Never publish or attach these files or values in public issues, pull requests, screenshots, logs, or comments:
+
+| Do not publish | Why |
+| --- | --- |
+| `config/config.php` | Contains integration secrets and deployment paths |
+| `config/token.json` | Contains amoCRM OAuth tokens |
+| `.env` | May contain secrets |
+| HMAC secrets | Can authorize requests |
+| amoCRM access/refresh tokens | Can access CRM data |
+| generated documents | May contain customer data |
+| raw customer CRM data | Private business data |
+
+Use `config/config.example.php` for public examples.
+
+## 📣 Reporting a Vulnerability
+
+If the report includes secrets, customer data, tokens, or a working exploit path, do **not** open a public issue.
+
+Send the maintainer a private report through the contact channel listed on the repository profile, or open a minimal public issue that says a private security report is needed without including sensitive details.
+
+Include:
+
+- affected endpoint or component;
+- expected vs actual behavior;
+- reproduction steps without real credentials;
+- suggested severity;
+- whether any token, document, or CRM data may be exposed.
+
+## 🔐 Request Authentication
+
+Browser generation should use server-issued `generate_token` values from `prefill.php`.
+
+HMAC mode is only for trusted server-to-server clients:
+
+| Mode | Intended use |
+| --- | --- |
+| `browser_token` | Browser UI flow |
+| `hmac` | Trusted backend client |
+| `either` | Transitional compatibility |
+
+Never expose `hmac_secret` in browser JavaScript.
+
+## 🧪 Security Checks
+
+Relevant test areas:
+
+- token validation;
+- HMAC secret requirements;
+- field ID mapping;
+- frontend API path safety;
+- cache and note-service behavior.
+
+Run:
+
+```powershell
+.\vendor\bin\phpunit
+```
diff --git a/api/generate.php b/api/generate.php
index 73bd363..8ff13bd 100644
--- a/api/generate.php
+++ b/api/generate.php
@@ -4,210 +4,60 @@
require __DIR__ . '/../vendor/autoload.php';
$config = require __DIR__ . '/../config/config.php';
date_default_timezone_set($config['timezone']);
-$DIR_MODE = $config['dir_mode'];
-$FILE_MODE = $config['file_mode'];
-use AmoDocGenerator\DocumentDataBuilder;
-use AmoDocGenerator\Support\RubleFormatter;
-use PhpOffice\PhpWord\TemplateProcessor;
+use AmoDocGenerator\AmoCrm\AmoCrmClient;
+use AmoDocGenerator\AmoCrm\AmoCrmNoteService;
+use AmoDocGenerator\Documents\DocumentGenerationService;
+use AmoDocGenerator\Security\GenerateTokenStore;
+use AmoDocGenerator\Security\RequestAuthenticator;
+use AmoDocGenerator\Storage\PrefillCache;
+use AmoDocGenerator\Support\JsonLogger;
-// Directory setup / Пути к директориям
-$baseDir = realpath(__DIR__ . '/..');
-$docDir = rtrim($config['document_path'], '/');
-$logDir = rtrim($config['logs_path'], '/');
-$prefDir = rtrim($config['temp_data_path'], '/').'/prefill';
-@is_dir($docDir) || @mkdir($docDir, $DIR_MODE, true);
-@is_dir($logDir) || @mkdir($logDir, $DIR_MODE, true);
-@is_dir($prefDir) || @mkdir($prefDir, $DIR_MODE, true);
-$LOG = $logDir . '/generate.log';
-$log = function($x) use($LOG){ file_put_contents($LOG, json_encode($x, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)."\n", FILE_APPEND); };
+$logger = JsonLogger::fromConfig($config, 'generate.log');
// get input data / получить входные данные
$raw = file_get_contents('php://input');
-if (!empty($config['hmac_secret'])) {
- $sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
- $calc = hash_hmac('sha256', $raw, $config['hmac_secret']);
- if (!hash_equals($calc, $sig)) { http_response_code(401); echo json_encode(['error'=>'bad signature']); exit; }
-}
$in = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) { http_response_code(400); echo json_encode(['error'=>'Bad JSON']); exit; }
$leadId = (int)($in['lead_id'] ?? 0);
-$template = (($in['template'] ?? 'order') === 'act') ? 'act' : 'order';
+$template = (string)($in['template'] ?? 'order');
$products = is_array($in['products'] ?? null) ? $in['products'] : [];
$discount = (int)($in['discount'] ?? 0);
if ($leadId <= 0 || !count($products)) { http_response_code(400); echo json_encode(['error'=>'Invalid lead_id or products']); exit; }
-
-// Load configuration and tokens / Загрузка конфигурации и токенов
-$tokenPath = $config['token_path'];
-$tokens = json_decode(@file_get_contents($tokenPath), true);
-
-function saveTokens(array $t, string $p){ $t['created_at']=time(); file_put_contents($p, json_encode($t, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)); }
-function refreshToken(array $cfg, array &$t, string $p){
- $payload = ['client_id'=>$cfg['client_id'],'client_secret'=>$cfg['client_secret'],'grant_type'=>'refresh_token','refresh_token'=>$t['refresh_token']??'','redirect_uri'=>$cfg['redirect_uri']];
- $ch=curl_init(); curl_setopt_array($ch,[CURLOPT_URL=>rtrim($cfg['base_domain'],'/').'/oauth2/access_token',CURLOPT_RETURNTRANSFER=>true,CURLOPT_HTTPHEADER=>['Content-Type: application/json'],CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>json_encode($payload, JSON_UNESCAPED_UNICODE),CURLOPT_TIMEOUT=>20]); $resp=curl_exec($ch); $code=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
- if($code!==200) throw new RuntimeException("REFRESH {$code}: {$resp}");
- $new=json_decode($resp,true); if(empty($new['access_token'])) throw new RuntimeException('REFRESH: empty access_token');
- $t=$new; saveTokens($t,$p);
-}
-function amoRequest(string $url, array &$t, array $cfg, string $p): array{
- $do=function($tk,$u){ $ch=curl_init(); curl_setopt_array($ch,[CURLOPT_URL=>$u,CURLOPT_RETURNTRANSFER=>true,CURLOPT_HTTPHEADER=>["Authorization: Bearer {$tk}"],CURLOPT_TIMEOUT=>25]); $r=curl_exec($ch); $c=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); return [$c,$r]; };
- [$c,$r]=$do($t['access_token']??'', $url);
- if($c===401){ refreshToken($cfg,$t,$p); [$c,$r]=$do($t['access_token'],$url); }
- if($c<200||$c>=300) throw new RuntimeException("AMO {$c}: {$r}");
- $j=json_decode($r,true); if(!is_array($j)) throw new RuntimeException('AMO bad JSON');
- return $j;
-}
-
-if (!function_exists('rublesToWords')) {
- function rublesToWords(int $n): string {
- return RubleFormatter::toWords($n);
- }
+if (!(new RequestAuthenticator($config, GenerateTokenStore::fromConfig($config)))->isAuthorized((string)$raw, $in, $_SERVER)) {
+ http_response_code(401);
+ echo json_encode(['error'=>'Unauthorized'], JSON_UNESCAPED_UNICODE);
+ exit;
}
+// Load amoCRM client / Загрузка клиента amoCRM
+$tokenPath = $config['token_path'];
+$amo = new AmoCrmClient($config, $tokenPath);
// Main logic / Основная логика
try{
// token check and refresh / проверка токена и обновление
- $lead = amoRequest(rtrim($config['base_domain'],'/')."/api/v4/leads/{$leadId}?with=contacts", $tokens, $config, $tokenPath);
+ $lead = $amo->get("/api/v4/leads/{$leadId}?with=contacts");
$cid = $lead['_embedded']['contacts'][0]['id'] ?? null;
- $contact = $cid ? amoRequest(rtrim($config['base_domain'],'/')."/api/v4/contacts/{$cid}", $tokens, $config, $tokenPath) : null;
-
- $fio = $contact['name'] ?? '';
- $phone = '';
- foreach (($contact['custom_fields_values'] ?? []) as $f) {
- if (($f['field_code'] ?? '') === 'PHONE') { $phone = $f['values'][0]['value'] ?? ''; break; }
- }
-
- // If no contact found, use lead's phone if available
- $fields = $lead['custom_fields_values'] ?? [];
- $getCF = function($fields,$name){ foreach($fields as $f){ if(($f['field_name']??'')===$name) return $f['values'][0]['value']??''; } return ''; };
-
- // template path / путь к шаблону
- $tplDir = rtrim($config['template_path'], '/');
- $tplFile = ($template === 'act') ? 'act_template.docx' : 'order_template.docx';
- $tpl = $tplDir . '/' . $tplFile;
- if (!is_file($tpl)) { http_response_code(500); echo json_encode(['error'=>'Template not found']); exit; }
-
- // чистим прошлые файлы этой сделки / clean up old files for this lead
- foreach (glob($docDir . "/doc_{$leadId}_*.docx") as $old) @unlink($old);
-
- $tp = new TemplateProcessor($tpl);
- // поля сделки и ФИО / deal fields and FIO
- $fields = $lead['custom_fields_values'] ?? [];
- $get = function($fields, $name){
- foreach ($fields as $f) if (($f['field_name'] ?? '') === $name) return $f['values'][0]['value'] ?? '';
- return '';
- };
-
- // ФИО из кастом-полей, если пусто — парсим contact.name / FIO from custom fields, if empty — parse contact.name
- list($p1,$p2,$p3) = array_pad(preg_split('/\s+/', trim($contact['name'] ?? ''), 3), 3, '');
- $lastName = $getCF($fields,'Фамилия') ?: $p1;
- $firstName = $getCF($fields,'Имя') ?: $p2;
- $middle = $getCF($fields,'Отчество') ?: $p3;
-
- // базовые поля сделки / basic deal fields
- $tp->setValue('Номер', $leadId);
- $tp->setValue('Дата', date('d.m.Y'));
- $tp->setValue('Телефон', $phone ? ' '.$phone : '');
- $tp->setValue('Марка', $getCF($fields,'Марка') ?: '—');
- $tp->setValue('Модель', $getCF($fields,'Модель') ?: '—');
- $tp->setValue('VIN', $getCF($fields,'VIN') ?: '—');
- $tp->setValue('Год выпуска', $getCF($fields,'Год выпуска') ?: '—');
-
- // ФИО / FIO
- $tp->setValue('Фамилия', $lastName);
- $tp->setValue('Имя', $firstName);
- $tp->setValue('Отчество', $middle);
-
- // табличка услуг / services table
- if ($template === 'order' && count($products)) {
- $rows = DocumentDataBuilder::buildRows($products);
- $tp->cloneRow('row_num', count($rows)); // клон по базовому тегу / clone by base tag
-
- foreach ($rows as $row) {
- $n = $row['index'];
- $tp->setValue("row_num#{$n}", $n);
- $tp->setValue("услуга_название#{$n}", $row['name']);
- $tp->setValue("row_qty#{$n}", $row['qty']);
- $tp->setValue("row_price#{$n}", number_format((int)$row['unit_price'], 0, ',', ' '));
- $tp->setValue("row_discount#{$n}", $row['discount_label']);
- $tp->setValue("row_sum#{$n}", number_format((int)$row['net_sum'], 0, ',', ' '));
- }
- }
-
-
- // Итоги из products: поддержка unit_price+qty, price, скидок по строке / Totals from products: support for unit_price+qty, price, discounts per line
- $summary = DocumentDataBuilder::summarize($products, (int)$discount);
- $sum_gross = $summary['sum_gross'];
- $sum_after = $summary['sum_after'];
- $global = $summary['discount'];
- $total = $summary['total'];
-
- $tp->setValue('Итого', $sum_gross);
- $tp->setValue('Скидка', $global);
- $tp->setValue('Всего к оплате', $total);
- $tp->setValue('Количество наименований', $summary['count']);
- $tp->setValue('Сумма прописью', rublesToWords($total));
-
- $filename = "doc_{$leadId}_" . time() . ".docx";
- $savePath = $docDir . '/' . $filename;
- $tp->saveAs($savePath);
- @chmod($savePath, $FILE_MODE);
- $publicDocs = rtrim($config['public_documents_url'], '/');
- $url = $publicDocs . '/' . rawurlencode($filename);
-
- // кэш для префилла (1–7 дней) / cache for prefill (1-7 days)
- $cacheDir = rtrim($config['cache_path'] ?? (rtrim($config['temp_data_path'],'/').'/cache'), '/');
- @mkdir($cacheDir, $DIR_MODE, true);
- file_put_contents(
- $cacheDir . '/' . $leadId . '.json',
- json_encode([
- 'saved_at' => time(),
- 'template' => $template,
- 'discount' => $discount,
- 'products' => $products
- ], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)
- );
-
-
- // примечание: удалить предыдущее (если можно), создать новое / note: delete previous (if possible), create new
- $metaPath = $prefDir . "/lead_{$leadId}_meta.json";
- $meta = is_file($metaPath) ? json_decode(file_get_contents($metaPath), true) : [];
- $prevNoteId = $meta['note_id'] ?? null;
-
- $notesBase = rtrim($config['base_domain'],'/') . '/api/v4/leads/notes';
- $do = function($method,$url,$token,$payload=null){
- $ch=curl_init(); curl_setopt_array($ch,[
- CURLOPT_URL=>$url, CURLOPT_CUSTOMREQUEST=>$method, CURLOPT_RETURNTRANSFER=>true,
- CURLOPT_HTTPHEADER=>array_filter(["Authorization: Bearer {$token}", $payload?"Content-Type: application/json":null]),
- CURLOPT_POSTFIELDS=>$payload?json_encode($payload, JSON_UNESCAPED_UNICODE):null, CURLOPT_TIMEOUT=>20
- ]); $resp=curl_exec($ch); $code=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); return [$code,$resp];
- };
+ $contact = $cid ? $amo->get("/api/v4/contacts/{$cid}") : null;
- // попытка удалить прошлое примечание (если API разрешит) / attempt to delete the previous note (if API allows)
- if ($prevNoteId) {
- [$dc,$dr] = $do('DELETE', $notesBase.'/'.(int)$prevNoteId, $tokens['access_token']);
- // игнорируем ошибки удаления — не критично / ignore delete errors — not critical
- }
+ $documentService = new DocumentGenerationService($config);
+ $document = $documentService->generate($lead + ['id' => $leadId], $contact, $template, $products, $discount);
+ $url = $document['url'];
- // создаём новое примечание / create a new note
- $title = ($template==='act' ? 'Акт приёма-передачи' : 'Заказ-наряд');
- $text = "{$title} №{$leadId}: {$url}";
- [$pc,$pr] = $do('POST', $notesBase, $tokens['access_token'], [[
- 'entity_id'=>(int)$leadId,'entity_type'=>'leads','note_type'=>'common','params'=>['text'=>$text]
- ]]);
- if ($pc>=200 && $pc<300) {
- $r = json_decode($pr, true);
- $newId = $r['_embedded']['notes'][0]['id'] ?? null;
- if ($newId) { $meta['note_id'] = $newId; file_put_contents($metaPath, json_encode($meta, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)); }
- }
+ PrefillCache::fromConfig($config)->write($leadId, $template, $discount, $products);
+ AmoCrmNoteService::fromClient($amo, rtrim($config['temp_data_path'], '/').'/prefill')
+ ->replaceDocumentNote($leadId, $template, $url);
echo json_encode(['url'=>$url], JSON_UNESCAPED_UNICODE);
+} catch (InvalidArgumentException $e) {
+ http_response_code(400);
+ echo json_encode(['error'=>$e->getMessage()], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e){
- $log(['EX'=>$e->getMessage(),'line'=>$e->getLine()]);
+ $logger->log(['EX'=>$e->getMessage(),'line'=>$e->getLine()]);
http_response_code(500);
echo json_encode(['error'=>'Internal Server Error'], JSON_UNESCAPED_UNICODE);
}
diff --git a/api/prefill.php b/api/prefill.php
index 7a4bd6c..3f68880 100644
--- a/api/prefill.php
+++ b/api/prefill.php
@@ -3,15 +3,17 @@
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
+require __DIR__ . '/../vendor/autoload.php';
+
+use AmoDocGenerator\Security\GenerateTokenStore;
+use AmoDocGenerator\Storage\PrefillCache;
+
+$config = require __DIR__ . '/../config/config.php';
$leadId = isset($_GET['lead_id']) ? (int)$_GET['lead_id'] : 0;
-$out = ['template'=>'order','discount'=>0,'products'=>[], 'saved_at'=>0];
+$out = PrefillCache::fromConfig($config)->read($leadId);
if ($leadId > 0) {
- $file = __DIR__ . '/../data/cache/' . $leadId . '.json';
- if (is_file($file)) {
- $j = json_decode(@file_get_contents($file), true);
- if (is_array($j)) $out = $j;
- }
+ $out['generate_token'] = GenerateTokenStore::fromConfig($config)->issue($leadId);
}
echo json_encode($out, JSON_UNESCAPED_UNICODE);
diff --git a/api/quote.php b/api/quote.php
new file mode 100644
index 0000000..e1793d8
--- /dev/null
+++ b/api/quote.php
@@ -0,0 +1,25 @@
+ 'Bad JSON'], JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+$products = is_array($input['products'] ?? null) ? $input['products'] : [];
+$discount = (int)($input['discount'] ?? 0);
+
+echo json_encode((new DocumentQuoteService())->quote($products, $discount), JSON_UNESCAPED_UNICODE);
diff --git a/config/config.example.php b/config/config.example.php
new file mode 100644
index 0000000..42e900f
--- /dev/null
+++ b/config/config.example.php
@@ -0,0 +1,46 @@
+ 'YOUR_CLIENT_ID',
+ 'client_secret' => 'YOUR_CLIENT_SECRET',
+ 'redirect_uri' => 'https://example.com/amo_doc_generator/oauth.php',
+ 'base_domain' => 'https://example.amocrm.ru',
+
+ 'template_path' => __DIR__ . '/../templates/',
+ 'document_path' => __DIR__ . '/../documents/',
+ 'temp_data_path' => __DIR__ . '/../data/',
+ 'logs_path' => __DIR__ . '/../logs/',
+ 'cache_path' => __DIR__ . '/../data/cache/',
+ 'token_path' => __DIR__ . '/token.json',
+
+ 'public_base_url' => 'https://example.com/amo_doc_generator',
+ 'public_documents_url' => 'https://example.com/amo_doc_generator/documents',
+
+ 'templates' => [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+ ],
+ 'amo_fields' => [
+ 'last_name' => 111111,
+ 'first_name' => 222222,
+ 'middle_name' => 333333,
+ 'car_make' => 444444,
+ 'car_model' => 555555,
+ 'vin' => 666666,
+ 'year' => 777777,
+ ],
+
+ 'security' => [
+ 'generate_auth_mode' => 'browser_token',
+ 'generate_token_ttl_seconds' => 1800,
+ 'generate_token_path' => __DIR__ . '/../data/security/generate_tokens.json',
+ 'hmac_secret' => '',
+ ],
+
+ 'doc_filename_pattern' => 'doc_{leadId}_{ts}.docx',
+ 'timezone' => 'Europe/Moscow',
+ 'dir_mode' => 0775,
+ 'file_mode' => 0640,
+ 'subdomain' => 'example',
+ 'prefill_ttl_days' => 5,
+];
diff --git a/docs/README_en.md b/docs/README_en.md
deleted file mode 100644
index 7c63c4b..0000000
--- a/docs/README_en.md
+++ /dev/null
@@ -1,287 +0,0 @@
-# Document Generator for amoCRM
-
-This project integrates with amoCRM deals to generate `.docx` documents based on Word templates and deal/contact data. Two templates are supported: **work order** and **act**. Documents are generated on the server and saved in the `documents/` folder, with a link returned to the UI.
-
----
-
-## 1) Architecture
-
-```
-amo_doc_generator/
-├─ api/
-│ ├─ generate.php # Generate .docx from form data and amoCRM
-│ └─ prefill.php # Return last saved form state by lead_id
-├─ config/
-│ ├─ config.php # Project settings and amoCRM OAuth data
-│ └─ token.json # Current OAuth tokens (auto-generated)
-├─ data/
-│ └─ cache/ # Cache for prefill by lead_id (auto-created)
-├─ documents/ # Generated .docx documents (auto-created)
-├─ logs/
-│ ├─ generate.log # Errors and exceptions
-│ └─ debug_generate.log # Debug logs
-├─ public/
-│ ├─ ui.html # User interface
-│ ├─ app.js # Form logic, API calls
-│ └─ main.css # UI styles
-├─ templates/
-│ ├─ order_template.docx # Work order template
-│ └─ act_template.docx # Act template
-├─ oauth.php # Exchange `code` → tokens and save to config/token.json
-├─ composer.json # Dependencies (phpoffice/phpword)
-└─ composer.phar # Local Composer (system Composer can also be used)
-```
-
-Backend: PHP 7.4+ with `phpoffice/phpword`
-Frontend: `public/ui.html` + `public/app.js` (fetches API and renders form).
-
----
-
-## 2) Server requirements
-
-- PHP **7.4+**.
-- PHP extensions: **zip**, **xml**, **mbstring**, **json**, **curl**.
-- Write permissions for: `documents/`, `logs/`, `data/`, `data/cache/`.
-- Composer (system `composer` or local `php composer.phar`).
-
-Install dependencies (from project root):
-```bash
-composer install
-# or
-php composer.phar install
-```
-
----
-
-## 3) Deployment to a new domain/location
-
-1. **Copy** the project folder to your hosting. Recommended base URL: `https:////amo_doc_generator`.
-2. Grant write permissions for `documents/`, `logs/`, `data/`:
- ```bash
- chmod -R 775 documents logs data
- # or 777 in environments without proper web process ownership
- ```
-3. In `public/app.js` **update the API base URL**:
- ```js
- // was:
- const API = 'https://apiport.ru/amo_doc_generator';
- // should point to your domain/path, e.g.:
- const API = 'https:////amo_doc_generator';
- // or use a relative path if UI and API are on the same host:
- // const API = '/amo_doc_generator';
- ```
-4. Verify that `https:////amo_doc_generator/public/ui.html` opens without browser errors.
-
----
-
-## 4) Connecting to a new amoCRM account (new integration)
-
-> Goal: obtain `client_id`, `client_secret`, `redirect_uri` and save OAuth tokens to `config/token.json`.
-
-### 4.1. Create a **private integration** in amoCRM
-- In amoCRM UI, create a new private integration.
-- Set **Redirect URI**:
- `https:////amo_doc_generator/oauth.php`
-- Save the generated **Client ID** and **Client Secret**.
-
-### 4.2. Update `config/config.php`
-```php
-return [
- 'client_id' => 'YOUR_CLIENT_ID',
- 'client_secret' => 'YOUR_CLIENT_SECRET',
- 'redirect_uri' => 'https:////amo_doc_generator/oauth.php',
- 'base_domain' => 'https://.amocrm.ru', // your amoCRM domain
- 'subdomain' => '',
-
- 'template_path' => __DIR__.'/../templates/',
- 'document_path' => __DIR__.'/../documents/',
- 'temp_data_path'=> __DIR__.'/../data/',
-
- 'prefill_ttl_days' => 5,
-];
-```
-
-### 4.3. Authorize the integration
-1. Follow the authorization link from the integration card in amoCRM **or** generate an authorization URL using the integration credentials.
-2. After granting access, you will be redirected to `oauth.php` with `?code=...`.
-3. The `oauth.php` script exchanges `code` for tokens and saves them in `config/token.json`.
-4. Token refresh happens **automatically** inside `api/generate.php` on amoCRM 401 responses.
-
-Verify: after authorization, `config/token.json` should exist with valid tokens.
-
----
-
-## 5) Embedding into deal card
-
-You can simply open the UI with `lead_id`:
-```
-https:////amo_doc_generator/public/ui.html?lead_id=
-```
-Ways to embed:
-- Button/link in the deal card pointing to this URL.
-- Embed via your widget as an iframe with the same URL.
-
-> `lead_id` is required for data prefill and cache to work.
-
----
-
-## 6) Using the UI
-
-1. Open `ui.html` with `lead_id` parameter.
-2. If cached form data exists for the deal, it will be loaded from `api/prefill.php`.
-3. Fill the table: **Name**, **Unit Price**, **Quantity**, **Row Discount %**.
-4. Optionally enter **total discount** in rubles (right-side field).
-5. Choose template: `order` (work order) or `act` (act).
-6. Click **Generate**. The response will contain a `.docx` link from `documents/`.
-
-Totals and amounts in words are calculated both on frontend and backend.
-
----
-
-## 7) API
-
-### `POST /api/generate.php`
-Input (JSON):
-```json
-{
- "lead_id": 123456,
- "template": "order", // or "act"
- "discount": 500, // total discount in rubles
- "products": [
- {
- "name": "Diagnostics",
- "unit_price": 1500,
- "qty": 1,
- "discount_percent": 0
- }
- ]
-}
-```
-Output:
-```json
-{ "url": "https:////amo_doc_generator/documents/.docx" }
-```
-Error codes: `400 Bad JSON/Invalid lead_id or products`, `500 Internal Server Error`.
-
-### `GET /api/prefill.php?lead_id=`
-Returns last saved form state for the deal:
-```json
-{
- "template": "order",
- "discount": 0,
- "products": [ ... ],
- "saved_at": 1710000000
-}
-```
-
----
-
-## 8) Data fetched from amoCRM
-
-`generate.php` fetches via amoCRM API:
-- Deal: `leads/{id}?with=contacts`
-- Linked contact (if exists).
-
-Used fields:
-- Name: priority is custom fields **"Last Name"**, **"First Name"**, **"Middle Name"** on the deal; otherwise from contact name.
-- Phone: `PHONE` field of contact.
-- Vehicle: **"Make"**, **"Model"**, **"VIN"**, **"Year of manufacture"** — must match **exact custom field names**.
-
-> If your field names differ, update mapping in `api/generate.php` (`setValue(...)` and `$getCF` function).
-
----
-
-## 9) Word templates and placeholders
-
-In `templates/` there are two files: `order_template.docx` and `act_template.docx`.
-PhpWord `TemplateProcessor` placeholders used:
-
-**Single fields**
-```
-${Number}
-${Date}
-${Phone}
-${Make}
-${Model}
-${VIN}
-${Year}
-${LastName}
-${FirstName}
-${MiddleName}
-${Total}
-${Discount}
-${TotalToPay}
-${ItemsCount}
-${AmountInWords}
-```
-
-**Table rows** (numbered `#1`, `#2`, ...):
-```
-${row_num#1}
-${service_name#1}
-${row_qty#1}
-${row_price#1}
-${row_discount#1} // "10%" or "-", if no discount
-${row_sum#1}
-```
-Rows are cloned according to the number of products.
-
-**Modifying templates**
-- Edit `.docx` only in MS Word/LibreOffice.
-- Do not change placeholder keys without updating code.
-- Adding a new template requires code changes (currently allowed: `order` and `act`).
-
----
-
-## 10) Logs and debugging
-
-- `logs/generate.log` — PHP errors/exceptions.
-- `logs/debug_generate.log` — debug info (deal ID, found fields, etc.).
-- On amoCRM 401, token is **automatically refreshed**. If refresh fails — check `config/config.php` and repeat **section 4.3**.
-
----
-
-## 11) Migration to another amoCRM account / new integration
-
-Checklist:
-1. Copy project to target hosting, grant write permissions to `documents/`, `logs/`, `data/`.
-2. Run `composer install`.
-3. Create **new private integration** in target amoCRM account.
-4. Update `config/config.php` (`client_id`, `client_secret`, `redirect_uri`, `base_domain`, `subdomain`).
-5. Authorize and complete **section 4.3** to create `config/token.json`.
-6. Update `public/app.js` → `API` constant to new domain/path.
-7. Test UI at `public/ui.html?lead_id=` and document generation.
-8. Adjust custom field mapping if necessary (section 8).
-
----
-
-## 12) Security
-
-- Do not keep `config/` and `documents/` in public root without directory listing restrictions.
-- Protect `config/token.json` from direct URL access.
-- Exclude `config/token.json` from version control.
-
----
-
-## 13) Known limitations
-
-- Global discount — **in rubles**, row discount — **in percent**.
-- Templates are fixed: `order` and `act`.
-- Exact custom field names are expected (see section 8).
-
----
-
-## 14) Quick smoke-test after installation
-
-```bash
-# 1) Check PHP dependencies
-php -m | grep -E 'zip|xml|mbstring|curl|json'
-
-# 2) Check UI
-open https:////amo_doc_generator/public/ui.html?lead_id=TEST_ID
-
-# 3) Test API
-curl -X POST https:////amo_doc_generator/api/generate.php -H 'Content-Type: application/json' -d '{"lead_id":123456,"template":"order","discount":0,"products":[{"name":"Test","unit_price":1000,"qty":1,"discount_percent":0}]}'
-
-# Expected {"url":"https://.../documents/.docx"}
-```
diff --git a/docs/README_ru.md b/docs/README_ru.md
deleted file mode 100644
index 4d74ca0..0000000
--- a/docs/README_ru.md
+++ /dev/null
@@ -1,287 +0,0 @@
-# Генератор документов для amoCRM
-
-Этот проект добавляет к сделке формирования документов `.docx` на основе шаблонов Word и данных сделки/контакта. Поддерживаются два шаблона: **заказ-наряд** и **акт**. Документы генерируются на сервере и сохраняются в папке `documents/`, после чего ссылка возвращается в интерфейс.
-
----
-
-## 1) Архитектура
-
-```
-amo_doc_generator/
-├─ api/
-│ ├─ generate.php # Генерация .docx по данным формы и из amoCRM
-│ └─ prefill.php # Возврат последнего сохраненного состояния формы по lead_id
-├─ config/
-│ ├─ config.php # Настройки проекта и OAuth-данные amoCRM
-│ └─ token.json # Акutальные OAuth-токены (автогенерация)
-├─ data/
-│ └─ cache/ # Кеш для префилла по lead_id (автосоздание)
-├─ documents/ # Сохраненные документы .docx (автосоздание)
-├─ logs/
-│ ├─ generate.log # Ошибки и исключения
-│ └─ debug_generate.log # Отладочные записи
-├─ public/
-│ ├─ ui.html # UI для пользователя
-│ ├─ app.js # Логика формы, отправка на API
-│ └─ main.css # Стили UI
-├─ templates/
-│ ├─ order_template.docx # Шаблон Заказ-наряда
-│ └─ act_template.docx # Шаблон Акта
-├─ oauth.php # Обмен `code` → токены и запись в config/token.json
-├─ composer.json # Зависимости (phpoffice/phpword)
-└─ composer.phar # Локальный Composer (можно использовать системный)
-```
-
-Бэкенд: PHP 7.4+ с `phpoffice/phpword`
-Фронтенд: `public/ui.html` + `public/app.js` (запрашивает API и отрисовывает форму).
-
----
-
-## 2) Требования к серверу
-
-- PHP **7.4+**.
-- Расширения PHP: **zip**, **xml**, **mbstring**, **json**, **curl**.
-- Права на запись для каталогов: `documents/`, `logs/`, `data/`, `data/cache/`.
-- Composer (можно системный `composer`, либо локальный `php composer.phar`).
-
-Установка зависимостей (из корня проекта):
-```bash
-composer install
-# или
-php composer.phar install
-```
-
----
-
-## 3) Настройка под новый домен/размещение
-
-1. **Скопируйте** папку проекта на хостинг. Рекомендуемый URL-базис: `https://<ваш-домен>/<путь>/amo_doc_generator`.
-2. Выдайте права на запись для `documents/`, `logs/`, `data/`:
- ```bash
- chmod -R 775 documents logs data
- # при необходимости 777 в окружениях без корректных владельцев веб-процесса
- ```
-3. В `public/app.js` **обновите базовый URL API**:
- ```js
- // было:
- const API = 'https://apiport.ru/amo_doc_generator';
- // должно указывать на ваш домен/путь, например:
- const API = 'https://<ваш-домен>/<путь>/amo_doc_generator';
- // или используйте относительный путь, если UI и API на одном хосте:
- // const API = '/amo_doc_generator';
- ```
-4. Убедитесь, что по адресу `https://<ваш-домен>/<путь>/amo_doc_generator/public/ui.html` UI открывается без ошибок браузера.
-
----
-
-## 4) Подключение к новому аккаунту amoCRM (новая интеграция)
-
-> Задача: получить `client_id`, `client_secret`, `redirect_uri` и сохранить OAuth-токены в `config/token.json`.
-
-### 4.1. Создайте **приватную интеграцию** в amoCRM
-- В интерфейсе amoCRM создайте новую интеграцию (приватную).
-- Укажите **Redirect URI**:
- `https://<ваш-домен>/<путь>/amo_doc_generator/oauth.php`
-- Сохраните выданные **Client ID** и **Client Secret**.
-
-### 4.2. Пропишите настройки в `config/config.php`
-```php
-return [
- 'client_id' => 'ВАШ_CLIENT_ID',
- 'client_secret' => 'ВАШ_CLIENT_SECRET',
- 'redirect_uri' => 'https://<ваш-домен>/<путь>/amo_doc_generator/oauth.php',
- 'base_domain' => 'https://.amocrm.ru', // домен вашего аккаунта
- 'subdomain' => '',
-
- 'template_path' => __DIR__.'/../templates/',
- 'document_path' => __DIR__.'/../documents/',
- 'temp_data_path'=> __DIR__.'/../data/',
-
- 'prefill_ttl_days' => 5,
-];
-```
-
-### 4.3. Авторизуйте интеграцию
-1. Перейдите по ссылке авторизации из карточки интеграции amoCRM **или** сформируйте URL авторизации по данным интеграции.
-2. После успешного согласия вы будете перенаправлены на `oauth.php` с параметром `?code=...`.
-3. Скрипт `oauth.php` выполнит обмен `code → access/refresh` и сохранит токены в `config/token.json`.
-4. Дальше обновление токенов выполняется **автоматически** внутри `api/generate.php` при получении ответа 401 от amoCRM.
-
-Проверка: после авторизации файл `config/token.json` должен появиться с валидными токенами.
-
----
-
-## 5) Встраивание в карточку сделки
-
-Минимально достаточно открыть UI со `lead_id`:
-```
-https://<ваш-домен>/<путь>/amo_doc_generator/public/ui.html?lead_id=
-```
-Способы:
-- Кнопка/ссылка в карточке сделки, ведущая на этот URL.
-- Встроить через ваш виджет как iframe с тем же URL.
-
-> Параметр `lead_id` обязателен для подстановки данных и работы кеша префилла.
-
----
-
-## 6) Как пользоваться UI
-
-1. Откройте `ui.html` с параметром `lead_id`.
-2. При наличии кеша формы по этой сделке скрипт подтянет его из `api/prefill.php`.
-3. Заполните таблицу позиций: **Название**, **Цена за единицу**, **Кол-во**, **Скидка % на строку**.
-4. При необходимости укажите **общую скидку** в рублях (поле «Скидка» справа).
-5. Выберите **шаблон**: `order` (заказ-наряд) или `act` (акт).
-6. Нажмите **«Сформировать»**. В ответ вернется ссылка на `.docx` в `documents/`.
-
-Итоги и суммы считаются на фронтенде и продублированы на бэкенде. Число прописью формируется на обеих сторонах.
-
----
-
-## 7) API
-
-### `POST /api/generate.php`
-Вход (JSON):
-```json
-{
- "lead_id": 123456,
- "template": "order", // или "act"
- "discount": 500, // общая скидка в рублях
- "products": [
- {
- "name": "Диагностика",
- "unit_price": 1500,
- "qty": 1,
- "discount_percent": 0
- }
- ]
-}
-```
-Выход:
-```json
-{ "url": "https://<домен>/<путь>/amo_doc_generator/documents/<файл>.docx" }
-```
-Коды ошибок: `400 Bad JSON/Invalid lead_id or products`, `500 Internal Server Error`.
-
-### `GET /api/prefill.php?lead_id=`
-Возвращает последнее сохраненное состояние формы для сделки:
-```json
-{
- "template": "order",
- "discount": 0,
- "products": [ ... ],
- "saved_at": 1710000000
-}
-```
-
----
-
-## 8) Данные, подтягиваемые из amoCRM
-
-`generate.php` получает по API amoCRM:
-- Сделку `leads/{id}?with=contacts`.
-- Связанный контакт (если есть).
-
-Используются значения полей:
-- ФИО: приоритет — кастомные поля **«Фамилия»**, **«Имя»**, **«Отчество»** на сделке; иначе берутся из имени контакта.
-- Телефон: поле `PHONE` у контакта (если найден).
-- Технические поля авто: **«Марка»**, **«Модель»**, **«VIN»**, **«Год выпуска»** — берутся из **кастомных полей** сделки по **точному названию**.
-
-> Если у вас другие названия полей, скорректируйте маппинг в `api/generate.php` (поиском `setValue('Марка'|...)` и функцией `$getCF`).
-
----
-
-## 9) Шаблоны Word и плейсхолдеры
-
-В `templates/` лежат два файла: `order_template.docx` и `act_template.docx`.
-Используются плейсхолдеры PhpWord `TemplateProcessor`:
-
-**Единичные поля**
-```
-${Номер}
-${Дата}
-${Телефон}
-${Марка}
-${Модель}
-${VIN}
-${Год выпуска}
-${Фамилия}
-${Имя}
-${Отчество}
-${Итого}
-${Скидка}
-${Всего к оплате}
-${Количество наименований}
-${Сумма прописью}
-```
-
-**Табличные строки** (нумерация `#1`, `#2`, ...):
-```
-${row_num#1}
-${услуга_название#1}
-${row_qty#1}
-${row_price#1}
-${row_discount#1} // «10%» или «-», если скидки нет
-${row_sum#1}
-```
-При генерации строки клонируются по количеству позиций.
-
-**Изменение шаблонов**
-- Редактируйте `.docx` только в MS Word/LibreOffice.
-- Не изменяйте ключи плейсхолдеров в коде без синхронизации с файлами.
-- Для добавления нового шаблона потребуется доработка кода (сейчас допустимы значения `order` и `act`).
-
----
-
-## 10) Логи и диагностика
-
-- `logs/generate.log` — ошибки/исключения PHP.
-- `logs/debug_generate.log` — диагностические записи (id сделки, найденные поля и т.п.).
-- При ошибке 401 по amoCRM токен **обновляется автоматически** через refresh. Если refresh не сработал — проверьте `config/config.php` и заново выполните **раздел 4.3**.
-
----
-
-## 11) Миграция на другой аккаунт amoCRM / новую интеграцию
-
-Чек-лист:
-1. Скопировать проект на целевой хостинг, выдать права на `documents/`, `logs/`, `data/`.
-2. Выполнить `composer install`.
-3. Создать **новую приватную интеграцию** в целевом аккаунте amoCRM.
-4. Обновить `config/config.php` (`client_id`, `client_secret`, `redirect_uri`, `base_domain`, `subdomain`).
-5. Открыть ссылку авторизации и пройти **раздел 4.3**, чтобы записался `config/token.json`.
-6. Обновить `public/app.js` → константа `API` под новый домен/путь.
-7. Проверить открытие UI по `public/ui.html?lead_id=` и генерацию документов.
-8. При необходимости адаптировать маппинг кастомных полей (раздел 8).
-
----
-
-## 12) Безопасность
-
-- Не храните `config/` и `documents/` в общедоступном корне без ограничений листинга. На Apache/Nginx запретите индексирование директорий.
-- `config/token.json` — чувствительный файл. Убедитесь, что к нему нет прямого внешнего доступа по URL.
-- При публикации репозитория исключайте `config/token.json` из VCS.
-
----
-
-## 13) Известные ограничения
-
-- Глобальная скидка — **в рублях**, скидка по строкам — **в процентах**.
-- Шаблоны фиксированы: `order` и `act`.
-- Ожидаются конкретные названия кастомных полей (см. раздел 8).
-
----
-
-## 14) Быстрый smoke-test после установки
-
-```bash
-# 1) Проверка PHP-зависимостей
-php -m | grep -E 'zip|xml|mbstring|curl|json'
-
-# 2) Проверка UI
-open https://<домен>/<путь>/amo_doc_generator/public/ui.html?lead_id=TEST_ID
-
-# 3) Тест API (пример)
-curl -X POST https://<домен>/<путь>/amo_doc_generator/api/generate.php -H 'Content-Type: application/json' -d '{"lead_id":123456,"template":"order","discount":0,"products":[{"name":"Тест","unit_price":1000,"qty":1,"discount_percent":0}]}'
-
-# Ожидается {"url":"https://.../documents/<файл>.docx"}
-```
diff --git a/docs/assets/architecture-overview.mmd b/docs/assets/architecture-overview.mmd
new file mode 100644
index 0000000..8326b02
--- /dev/null
+++ b/docs/assets/architecture-overview.mmd
@@ -0,0 +1,14 @@
+flowchart LR
+ UI["Browser UI
public/ui.html + app.js"]
+ API["HTTP endpoints
generate / prefill / quote"]
+ Security["Security
GenerateTokenStore + HMAC"]
+ Amo["amoCRM
leads, contacts, notes"]
+ Docs["Documents
PhpWord + templates"]
+ Storage["Runtime storage
cache, tokens, logs"]
+
+ UI --> API
+ API --> Security
+ API --> Amo
+ API --> Docs
+ API --> Storage
+ Docs --> Storage
diff --git a/docs/assets/github-social-preview.png b/docs/assets/github-social-preview.png
new file mode 100644
index 0000000..79ab8d0
Binary files /dev/null and b/docs/assets/github-social-preview.png differ
diff --git a/docs/assets/workflow-overview.mmd b/docs/assets/workflow-overview.mmd
new file mode 100644
index 0000000..2dab102
--- /dev/null
+++ b/docs/assets/workflow-overview.mmd
@@ -0,0 +1,17 @@
+sequenceDiagram
+ participant UI as Browser UI
+ participant Prefill as GET /api/prefill.php
+ participant Quote as POST /api/quote.php
+ participant Generate as POST /api/generate.php
+ participant Amo as amoCRM API
+ participant Docx as DOCX storage
+
+ UI->>Prefill: lead_id
+ Prefill-->>UI: cached form + generate_token
+ UI->>Quote: products + discount
+ Quote-->>UI: rows + totals + total_words
+ UI->>Generate: lead_id + template + products + generate_token
+ Generate->>Amo: fetch lead/contact
+ Generate->>Docx: render PhpWord template
+ Generate->>Amo: replace document note
+ Generate-->>UI: generated document URL
diff --git a/docs/en/api.md b/docs/en/api.md
new file mode 100644
index 0000000..48d93b2
--- /dev/null
+++ b/docs/en/api.md
@@ -0,0 +1,114 @@
+🔌 API Reference
+
+
+ HTTP contracts for prefill, quote preview, and document generation.
+
+
+
+ 📚 Docs ·
+ ⚙️ Configuration ·
+ 🧾 Templates ·
+ 🇷🇺 RU
+
+
+---
+
+## 🧭 Endpoint Map
+
+| Endpoint | Auth | Purpose |
+| --- | --- | --- |
+| `GET /api/prefill.php?lead_id=` | none | Restore cached form and issue `generate_token` |
+| `POST /api/quote.php` | none | Calculate backend totals for UI preview |
+| `POST /api/generate.php` | `generate_token` or HMAC | Generate DOCX and update amoCRM note |
+
+All responses are JSON unless a generated document URL points to a DOCX file.
+
+## `GET /api/prefill.php?lead_id=`
+
+Restores cached form data and issues a server-side token for generation.
+
+```json
+{
+ "template": "order",
+ "discount": 0,
+ "products": [],
+ "saved_at": 1710000000,
+ "generate_token": "..."
+}
+```
+
+## `POST /api/quote.php`
+
+Calculates rows, totals, and amount in words on the backend.
+
+```json
+{
+ "discount": 500,
+ "products": [
+ {"name": "Diagnostics", "unit_price": 1500, "qty": 1, "discount_percent": 0}
+ ]
+}
+```
+
+Returns `rows`, `sum_gross`, `sum_after`, `discount`, `total`, `count`, and `total_words`.
+
+Example response:
+
+```json
+{
+ "rows": [
+ {
+ "index": 1,
+ "name": "Diagnostics",
+ "qty": 1,
+ "unit_price": 1500,
+ "discount_label": "-",
+ "net_sum": 1500
+ }
+ ],
+ "sum_gross": 1500,
+ "sum_after": 1500,
+ "discount": 500,
+ "total": 1000,
+ "count": 1,
+ "total_words": "одна тысяча рублей"
+}
+```
+
+## `POST /api/generate.php`
+
+Generates the document and writes a note back to amoCRM.
+
+```json
+{
+ "lead_id": 123456,
+ "template": "order",
+ "discount": 500,
+ "generate_token": "...",
+ "products": [
+ {"name": "Diagnostics", "unit_price": 1500, "qty": 1, "discount_percent": 0}
+ ]
+}
+```
+
+Response:
+
+```json
+{"url": "https:///documents/doc_123456_1710000000.docx"}
+```
+
+Error codes: `400` for invalid input or unknown templates, `401` for missing/invalid auth, `500` for internal errors.
+
+## HMAC Server Client
+
+When `generate_auth_mode` is `hmac`, send `X-Signature` as:
+
+```text
+hash_hmac('sha256', raw_json_body, hmac_secret)
+```
+
+Use exactly the raw body bytes sent to `generate.php`. Do not sign a re-encoded JSON object.
+
+---
+
+**Next:** [Templates](templates.md) · **Back:** [Configuration](configuration.md)
diff --git a/docs/en/configuration.md b/docs/en/configuration.md
new file mode 100644
index 0000000..7de8988
--- /dev/null
+++ b/docs/en/configuration.md
@@ -0,0 +1,117 @@
+⚙️ Configuration
+
+
+ Configure OAuth, field IDs, templates, paths, and request security without committing secrets.
+
+
+
+ 📚 Docs ·
+ 🚀 Start ·
+ 🔌 API ·
+ 🇷🇺 RU
+
+
+---
+
+## 🧭 At a Glance
+
+| Config area | File / key |
+| --- | --- |
+| OAuth | `client_id`, `client_secret`, `redirect_uri`, `base_domain` |
+| Fields | `amo_fields` by amoCRM field ID |
+| Security | `security.generate_auth_mode` |
+| Templates | `templates` registry |
+| Runtime | paths for documents, logs, cache, tokens |
+
+`config/config.php` is ignored by git because it contains secrets. Start from `config/config.example.php`.
+
+## Required amoCRM Settings
+
+- `client_id`
+- `client_secret`
+- `redirect_uri`
+- `base_domain`
+- `subdomain`
+- `token_path`
+
+Use the full amoCRM domain in `base_domain`, for example `https://example.amocrm.ru`.
+
+## Field Mapping
+
+Deal custom fields are mapped by amoCRM field ID, not by display name:
+
+```php
+'amo_fields' => [
+ 'last_name' => 111111,
+ 'first_name' => 222222,
+ 'middle_name' => 333333,
+ 'car_make' => 444444,
+ 'car_model' => 555555,
+ 'vin' => 666666,
+ 'year' => 777777,
+],
+```
+
+Renaming fields in amoCRM is safe as long as these IDs stay correct. Contact phone uses the stable amoCRM `PHONE` field code.
+
+## Security
+
+Browser generation uses server-issued `generate_token` values from `prefill.php`.
+
+HMAC mode is for trusted server-to-server clients only:
+
+```php
+'security' => [
+ 'generate_auth_mode' => 'browser_token',
+ 'generate_token_ttl_seconds' => 1800,
+ 'hmac_secret' => '',
+],
+```
+
+Supported modes:
+
+| Mode | Use case |
+| --- | --- |
+| `browser_token` | Default UI flow. `prefill.php` issues `generate_token`; `generate.php` validates it. |
+| `hmac` | Trusted backend integration signs the raw request body with `X-Signature`. |
+| `either` | Transitional mode for clients that may use browser token or HMAC. |
+
+Do not put an HMAC secret into browser JavaScript. If `hmac` mode is enabled, `hmac_secret` must be non-empty.
+
+## Runtime Paths
+
+| Key | Purpose |
+| --- | --- |
+| `template_path` | Source `.docx` templates |
+| `document_path` | Generated document files |
+| `public_documents_url` | Public URL prefix for generated documents |
+| `temp_data_path` | Runtime state root |
+| `cache_path` | Prefill cache |
+| `logs_path` | JSON logs |
+| `token_path` | amoCRM OAuth tokens |
+
+## Templates
+
+```php
+'templates' => [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+],
+```
+
+Add another key here to expose a new template through the registry.
+
+## Do Not Commit
+
+These files must stay out of git:
+
+- `config/config.php`
+- `config/token.json`
+- `.env`
+- `data/`
+- `documents/`
+- `logs/`
+
+---
+
+**Next:** [API](api.md) · **Back:** [Getting Started](getting-started.md)
diff --git a/docs/en/development.md b/docs/en/development.md
new file mode 100644
index 0000000..4a0dcec
--- /dev/null
+++ b/docs/en/development.md
@@ -0,0 +1,66 @@
+🧪 Development
+
+
+ Understand the module boundaries, local checks, and contribution workflow.
+
+
+
+ 📚 Docs ·
+ 🛠️ Operations ·
+ 🤝 Contributing ·
+ 🇷🇺 RU
+
+
+---
+
+## 🧭 Development Map
+
+| Layer | Directory |
+| --- | --- |
+| HTTP entrypoints | `api/` |
+| Domain services | `src/` |
+| Runtime config example | `config/config.example.php` |
+| PHPUnit coverage | `tests/` |
+
+## Project Layout
+
+- `api/` contains HTTP entrypoints.
+- `src/AmoCrm/` contains amoCRM client, notes, and field mapping.
+- `src/Documents/` contains quote calculation, template registry, and DOCX generation.
+- `src/Security/` contains browser tokens and request authentication.
+- `src/Storage/` contains prefill cache.
+- `src/Support/` contains formatters and logging helpers.
+- `tests/` contains PHPUnit tests.
+
+## Local Checks
+
+```powershell
+composer install
+.\vendor\bin\phpunit
+```
+
+Optional PHP syntax check:
+
+```powershell
+$files = Get-ChildItem -Recurse -Filter *.php | Where-Object { $_.FullName -notlike '*\vendor\*' }
+foreach ($file in $files) { php -l $file.FullName }
+```
+
+## Contribution Rules
+
+- Use feature branches such as `docs/readme-polish` or `fix/template-registry`.
+- Do not commit `config/config.php`, `config/token.json`, `.env`, logs, cache files, or generated documents.
+- Keep docs links relative and check them before opening a PR.
+
+## Commit Style
+
+Use small concern-based commits:
+
+- `docs: update api guide`
+- `fix(frontend): use api base path`
+- `refactor(api): isolate prefill cache`
+- `test: cover token validation`
+
+---
+
+**Back:** [Documentation index](index.md) · **Community:** [Contributing](../../CONTRIBUTING.md)
diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md
new file mode 100644
index 0000000..bd82751
--- /dev/null
+++ b/docs/en/getting-started.md
@@ -0,0 +1,87 @@
+🚀 Getting Started
+
+
+ Install AmoDocsEngine, connect amoCRM OAuth, and open the document generation UI.
+
+
+
+ 📚 Docs ·
+ ⚙️ Configuration ·
+ 🔌 API ·
+ 🇷🇺 RU
+
+
+---
+
+## 🧭 At a Glance
+
+| Step | Outcome |
+| --- | --- |
+| Install dependencies | `vendor/` is ready |
+| Copy config | ignored `config/config.php` exists |
+| Run OAuth | `config/token.json` exists |
+| Open UI | `public/ui.html?lead_id=` loads |
+
+## Requirements
+
+- PHP 7.4+ with `curl`, `intl`, `mbstring`, and `zip`.
+- Composer.
+- Writable runtime directories: `documents/`, `logs/`, `data/`, `data/cache/`.
+
+## Install
+
+```bash
+composer install
+cp config/config.example.php config/config.php
+```
+
+On Windows PowerShell:
+
+```powershell
+composer install
+Copy-Item config/config.example.php config/config.php
+```
+
+## Deploy Files
+
+Upload the project to the hosting directory that will serve the UI and API. Keep these directories writable by the PHP process:
+
+```bash
+chmod -R 775 documents logs data
+```
+
+If the host has strict ownership rules, fix ownership first and use `777` only as a last resort.
+
+## Configure amoCRM OAuth
+
+1. Create a private amoCRM integration.
+2. Set Redirect URI to your deployed `oauth.php` URL.
+3. Fill `client_id`, `client_secret`, `redirect_uri`, `base_domain`, and `subdomain` in `config/config.php`.
+4. Open the amoCRM authorization URL and complete the flow.
+5. Confirm that `config/token.json` exists.
+
+The refresh flow is automatic: when amoCRM returns `401`, `AmoCrmClient` refreshes the access token and retries the request.
+
+## Open the UI
+
+```text
+https:////public/ui.html?lead_id=
+```
+
+If the UI and API are not served from the same root, update `API_BASE` in `public/app.js`.
+
+## First Validation
+
+Run the backend quote endpoint before testing a real document:
+
+```bash
+curl -X POST https:////api/quote.php \
+ -H 'Content-Type: application/json' \
+ -d '{"discount":0,"products":[{"name":"Test","unit_price":1000,"qty":1}]}'
+```
+
+Expected: JSON with `"total":1000` and a Russian `total_words` value.
+
+---
+
+**Next:** [Configuration](configuration.md) · **Back:** [Documentation index](index.md)
diff --git a/docs/en/index.md b/docs/en/index.md
new file mode 100644
index 0000000..aef85f4
--- /dev/null
+++ b/docs/en/index.md
@@ -0,0 +1,96 @@
+
+
+
+
+📚 AmoDocsEngine Documentation
+
+
+ Install, configure, operate, and extend a secure amoCRM → DOCX automation engine.
+
+
+
+ 🏠 README ·
+ 🇷🇺 Русский ·
+ 🔌 API ·
+ 🛡️ Security
+
+
+---
+
+## 🧭 Navigation
+
+| Section | Use it for |
+| --- | --- |
+| [🚀 Getting Started](getting-started.md) | Install dependencies, deploy files, run OAuth, open the UI |
+| [⚙️ Configuration](configuration.md) | `config/config.php`, `amo_fields`, templates, security and paths |
+| [🔌 API](api.md) | `prefill.php`, `quote.php`, `generate.php` request/response contracts |
+| [🧾 Templates](templates.md) | DOCX placeholders, row cloning, adding new document templates |
+| [🛠️ Operations](operations.md) | Logs, troubleshooting, migration, smoke tests |
+| [🧪 Development](development.md) | Project structure, tests, local workflow, contribution checks |
+
+## ⚡ Fast Route
+
+1. Install dependencies with `composer install`.
+2. Copy `config/config.example.php` to `config/config.php`.
+3. Fill amoCRM OAuth credentials and `amo_fields` IDs.
+4. Run OAuth through `oauth.php?code=...`.
+5. Open `public/ui.html?lead_id=`.
+
+## 🧩 Documentation Type Map
+
+| Type | Pages |
+| --- | --- |
+| Tutorial | [Getting Started](getting-started.md) |
+| How-to | [Configuration](configuration.md), [Templates](templates.md), [Operations](operations.md) |
+| Reference | [API](api.md), [Development](development.md) |
+| Explanation | Architecture and workflow sections below |
+
+## 🏗️ Architecture Snapshot
+
+The browser UI calls small PHP endpoints. Backend services handle request auth, quote calculation, amoCRM API calls, DOCX rendering, prefill cache, notes, and logs.
+
+```mermaid
+flowchart LR
+ UI["Browser UI"]
+ API["api/ endpoints"]
+ Security["src/Security"]
+ Amo["src/AmoCrm"]
+ Documents["src/Documents"]
+ Storage["src/Storage"]
+ Logs["src/Support"]
+
+ UI --> API
+ API --> Security
+ API --> Amo
+ API --> Documents
+ API --> Storage
+ API --> Logs
+```
+
+## 🔁 Generation Workflow
+
+```mermaid
+sequenceDiagram
+ participant UI as Browser UI
+ participant Prefill as prefill.php
+ participant Quote as quote.php
+ participant Generate as generate.php
+ participant Amo as amoCRM
+ participant Docx as DOCX
+
+ UI->>Prefill: request cached state
+ Prefill-->>UI: products + generate_token
+ UI->>Quote: products + discount
+ Quote-->>UI: backend totals
+ UI->>Generate: token + template + products
+ Generate->>Amo: lead/contact
+ Generate->>Docx: render template
+ Generate->>Amo: replace note
+ Generate-->>UI: document URL
+```
+
+Diagram sources: [architecture](../assets/architecture-overview.mmd), [workflow](../assets/workflow-overview.mmd).
+
+---
+
+**Next:** [Getting Started](getting-started.md) · **Русский:** [Документация](../ru/index.md)
diff --git a/docs/en/operations.md b/docs/en/operations.md
new file mode 100644
index 0000000..5c82c40
--- /dev/null
+++ b/docs/en/operations.md
@@ -0,0 +1,78 @@
+🛠️ Operations
+
+
+ Run smoke checks, read logs, troubleshoot hosting issues, and migrate amoCRM accounts.
+
+
+
+ 📚 Docs ·
+ 🧾 Templates ·
+ 🧪 Development ·
+ 🇷🇺 RU
+
+
+---
+
+## 🧭 Operations Map
+
+| Area | First file to check |
+| --- | --- |
+| Runtime data | `data/`, `documents/`, `logs/` |
+| OAuth | `config/token.json` |
+| Request auth | `data/security/generate_tokens.json` |
+| Exceptions | `logs/generate.log` |
+
+## Runtime Files
+
+- `config/token.json` stores OAuth tokens.
+- `data/cache/` stores prefill payloads.
+- `data/security/generate_tokens.json` stores short-lived browser generation tokens.
+- `documents/` stores generated DOCX files.
+- `logs/generate.log` stores JSON error events.
+
+Keep `config/`, `data/`, `documents/`, and `logs/` outside direct public access when possible. If hosting exposes them, disable directory listing and block sensitive files.
+
+## Smoke Test
+
+```bash
+php -m | grep -E 'zip|xml|mbstring|curl|json'
+composer install
+vendor/bin/phpunit
+```
+
+On Windows:
+
+```powershell
+.\vendor\bin\phpunit
+```
+
+## Troubleshooting
+
+| Symptom | Check |
+| --- | --- |
+| `401 Unauthorized` | UI called `prefill.php` first and sends `generate_token` |
+| amoCRM `401` | OAuth token exists and refresh token is valid |
+| Empty vehicle fields | `amo_fields` IDs match the deal custom fields |
+| Template not found | Template key exists and file is present in `templates/` |
+| Document URL broken | `public_documents_url` matches hosting path |
+
+## Log Reading
+
+`logs/generate.log` is append-only JSON lines. Typical entries contain:
+
+```json
+{
+ "EX": "Template not found",
+ "line": 123
+}
+```
+
+Use the error message to decide whether the failure is template, amoCRM, security, or filesystem related.
+
+## Migration
+
+For another amoCRM account, update OAuth credentials, `base_domain`, `subdomain`, `amo_fields`, and run OAuth again to create a fresh `config/token.json`.
+
+---
+
+**Next:** [Development](development.md) · **Back:** [Templates](templates.md)
diff --git a/docs/en/templates.md b/docs/en/templates.md
new file mode 100644
index 0000000..c8a15c7
--- /dev/null
+++ b/docs/en/templates.md
@@ -0,0 +1,91 @@
+🧾 Word Templates
+
+
+ Design DOCX templates, placeholders, table rows, and new document types.
+
+
+
+ 📚 Docs ·
+ 🔌 API ·
+ 🛠️ Operations ·
+ 🇷🇺 RU
+
+
+---
+
+## 🧭 Template Map
+
+| Template key | DOCX file | Purpose |
+| --- | --- | --- |
+| `order` | `order_template.docx` | Work order with service rows |
+| `act` | `act_template.docx` | Acceptance/transfer act |
+| custom | configured in `templates` | Any added document type |
+
+Templates live in `templates/` and are rendered through PhpWord `TemplateProcessor`.
+
+## Built-in Templates
+
+- `order` → `order_template.docx`
+- `act` → `act_template.docx`
+
+## Common Placeholders
+
+- `${Номер}`
+- `${Дата}`
+- `${Телефон}`
+- `${Марка}`
+- `${Модель}`
+- `${VIN}`
+- `${Год выпуска}`
+- `${Фамилия}`
+- `${Имя}`
+- `${Отчество}`
+- `${Итого}`
+- `${Скидка}`
+- `${Всего к оплате}`
+- `${Количество наименований}`
+- `${Сумма прописью}`
+
+## Order Table Rows
+
+The order template clones rows by `row_num` and fills:
+
+- `${row_num}`
+- `${услуга_название}`
+- `${row_qty}`
+- `${row_price}`
+- `${row_discount}`
+- `${row_sum}`
+
+## Adding a Template
+
+1. Add a `.docx` file to `templates/`.
+2. Add a key in `config/config.php` under `templates`.
+3. Send that key as `template` from the UI or API client.
+
+Example:
+
+```php
+'templates' => [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+ 'invoice' => 'invoice_template.docx',
+],
+```
+
+Then call:
+
+```json
+{"template": "invoice"}
+```
+
+## Template Safety Checklist
+
+- Keep placeholder names exact.
+- Keep table row placeholders inside the row that should be cloned.
+- Do not rename existing keys unless the backend service is updated.
+- Upload templates as `.docx`, not `.doc`.
+
+---
+
+**Next:** [Operations](operations.md) · **Back:** [API](api.md)
diff --git a/docs/ru/api.md b/docs/ru/api.md
new file mode 100644
index 0000000..ce4e113
--- /dev/null
+++ b/docs/ru/api.md
@@ -0,0 +1,114 @@
+🔌 API Reference
+
+
+ HTTP-контракты для prefill, quote preview и генерации документа.
+
+
+
+ 📚 Документация ·
+ ⚙️ Конфигурация ·
+ 🧾 Шаблоны ·
+ 🇬🇧 EN
+
+
+---
+
+## 🧭 Карта endpoint-ов
+
+| Endpoint | Auth | Назначение |
+| --- | --- | --- |
+| `GET /api/prefill.php?lead_id=` | нет | Восстановить форму и выдать `generate_token` |
+| `POST /api/quote.php` | нет | Посчитать backend totals для UI preview |
+| `POST /api/generate.php` | `generate_token` или HMAC | Сгенерировать DOCX и обновить заметку amoCRM |
+
+Все ответы JSON, кроме ссылок на готовые DOCX-файлы.
+
+## `GET /api/prefill.php?lead_id=`
+
+Возвращает кэш формы и серверный токен генерации.
+
+```json
+{
+ "template": "order",
+ "discount": 0,
+ "products": [],
+ "saved_at": 1710000000,
+ "generate_token": "..."
+}
+```
+
+## `POST /api/quote.php`
+
+Считает строки, итоги и сумму прописью на backend.
+
+```json
+{
+ "discount": 500,
+ "products": [
+ {"name": "Диагностика", "unit_price": 1500, "qty": 1, "discount_percent": 0}
+ ]
+}
+```
+
+Возвращает `rows`, `sum_gross`, `sum_after`, `discount`, `total`, `count`, `total_words`.
+
+Пример ответа:
+
+```json
+{
+ "rows": [
+ {
+ "index": 1,
+ "name": "Диагностика",
+ "qty": 1,
+ "unit_price": 1500,
+ "discount_label": "-",
+ "net_sum": 1500
+ }
+ ],
+ "sum_gross": 1500,
+ "sum_after": 1500,
+ "discount": 500,
+ "total": 1000,
+ "count": 1,
+ "total_words": "одна тысяча рублей"
+}
+```
+
+## `POST /api/generate.php`
+
+Создает документ и обновляет заметку в amoCRM.
+
+```json
+{
+ "lead_id": 123456,
+ "template": "order",
+ "discount": 500,
+ "generate_token": "...",
+ "products": [
+ {"name": "Диагностика", "unit_price": 1500, "qty": 1, "discount_percent": 0}
+ ]
+}
+```
+
+Ответ:
+
+```json
+{"url": "https:///documents/doc_123456_1710000000.docx"}
+```
+
+Коды ошибок: `400` для некорректного ввода или неизвестного шаблона, `401` для ошибки авторизации, `500` для внутренних ошибок.
+
+## HMAC для backend-клиента
+
+В режиме `generate_auth_mode = hmac` отправляйте `X-Signature` как:
+
+```text
+hash_hmac('sha256', raw_json_body, hmac_secret)
+```
+
+Подписывать нужно ровно тот raw body, который отправляется в `generate.php`, без повторного JSON encoding.
+
+---
+
+**Дальше:** [Шаблоны](templates.md) · **Назад:** [Конфигурация](configuration.md)
diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md
new file mode 100644
index 0000000..f654bd1
--- /dev/null
+++ b/docs/ru/configuration.md
@@ -0,0 +1,117 @@
+⚙️ Конфигурация
+
+
+ OAuth, ID полей, шаблоны, runtime paths и request security без коммита секретов.
+
+
+
+ 📚 Документация ·
+ 🚀 Запуск ·
+ 🔌 API ·
+ 🇬🇧 EN
+
+
+---
+
+## 🧭 Коротко
+
+| Зона | Ключи |
+| --- | --- |
+| OAuth | `client_id`, `client_secret`, `redirect_uri`, `base_domain` |
+| Поля | `amo_fields` по ID amoCRM |
+| Security | `security.generate_auth_mode` |
+| Шаблоны | `templates` registry |
+| Runtime | пути для documents, logs, cache, tokens |
+
+`config/config.php` игнорируется git, потому что содержит секреты. Начинайте с `config/config.example.php`.
+
+## amoCRM
+
+Заполните:
+
+- `client_id`
+- `client_secret`
+- `redirect_uri`
+- `base_domain`
+- `subdomain`
+- `token_path`
+
+В `base_domain` указывайте полный домен amoCRM, например `https://example.amocrm.ru`.
+
+## Маппинг полей
+
+Кастомные поля сделки ищутся по ID amoCRM, не по названию:
+
+```php
+'amo_fields' => [
+ 'last_name' => 111111,
+ 'first_name' => 222222,
+ 'middle_name' => 333333,
+ 'car_make' => 444444,
+ 'car_model' => 555555,
+ 'vin' => 666666,
+ 'year' => 777777,
+],
+```
+
+Переименование полей в amoCRM не ломает генерацию, если ID остались верными. Телефон берется по стабильному коду `PHONE`.
+
+## Безопасность
+
+Browser flow использует серверный `generate_token` из `prefill.php`.
+
+HMAC-режим нужен только для доверенных server-to-server клиентов:
+
+```php
+'security' => [
+ 'generate_auth_mode' => 'browser_token',
+ 'generate_token_ttl_seconds' => 1800,
+ 'hmac_secret' => '',
+],
+```
+
+Режимы:
+
+| Режим | Для чего |
+| --- | --- |
+| `browser_token` | Режим по умолчанию. `prefill.php` выдает `generate_token`, `generate.php` проверяет его. |
+| `hmac` | Доверенный backend-клиент подписывает raw body через `X-Signature`. |
+| `either` | Переходный режим, где разрешены browser token или HMAC. |
+
+Не кладите HMAC secret в браузерный JavaScript. Если включен `hmac`, `hmac_secret` обязан быть заполнен.
+
+## Runtime-пути
+
+| Ключ | Назначение |
+| --- | --- |
+| `template_path` | Исходные `.docx` шаблоны |
+| `document_path` | Сгенерированные документы |
+| `public_documents_url` | Публичный URL-префикс документов |
+| `temp_data_path` | Корень runtime-состояния |
+| `cache_path` | Кэш префилла |
+| `logs_path` | JSON-логи |
+| `token_path` | OAuth-токены amoCRM |
+
+## Шаблоны
+
+```php
+'templates' => [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+],
+```
+
+Новый ключ в этой секции подключает новый шаблон через registry.
+
+## Не коммитить
+
+- `config/config.php`
+- `config/token.json`
+- `.env`
+- `data/`
+- `documents/`
+- `logs/`
+
+---
+
+**Дальше:** [API](api.md) · **Назад:** [Запуск](getting-started.md)
diff --git a/docs/ru/development.md b/docs/ru/development.md
new file mode 100644
index 0000000..e9209fb
--- /dev/null
+++ b/docs/ru/development.md
@@ -0,0 +1,66 @@
+🧪 Разработка
+
+
+ Границы модулей, локальные проверки и contribution workflow.
+
+
+
+ 📚 Документация ·
+ 🛠️ Эксплуатация ·
+ 🤝 Contributing ·
+ 🇬🇧 EN
+
+
+---
+
+## 🧭 Карта разработки
+
+| Слой | Директория |
+| --- | --- |
+| HTTP entrypoints | `api/` |
+| Domain services | `src/` |
+| Runtime config example | `config/config.example.php` |
+| PHPUnit coverage | `tests/` |
+
+## Структура проекта
+
+- `api/` содержит HTTP entrypoints.
+- `src/AmoCrm/` содержит amoCRM client, заметки и маппинг полей.
+- `src/Documents/` содержит quote calculation, template registry и DOCX generation.
+- `src/Security/` содержит browser tokens и request authentication.
+- `src/Storage/` содержит prefill cache.
+- `src/Support/` содержит formatters и logging helpers.
+- `tests/` содержит PHPUnit tests.
+
+## Локальные проверки
+
+```powershell
+composer install
+.\vendor\bin\phpunit
+```
+
+Опциональная проверка PHP syntax:
+
+```powershell
+$files = Get-ChildItem -Recurse -Filter *.php | Where-Object { $_.FullName -notlike '*\vendor\*' }
+foreach ($file in $files) { php -l $file.FullName }
+```
+
+## Правила контрибьюта
+
+- Используйте ветки вроде `docs/readme-polish` или `fix/template-registry`.
+- Не коммитьте `config/config.php`, `config/token.json`, `.env`, логи, cache-файлы и generated documents.
+- Используйте относительные ссылки в документации и проверяйте их перед PR.
+
+## Стиль коммитов
+
+Делайте небольшие коммиты по смыслу:
+
+- `docs: update api guide`
+- `fix(frontend): use api base path`
+- `refactor(api): isolate prefill cache`
+- `test: cover token validation`
+
+---
+
+**Назад:** [Индекс документации](index.md) · **Community:** [Contributing](../../CONTRIBUTING.md)
diff --git a/docs/ru/getting-started.md b/docs/ru/getting-started.md
new file mode 100644
index 0000000..1baf0f2
--- /dev/null
+++ b/docs/ru/getting-started.md
@@ -0,0 +1,87 @@
+🚀 Запуск
+
+
+ Установить AmoDocsEngine, подключить amoCRM OAuth и открыть UI генерации документов.
+
+
+
+ 📚 Документация ·
+ ⚙️ Конфигурация ·
+ 🔌 API ·
+ 🇬🇧 EN
+
+
+---
+
+## 🧭 Коротко
+
+| Шаг | Результат |
+| --- | --- |
+| Установить зависимости | готов `vendor/` |
+| Скопировать конфиг | есть ignored `config/config.php` |
+| Пройти OAuth | есть `config/token.json` |
+| Открыть UI | загружается `public/ui.html?lead_id=` |
+
+## Требования
+
+- PHP 7.4+ с `curl`, `intl`, `mbstring`, `zip`.
+- Composer.
+- Права на запись в `documents/`, `logs/`, `data/`, `data/cache/`.
+
+## Установка
+
+```bash
+composer install
+cp config/config.example.php config/config.php
+```
+
+PowerShell:
+
+```powershell
+composer install
+Copy-Item config/config.example.php config/config.php
+```
+
+## Развернуть файлы
+
+Загрузите проект в директорию хостинга, откуда будут раздаваться UI и API. PHP-процессу нужны права на запись:
+
+```bash
+chmod -R 775 documents logs data
+```
+
+`777` используйте только если хостинг не дает нормально настроить владельца процесса.
+
+## OAuth amoCRM
+
+1. Создайте приватную интеграцию amoCRM.
+2. Укажите Redirect URI на ваш `oauth.php`.
+3. Заполните `client_id`, `client_secret`, `redirect_uri`, `base_domain`, `subdomain`.
+4. Откройте ссылку авторизации и завершите flow.
+5. Проверьте, что появился `config/token.json`.
+
+Refresh работает автоматически: если amoCRM вернул `401`, `AmoCrmClient` обновит access token и повторит запрос.
+
+## Открыть UI
+
+```text
+https:////public/ui.html?lead_id=
+```
+
+Если UI и API раздаются не из одного root, обновите `API_BASE` в `public/app.js`.
+
+## Первая проверка
+
+Перед реальной генерацией проверьте backend-расчет:
+
+```bash
+curl -X POST https:////api/quote.php \
+ -H 'Content-Type: application/json' \
+ -d '{"discount":0,"products":[{"name":"Test","unit_price":1000,"qty":1}]}'
+```
+
+Ожидается JSON с `"total":1000` и `total_words`.
+
+---
+
+**Дальше:** [Конфигурация](configuration.md) · **Назад:** [Индекс документации](index.md)
diff --git a/docs/ru/index.md b/docs/ru/index.md
new file mode 100644
index 0000000..252fae3
--- /dev/null
+++ b/docs/ru/index.md
@@ -0,0 +1,96 @@
+
+
+
+
+📚 Документация AmoDocsEngine
+
+
+ Установка, настройка, эксплуатация и расширение secure amoCRM → DOCX automation engine.
+
+
+
+ 🏠 README ·
+ 🇬🇧 English ·
+ 🔌 API ·
+ 🛡️ Security
+
+
+---
+
+## 🧭 Навигация
+
+| Раздел | Для чего |
+| --- | --- |
+| [🚀 Запуск](getting-started.md) | Установить зависимости, развернуть файлы, пройти OAuth, открыть UI |
+| [⚙️ Конфигурация](configuration.md) | `config/config.php`, `amo_fields`, шаблоны, безопасность и пути |
+| [🔌 API](api.md) | Контракты `prefill.php`, `quote.php`, `generate.php` |
+| [🧾 Шаблоны](templates.md) | DOCX-плейсхолдеры, строки таблицы, новые шаблоны |
+| [🛠️ Эксплуатация](operations.md) | Логи, диагностика, миграция, smoke-тесты |
+| [🧪 Разработка](development.md) | Структура проекта, тесты, локальный workflow |
+
+## ⚡ Быстрый маршрут
+
+1. Запустить: `composer install`.
+2. Настроить: скопировать `config/config.example.php` в `config/config.php`.
+3. Заполнить amoCRM OAuth и ID полей в `amo_fields`.
+4. Проверить API через `quote.php`.
+5. Разобрать ошибки через `logs/generate.log`.
+
+## 🧩 Типы документации
+
+| Тип | Страницы |
+| --- | --- |
+| Tutorial | [Запуск](getting-started.md) |
+| How-to | [Конфигурация](configuration.md), [Шаблоны](templates.md), [Эксплуатация](operations.md) |
+| Reference | [API](api.md), [Разработка](development.md) |
+| Explanation | Архитектура и workflow ниже |
+
+## 🏗️ Кратко об архитектуре
+
+Browser UI вызывает небольшие PHP endpoints. Backend отвечает за авторизацию запроса, расчет сумм, amoCRM API, генерацию DOCX, кэш префилла, заметки и логи.
+
+```mermaid
+flowchart LR
+ UI["Browser UI"]
+ API["api/ endpoints"]
+ Security["src/Security"]
+ Amo["src/AmoCrm"]
+ Documents["src/Documents"]
+ Storage["src/Storage"]
+ Logs["src/Support"]
+
+ UI --> API
+ API --> Security
+ API --> Amo
+ API --> Documents
+ API --> Storage
+ API --> Logs
+```
+
+## 🔁 Workflow генерации
+
+```mermaid
+sequenceDiagram
+ participant UI as Browser UI
+ participant Prefill as prefill.php
+ participant Quote as quote.php
+ participant Generate as generate.php
+ participant Amo as amoCRM
+ participant Docx as DOCX
+
+ UI->>Prefill: запросить cached state
+ Prefill-->>UI: products + generate_token
+ UI->>Quote: products + discount
+ Quote-->>UI: backend totals
+ UI->>Generate: token + template + products
+ Generate->>Amo: lead/contact
+ Generate->>Docx: render template
+ Generate->>Amo: replace note
+ Generate-->>UI: document URL
+```
+
+Исходники схем: [architecture](../assets/architecture-overview.mmd), [workflow](../assets/workflow-overview.mmd).
+
+---
+
+**Дальше:** [Запуск](getting-started.md) · **English:** [Documentation](../en/index.md)
diff --git a/docs/ru/operations.md b/docs/ru/operations.md
new file mode 100644
index 0000000..fecbc13
--- /dev/null
+++ b/docs/ru/operations.md
@@ -0,0 +1,78 @@
+🛠️ Эксплуатация
+
+
+ Smoke checks, логи, диагностика хостинга и миграция amoCRM аккаунтов.
+
+
+
+ 📚 Документация ·
+ 🧾 Шаблоны ·
+ 🧪 Разработка ·
+ 🇬🇧 EN
+
+
+---
+
+## 🧭 Карта эксплуатации
+
+| Зона | Сначала проверить |
+| --- | --- |
+| Runtime data | `data/`, `documents/`, `logs/` |
+| OAuth | `config/token.json` |
+| Request auth | `data/security/generate_tokens.json` |
+| Exceptions | `logs/generate.log` |
+
+## Runtime-файлы
+
+- `config/token.json` хранит OAuth-токены.
+- `data/cache/` хранит payload префилла.
+- `data/security/generate_tokens.json` хранит короткоживущие browser-токены.
+- `documents/` хранит готовые DOCX.
+- `logs/generate.log` хранит JSON-события ошибок.
+
+По возможности держите `config/`, `data/`, `documents/` и `logs/` вне прямого публичного доступа. Если хостинг их раздает, отключите listing директорий и заблокируйте sensitive files.
+
+## Smoke-тест
+
+```bash
+php -m | grep -E 'zip|xml|mbstring|curl|json'
+composer install
+vendor/bin/phpunit
+```
+
+Windows:
+
+```powershell
+.\vendor\bin\phpunit
+```
+
+## Диагностика
+
+| Симптом | Что проверить |
+| --- | --- |
+| `401 Unauthorized` | UI сначала вызвал `prefill.php` и отправляет `generate_token` |
+| amoCRM `401` | Есть `config/token.json`, refresh token валиден |
+| Пустые поля авто | ID в `amo_fields` совпадают с полями сделки |
+| Template not found | Ключ шаблона есть, файл лежит в `templates/` |
+| Битая ссылка на документ | `public_documents_url` совпадает с hosting path |
+
+## Чтение логов
+
+`logs/generate.log` пишется append-only JSON lines. Типичная запись:
+
+```json
+{
+ "EX": "Template not found",
+ "line": 123
+}
+```
+
+По сообщению можно понять источник: шаблон, amoCRM, security или filesystem.
+
+## Миграция
+
+Для другого amoCRM аккаунта обновите OAuth-данные, `base_domain`, `subdomain`, `amo_fields` и заново пройдите OAuth для создания свежего `config/token.json`.
+
+---
+
+**Дальше:** [Разработка](development.md) · **Назад:** [Шаблоны](templates.md)
diff --git a/docs/ru/templates.md b/docs/ru/templates.md
new file mode 100644
index 0000000..482dfe2
--- /dev/null
+++ b/docs/ru/templates.md
@@ -0,0 +1,91 @@
+🧾 Word-шаблоны
+
+
+ DOCX-шаблоны, плейсхолдеры, табличные строки и новые типы документов.
+
+
+
+ 📚 Документация ·
+ 🔌 API ·
+ 🛠️ Эксплуатация ·
+ 🇬🇧 EN
+
+
+---
+
+## 🧭 Карта шаблонов
+
+| Template key | DOCX-файл | Назначение |
+| --- | --- | --- |
+| `order` | `order_template.docx` | Заказ-наряд со строками услуг |
+| `act` | `act_template.docx` | Акт приема-передачи |
+| custom | настроен в `templates` | Любой добавленный тип документа |
+
+Шаблоны лежат в `templates/` и заполняются через PhpWord `TemplateProcessor`.
+
+## Встроенные шаблоны
+
+- `order` → `order_template.docx`
+- `act` → `act_template.docx`
+
+## Общие плейсхолдеры
+
+- `${Номер}`
+- `${Дата}`
+- `${Телефон}`
+- `${Марка}`
+- `${Модель}`
+- `${VIN}`
+- `${Год выпуска}`
+- `${Фамилия}`
+- `${Имя}`
+- `${Отчество}`
+- `${Итого}`
+- `${Скидка}`
+- `${Всего к оплате}`
+- `${Количество наименований}`
+- `${Сумма прописью}`
+
+## Табличные строки order
+
+Строки клонируются по `row_num`:
+
+- `${row_num}`
+- `${услуга_название}`
+- `${row_qty}`
+- `${row_price}`
+- `${row_discount}`
+- `${row_sum}`
+
+## Добавить шаблон
+
+1. Положите `.docx` в `templates/`.
+2. Добавьте ключ в `config/config.php` в секцию `templates`.
+3. Передавайте этот ключ как `template` из UI или API клиента.
+
+Пример:
+
+```php
+'templates' => [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+ 'invoice' => 'invoice_template.docx',
+],
+```
+
+Затем отправляйте:
+
+```json
+{"template": "invoice"}
+```
+
+## Чеклист шаблона
+
+- Имена плейсхолдеров должны совпадать точно.
+- Плейсхолдеры таблицы должны быть внутри строки, которую нужно клонировать.
+- Не переименовывайте существующие ключи без изменения backend-сервиса.
+- Загружайте `.docx`, не `.doc`.
+
+---
+
+**Дальше:** [Эксплуатация](operations.md) · **Назад:** [API](api.md)
diff --git a/public/app.js b/public/app.js
index 310389b..1638a01 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,13 +1,15 @@
(function(){
const qs = new URLSearchParams(location.search);
const leadId = Number(qs.get('lead_id')||0);
- const API = '/api/generate.php';
+ const API_BASE = '/api';
const $ = s=>document.querySelector(s);
const tbody = $('#tbl tbody');
const sumEl = $('#sum'), totalEl = $('#total'), cntEl = $('#cnt'), wordsEl = $('#totalWords');
const discountEl = $('#discount'), templateEl = $('#template');
const genBtn = $('#gen'), linkA = $('#link');
+ let quoteTimer = null, quoteSeq = 0;
+ let generateToken = '';
const fmt = n => (Number(n)||0).toLocaleString('ru-RU');
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.hidden=false; setTimeout(()=>t.hidden=true,2500); }
@@ -58,10 +60,7 @@
const qty = Math.max(1, Number(tr.querySelector('.q').value||1));
const unit = Math.max(0, Number(tr.querySelector('.u').value||0));
const dp = Math.max(0, Number(tr.querySelector('.d').value||0));
- const gross = qty * unit;
- let after = gross;
- if (dp>0) after = Math.round(gross * (1 - dp/100));
- return {name, qty, unit, dp, gross, after};
+ return {name, qty, unit, dp};
}
function products(){
@@ -79,47 +78,44 @@
}
function recalc(){
- let sumGross = 0, sumAfter = 0, idx = 1;
+ let idx = 1;
tbody.querySelectorAll('tr').forEach(tr=>{
- const r = rowData(tr);
- tr.querySelector('.s').textContent = fmt(r.after);
tr.querySelector('.num').textContent = idx++;
- sumGross += r.gross;
- sumAfter += r.after;
});
- const globalDisc = Number(discountEl.value||0);
- const total = Math.max(sumAfter - globalDisc, 0);
cntEl.textContent = tbody.querySelectorAll('tr').length;
- sumEl.textContent = fmt(sumGross);
- totalEl.textContent = fmt(total);
- wordsEl.textContent = toWords(total);
linkA.style.display='none';
+ scheduleQuote();
+ }
+
+ function scheduleQuote(){
+ clearTimeout(quoteTimer);
+ quoteTimer = setTimeout(loadQuote, 200);
}
- function morph(n,f1,f2,f5){ n=Math.abs(n)%100; const n1=n%10;
- if(n>10&&n<20) return f5; if(n1>1&&n1<5) return f2; if(n1==1) return f1; return f5; }
- function toWords(num){
- if(num===0) return 'ноль рублей';
- const w1=['','один','два','три','четыре','пять','шесть','семь','восемь','девять'];
- const w1f=['','одна','две','три','четыре','пять','шесть','семь','восемь','девять'];
- const w10=['десять','одиннадцать','двенадцать','тринадцать','четырнадцать','пятнадцать','шестнадцать','семнадцать','восемнадцать','девятнадцать'];
- const w2=['','десять','двадцать','тридцать','сорок','пятьдесят','шестьдесят','семьдесят','восемьдесят','девяносто'];
- const w3=['','сто','двести','триста','четыреста','пятьсот','шестьсот','семьсот','восемьсот','девятьсот'];
- const units=[['рубль','рубля','рублей',0],['тысяча','тысячи','тысяч',1],['миллион','миллиона','миллионов',0]];
- let parts=[], i=0;
- while(num>0 && i=10 && t<20){ s.push(w10[t-10]); }
- else{ s.push(w2[Math.trunc(t/10)]); s.push((g?w1f:w1)[t%10]); }
- s.push(morph(n,units[i][0],units[i][1],units[i][2]));
- parts.push(s.filter(Boolean).join(' '));
- }
- num = Math.trunc(num/1000); i++;
- }
- return parts.reverse().join(' ').trim();
+ async function loadQuote(){
+ const seq = ++quoteSeq;
+ try{
+ const r = await fetch(API_BASE + '/quote.php', {
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({products: products(), discount: Number(discountEl.value||0)})
+ });
+ const j = await r.json();
+ if(seq !== quoteSeq || !r.ok) return;
+ renderQuote(j);
+ }catch(_){}
+ }
+
+ function renderQuote(q){
+ const rows = q.rows || [];
+ tbody.querySelectorAll('tr').forEach((tr, i)=>{
+ const row = rows[i] || {};
+ tr.querySelector('.s').textContent = fmt(row.net_sum || 0);
+ });
+ cntEl.textContent = q.count || 0;
+ sumEl.textContent = fmt(q.sum_gross || 0);
+ totalEl.textContent = fmt(q.total || 0);
+ wordsEl.textContent = q.total_words || 'ноль рублей';
}
$('#add').onclick = ()=> addRow();
@@ -128,12 +124,13 @@
lead_id: leadId,
template: templateEl.value,
products: products(),
- discount: Number(discountEl.value||0)
+ discount: Number(discountEl.value||0),
+ generate_token: generateToken
};
if(!body.products.length){ toast('Добавьте хотя бы одну позицию'); return; }
genBtn.disabled = true; genBtn.textContent = 'Генерирую…';
try{
- const r = await fetch(API+'/api/generate.php', {
+ const r = await fetch(API_BASE + '/generate.php', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
});
const text = await r.text(); let j=null;
@@ -149,8 +146,9 @@
(async function(){
if(!leadId){ addRow(); return; }
try{
- const r = await fetch(API+'/api/prefill.php?lead_id='+leadId);
+ const r = await fetch(API_BASE + '/prefill.php?lead_id='+leadId);
const j = await r.json();
+ generateToken = j.generate_token || '';
(j.products||[]).forEach(p=>{
addRow(p.name||'', p.unit_price||p.price||0, p.qty||p.quantity||1, p.discount_percent||0);
});
diff --git a/src/AmoCrm/AmoCrmClient.php b/src/AmoCrm/AmoCrmClient.php
new file mode 100644
index 0000000..99e7e86
--- /dev/null
+++ b/src/AmoCrm/AmoCrmClient.php
@@ -0,0 +1,169 @@
+ */
+ private array $config;
+ private string $tokenPath;
+ /** @var array */
+ private array $tokens;
+ /** @var callable */
+ private $http;
+
+ /**
+ * @param array $config
+ */
+ public function __construct(array $config, string $tokenPath, ?callable $http = null)
+ {
+ $this->config = $config;
+ $this->tokenPath = $tokenPath;
+ $loaded = json_decode((string)@file_get_contents($tokenPath), true);
+ $this->tokens = is_array($loaded) ? $loaded : [];
+ $this->http = $http ?? [$this, 'curlRequest'];
+ }
+
+ /**
+ * @return array
+ */
+ public function get(string $pathOrUrl): array
+ {
+ return $this->request('GET', $pathOrUrl);
+ }
+
+ /**
+ * @param mixed $payload
+ * @return array
+ */
+ public function post(string $pathOrUrl, $payload): array
+ {
+ return $this->request('POST', $pathOrUrl, $payload);
+ }
+
+ /**
+ * @return array
+ */
+ public function delete(string $pathOrUrl): array
+ {
+ return $this->request('DELETE', $pathOrUrl);
+ }
+
+ /**
+ * @param mixed $payload
+ * @return array
+ */
+ private function request(string $method, string $pathOrUrl, $payload = null): array
+ {
+ [$code, $body] = $this->sendAuthorized($method, $this->url($pathOrUrl), $payload);
+
+ if ($code === 401) {
+ $this->refreshToken();
+ [$code, $body] = $this->sendAuthorized($method, $this->url($pathOrUrl), $payload);
+ }
+
+ if ($code < 200 || $code >= 300) {
+ throw new RuntimeException("AMO {$code}: {$body}");
+ }
+
+ if (trim((string)$body) === '') {
+ return [];
+ }
+
+ $json = json_decode((string)$body, true);
+ if (!is_array($json)) {
+ throw new RuntimeException('AMO bad JSON');
+ }
+
+ return $json;
+ }
+
+ /**
+ * @param mixed $payload
+ * @return array{0:int,1:string}
+ */
+ private function sendAuthorized(string $method, string $url, $payload = null): array
+ {
+ $headers = ['Authorization: Bearer ' . ($this->tokens['access_token'] ?? '')];
+ if ($payload !== null) {
+ $headers[] = 'Content-Type: application/json';
+ }
+
+ return ($this->http)($method, $url, $headers, $payload, 25);
+ }
+
+ private function refreshToken(): void
+ {
+ $payload = [
+ 'client_id' => $this->config['client_id'] ?? '',
+ 'client_secret' => $this->config['client_secret'] ?? '',
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $this->tokens['refresh_token'] ?? '',
+ 'redirect_uri' => $this->config['redirect_uri'] ?? '',
+ ];
+
+ [$code, $body] = ($this->http)(
+ 'POST',
+ $this->url('/oauth2/access_token'),
+ ['Content-Type: application/json'],
+ $payload,
+ 20
+ );
+
+ if ($code !== 200) {
+ throw new RuntimeException("REFRESH {$code}: {$body}");
+ }
+
+ $newTokens = json_decode((string)$body, true);
+ if (!is_array($newTokens) || empty($newTokens['access_token'])) {
+ throw new RuntimeException('REFRESH: empty access_token');
+ }
+
+ $this->tokens = $newTokens;
+ $this->tokens['created_at'] = time();
+ file_put_contents(
+ $this->tokenPath,
+ json_encode($this->tokens, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
+ );
+ }
+
+ private function url(string $pathOrUrl): string
+ {
+ if (preg_match('~^https?://~', $pathOrUrl) === 1) {
+ return $pathOrUrl;
+ }
+
+ return rtrim((string)$this->config['base_domain'], '/') . '/' . ltrim($pathOrUrl, '/');
+ }
+
+ /**
+ * @param array $headers
+ * @param mixed $payload
+ * @return array{0:int,1:string}
+ */
+ private function curlRequest(string $method, string $url, array $headers, $payload, int $timeout): array
+ {
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_TIMEOUT => $timeout,
+ ]);
+
+ if ($payload !== null) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
+ }
+
+ $response = curl_exec($ch);
+ $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ return [$code, $response === false ? '' : (string)$response];
+ }
+}
diff --git a/src/AmoCrm/AmoCrmNoteService.php b/src/AmoCrm/AmoCrmNoteService.php
new file mode 100644
index 0000000..ab8ed17
--- /dev/null
+++ b/src/AmoCrm/AmoCrmNoteService.php
@@ -0,0 +1,83 @@
+metaDir = rtrim($metaDir, '/');
+ $this->delete = $delete;
+ $this->post = $post;
+ }
+
+ public static function fromClient(AmoCrmClient $client, string $metaDir): self
+ {
+ return new self(
+ $metaDir,
+ static function (int $noteId) use ($client): void {
+ $client->delete('/api/v4/leads/notes/' . $noteId);
+ },
+ static function (array $payload) use ($client): array {
+ return $client->post('/api/v4/leads/notes', $payload);
+ }
+ );
+ }
+
+ public function replaceDocumentNote(int $leadId, string $template, string $url): void
+ {
+ Filesystem::ensureDirectory($this->metaDir, 0775, 'Note metadata directory');
+ $metaPath = $this->metaPath($leadId);
+ $meta = is_file($metaPath) ? json_decode((string)file_get_contents($metaPath), true) : [];
+ $meta = is_array($meta) ? $meta : [];
+ $prevNoteId = (int)($meta['note_id'] ?? 0);
+
+ if ($prevNoteId > 0) {
+ try {
+ ($this->delete)($prevNoteId);
+ } catch (Throwable $ignored) {
+ }
+ }
+
+ $response = ($this->post)([$this->payload($leadId, $template, $url)]);
+ $newId = $response['_embedded']['notes'][0]['id'] ?? null;
+ if ($newId) {
+ $meta['note_id'] = (int)$newId;
+ if (file_put_contents($metaPath, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) === false) {
+ throw new RuntimeException(sprintf('Unable to write note metadata file "%s"', $metaPath));
+ }
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function payload(int $leadId, string $template, string $url): array
+ {
+ $title = $template === 'act' ? 'Акт приёма-передачи' : 'Заказ-наряд';
+
+ return [
+ 'entity_id' => $leadId,
+ 'entity_type' => 'leads',
+ 'note_type' => 'common',
+ 'params' => ['text' => "{$title} №{$leadId}: {$url}"],
+ ];
+ }
+
+ private function metaPath(int $leadId): string
+ {
+ return $this->metaDir . "/lead_{$leadId}_meta.json";
+ }
+}
diff --git a/src/AmoCrm/CustomFieldMapper.php b/src/AmoCrm/CustomFieldMapper.php
new file mode 100644
index 0000000..3b466e3
--- /dev/null
+++ b/src/AmoCrm/CustomFieldMapper.php
@@ -0,0 +1,45 @@
+ */
+ private array $fieldIds;
+
+ /**
+ * @param array $fieldIds
+ */
+ public function __construct(array $fieldIds)
+ {
+ $this->fieldIds = [];
+ foreach ($fieldIds as $key => $id) {
+ $this->fieldIds[$key] = (int)$id;
+ }
+ }
+
+ /**
+ * @param array> $fields
+ */
+ public function value(array $fields, string $key): string
+ {
+ $fieldId = $this->fieldIds[$key] ?? 0;
+ if ($fieldId <= 0) {
+ return '';
+ }
+
+ foreach ($fields as $field) {
+ if ((int)($field['field_id'] ?? 0) !== $fieldId) {
+ continue;
+ }
+
+ $value = $field['values'][0]['value'] ?? '';
+
+ return is_scalar($value) ? (string)$value : '';
+ }
+
+ return '';
+ }
+}
diff --git a/src/Documents/DocumentGenerationService.php b/src/Documents/DocumentGenerationService.php
new file mode 100644
index 0000000..7cee259
--- /dev/null
+++ b/src/Documents/DocumentGenerationService.php
@@ -0,0 +1,138 @@
+ */
+ private array $config;
+ private CustomFieldMapper $fieldMapper;
+ private TemplateRegistry $templateRegistry;
+ /** @var callable */
+ private $processorFactory;
+
+ /**
+ * @param array $config
+ */
+ public function __construct(array $config, ?callable $processorFactory = null)
+ {
+ $this->config = $config;
+ $this->fieldMapper = new CustomFieldMapper(is_array($config['amo_fields'] ?? null) ? $config['amo_fields'] : []);
+ $this->templateRegistry = TemplateRegistry::fromConfig($config);
+ $this->processorFactory = $processorFactory ?? static function (string $templatePath): TemplateProcessor {
+ return new TemplateProcessor($templatePath);
+ };
+ }
+
+ /**
+ * @param array $lead
+ * @param array|null $contact
+ * @param array> $products
+ * @return array{path:string,url:string,filename:string,total:int}
+ */
+ public function generate(array $lead, ?array $contact, string $template, array $products, int $discount): array
+ {
+ $leadId = (int)($lead['id'] ?? 0);
+ if ($leadId <= 0) {
+ throw new RuntimeException('Invalid lead id for document generation');
+ }
+
+ $templatePath = $this->templateRegistry->path($template);
+ $documentDir = rtrim((string)$this->config['document_path'], '/');
+ Filesystem::ensureDirectory($documentDir, (int)$this->config['dir_mode'], 'Document directory');
+
+ foreach (glob($documentDir . "/doc_{$leadId}_*.docx") ?: [] as $oldFile) {
+ @unlink($oldFile);
+ }
+
+ $processor = ($this->processorFactory)($templatePath);
+ $fields = $lead['custom_fields_values'] ?? [];
+ $phone = $this->phoneFromContact($contact);
+
+ [$lastName, $firstName, $middleName] = $this->fioParts($contact);
+ $lastName = $this->fieldMapper->value($fields, 'last_name') ?: $lastName;
+ $firstName = $this->fieldMapper->value($fields, 'first_name') ?: $firstName;
+ $middleName = $this->fieldMapper->value($fields, 'middle_name') ?: $middleName;
+
+ $processor->setValue('Номер', $leadId);
+ $processor->setValue('Дата', date('d.m.Y'));
+ $processor->setValue('Телефон', $phone ? ' ' . $phone : '');
+ $processor->setValue('Марка', $this->fieldMapper->value($fields, 'car_make') ?: '—');
+ $processor->setValue('Модель', $this->fieldMapper->value($fields, 'car_model') ?: '—');
+ $processor->setValue('VIN', $this->fieldMapper->value($fields, 'vin') ?: '—');
+ $processor->setValue('Год выпуска', $this->fieldMapper->value($fields, 'year') ?: '—');
+ $processor->setValue('Фамилия', $lastName);
+ $processor->setValue('Имя', $firstName);
+ $processor->setValue('Отчество', $middleName);
+
+ if ($template === 'order' && count($products) > 0) {
+ $rows = DocumentDataBuilder::buildRows($products);
+ $processor->cloneRow('row_num', count($rows));
+
+ foreach ($rows as $row) {
+ $n = $row['index'];
+ $processor->setValue("row_num#{$n}", $n);
+ $processor->setValue("услуга_название#{$n}", $row['name']);
+ $processor->setValue("row_qty#{$n}", $row['qty']);
+ $processor->setValue("row_price#{$n}", number_format((int)$row['unit_price'], 0, ',', ' '));
+ $processor->setValue("row_discount#{$n}", $row['discount_label']);
+ $processor->setValue("row_sum#{$n}", number_format((int)$row['net_sum'], 0, ',', ' '));
+ }
+ }
+
+ $summary = DocumentDataBuilder::summarize($products, $discount);
+ $processor->setValue('Итого', $summary['sum_gross']);
+ $processor->setValue('Скидка', $summary['discount']);
+ $processor->setValue('Всего к оплате', $summary['total']);
+ $processor->setValue('Количество наименований', $summary['count']);
+ $processor->setValue('Сумма прописью', RubleFormatter::toWords((int)$summary['total']));
+
+ $filename = "doc_{$leadId}_" . time() . ".docx";
+ $savePath = $documentDir . '/' . $filename;
+ $processor->saveAs($savePath);
+ @chmod($savePath, (int)$this->config['file_mode']);
+
+ return [
+ 'path' => $savePath,
+ 'url' => rtrim((string)$this->config['public_documents_url'], '/') . '/' . rawurlencode($filename),
+ 'filename' => $filename,
+ 'total' => (int)$summary['total'],
+ ];
+ }
+
+ /**
+ * @param array|null $contact
+ */
+ private function phoneFromContact(?array $contact): string
+ {
+ foreach (($contact['custom_fields_values'] ?? []) as $field) {
+ if (($field['field_code'] ?? '') === 'PHONE') {
+ return (string)($field['values'][0]['value'] ?? '');
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * @param array|null $contact
+ * @return array{0:string,1:string,2:string}
+ */
+ private function fioParts(?array $contact): array
+ {
+ $parts = preg_split('/\s+/', trim((string)($contact['name'] ?? '')), 3);
+ $parts = is_array($parts) ? $parts : [];
+
+ return array_pad($parts, 3, '');
+ }
+
+}
diff --git a/src/Documents/DocumentQuoteService.php b/src/Documents/DocumentQuoteService.php
new file mode 100644
index 0000000..fc06051
--- /dev/null
+++ b/src/Documents/DocumentQuoteService.php
@@ -0,0 +1,31 @@
+> $products
+ * @return array
+ */
+ public function quote(array $products, int $discount): array
+ {
+ $rows = DocumentDataBuilder::buildRows($products);
+ $summary = DocumentDataBuilder::summarize($products, $discount);
+
+ return [
+ 'rows' => $rows,
+ 'sum_gross' => $summary['sum_gross'],
+ 'sum_after' => $summary['sum_after'],
+ 'discount' => $summary['discount'],
+ 'total' => $summary['total'],
+ 'count' => $summary['count'],
+ 'total_words' => RubleFormatter::toWords((int)$summary['total']),
+ ];
+ }
+}
diff --git a/src/Documents/TemplateRegistry.php b/src/Documents/TemplateRegistry.php
new file mode 100644
index 0000000..82df289
--- /dev/null
+++ b/src/Documents/TemplateRegistry.php
@@ -0,0 +1,48 @@
+ */
+ private array $templates;
+
+ /**
+ * @param array $templates
+ */
+ public function __construct(string $templateDir, array $templates)
+ {
+ $this->templateDir = rtrim($templateDir, '/');
+ $this->templates = $templates;
+ }
+
+ public static function fromConfig(array $config): self
+ {
+ $templates = is_array($config['templates'] ?? null) ? $config['templates'] : [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+ ];
+
+ return new self((string)$config['template_path'], $templates);
+ }
+
+ public function path(string $key): string
+ {
+ if (!isset($this->templates[$key])) {
+ throw new InvalidArgumentException('Unknown template');
+ }
+
+ $path = $this->templateDir . '/' . $this->templates[$key];
+ if (!is_file($path)) {
+ throw new RuntimeException('Template not found');
+ }
+
+ return $path;
+ }
+}
diff --git a/src/Security/GenerateTokenStore.php b/src/Security/GenerateTokenStore.php
new file mode 100644
index 0000000..1ddedd9
--- /dev/null
+++ b/src/Security/GenerateTokenStore.php
@@ -0,0 +1,110 @@
+path = $path;
+ $this->ttlSeconds = $ttlSeconds;
+ }
+
+ public static function fromConfig(array $config): self
+ {
+ $security = is_array($config['security'] ?? null) ? $config['security'] : [];
+ $ttl = (int)($security['generate_token_ttl_seconds'] ?? 1800);
+ $path = (string)($security['generate_token_path'] ?? (
+ rtrim((string)($config['temp_data_path'] ?? (__DIR__ . '/../../data')), '/') . '/security/generate_tokens.json'
+ ));
+
+ return new self($path, $ttl > 0 ? $ttl : 1800);
+ }
+
+ public function issue(int $leadId): string
+ {
+ $tokens = $this->prune($this->load());
+ $token = bin2hex(random_bytes(32));
+ $tokens[$token] = [
+ 'lead_id' => $leadId,
+ 'expires_at' => time() + $this->ttlSeconds,
+ ];
+ $this->save($tokens);
+
+ return $token;
+ }
+
+ public function validate(int $leadId, string $token): bool
+ {
+ if ($leadId <= 0 || $token === '') {
+ return false;
+ }
+
+ $tokens = $this->prune($this->load());
+ $this->save($tokens);
+
+ if (!isset($tokens[$token])) {
+ return false;
+ }
+
+ return (int)$tokens[$token]['lead_id'] === $leadId;
+ }
+
+ /**
+ * @return array
+ */
+ private function load(): array
+ {
+ $data = json_decode((string)@file_get_contents($this->path), true);
+ if (!is_array($data)) {
+ return [];
+ }
+
+ $tokens = [];
+ foreach ($data as $token => $row) {
+ if (!is_string($token) || !is_array($row)) {
+ continue;
+ }
+ $tokens[$token] = [
+ 'lead_id' => (int)($row['lead_id'] ?? 0),
+ 'expires_at' => (int)($row['expires_at'] ?? 0),
+ ];
+ }
+
+ return $tokens;
+ }
+
+ /**
+ * @param array $tokens
+ * @return array
+ */
+ private function prune(array $tokens): array
+ {
+ $now = time();
+
+ return array_filter($tokens, static function (array $row) use ($now): bool {
+ return $row['expires_at'] >= $now;
+ });
+ }
+
+ /**
+ * @param array $tokens
+ */
+ private function save(array $tokens): void
+ {
+ $dir = dirname($this->path);
+ Filesystem::ensureDirectory($dir, 0775, 'Token directory');
+
+ if (file_put_contents($this->path, json_encode($tokens, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) === false) {
+ throw new RuntimeException(sprintf('Unable to write to file "%s"', $this->path));
+ }
+ }
+}
diff --git a/src/Security/RequestAuthenticator.php b/src/Security/RequestAuthenticator.php
new file mode 100644
index 0000000..88ede26
--- /dev/null
+++ b/src/Security/RequestAuthenticator.php
@@ -0,0 +1,76 @@
+ */
+ private array $config;
+ private GenerateTokenStore $tokenStore;
+
+ /**
+ * @param array $config
+ */
+ public function __construct(array $config, GenerateTokenStore $tokenStore)
+ {
+ $this->config = $config;
+ $this->tokenStore = $tokenStore;
+ }
+
+ /**
+ * @param array $input
+ * @param array $server
+ */
+ public function isAuthorized(string $rawBody, array $input, array $server): bool
+ {
+ $mode = $this->authMode();
+ if ($mode === 'hmac') {
+ return $this->validHmac($rawBody, $server);
+ }
+ if ($mode === 'either' && !empty($server['HTTP_X_SIGNATURE'])) {
+ return $this->validHmac($rawBody, $server);
+ }
+
+ return $this->tokenStore->validate(
+ (int)($input['lead_id'] ?? 0),
+ (string)($input['generate_token'] ?? '')
+ );
+ }
+
+ private function authMode(): string
+ {
+ $security = is_array($this->config['security'] ?? null) ? $this->config['security'] : [];
+ $mode = (string)($security['generate_auth_mode'] ?? 'browser_token');
+
+ return in_array($mode, ['browser_token', 'hmac', 'either'], true) ? $mode : 'browser_token';
+ }
+
+ /**
+ * @param array $server
+ */
+ private function validHmac(string $rawBody, array $server): bool
+ {
+ $secret = $this->hmacSecret();
+ $signature = (string)($server['HTTP_X_SIGNATURE'] ?? '');
+ if ($signature === '') {
+ return false;
+ }
+
+ return hash_equals(hash_hmac('sha256', $rawBody, $secret), $signature);
+ }
+
+ private function hmacSecret(): string
+ {
+ $security = is_array($this->config['security'] ?? null) ? $this->config['security'] : [];
+ $secret = (string)($security['hmac_secret'] ?? ($this->config['hmac_secret'] ?? ''));
+ if ($secret === '') {
+ throw new RuntimeException('HMAC secret is required for HMAC authentication');
+ }
+
+ return $secret;
+ }
+}
diff --git a/src/Storage/PrefillCache.php b/src/Storage/PrefillCache.php
new file mode 100644
index 0000000..0035913
--- /dev/null
+++ b/src/Storage/PrefillCache.php
@@ -0,0 +1,74 @@
+cacheDir = rtrim($cacheDir, '/');
+ $this->dirMode = $dirMode;
+ }
+
+ public static function fromConfig(array $config): self
+ {
+ $cacheDir = (string)($config['cache_path'] ?? (
+ rtrim((string)($config['temp_data_path'] ?? (__DIR__ . '/../../data')), '/') . '/cache'
+ ));
+
+ return new self($cacheDir, (int)($config['dir_mode'] ?? 0775));
+ }
+
+ /**
+ * @return array
+ */
+ public function read(int $leadId): array
+ {
+ $default = ['template' => 'order', 'discount' => 0, 'products' => [], 'saved_at' => 0];
+ if ($leadId <= 0) {
+ return $default;
+ }
+
+ $file = $this->path($leadId);
+ if (!is_file($file)) {
+ return $default;
+ }
+
+ $data = json_decode((string)@file_get_contents($file), true);
+
+ return is_array($data) ? $data : $default;
+ }
+
+ /**
+ * @param array> $products
+ */
+ public function write(int $leadId, string $template, int $discount, array $products): void
+ {
+ Filesystem::ensureDirectory($this->cacheDir, $this->dirMode, 'Cache directory');
+
+ $path = $this->path($leadId);
+ $data = json_encode([
+ 'saved_at' => time(),
+ 'template' => $template,
+ 'discount' => $discount,
+ 'products' => $products,
+ ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+
+ if (file_put_contents($path, $data) === false) {
+ throw new RuntimeException(sprintf('Unable to write to cache file: %s', $path));
+ }
+ }
+
+ private function path(int $leadId): string
+ {
+ return $this->cacheDir . '/' . $leadId . '.json';
+ }
+}
diff --git a/src/Support/Filesystem.php b/src/Support/Filesystem.php
new file mode 100644
index 0000000..a72cd2f
--- /dev/null
+++ b/src/Support/Filesystem.php
@@ -0,0 +1,30 @@
+path = $path;
+ $this->dirMode = $dirMode;
+ }
+
+ public static function fromConfig(array $config, string $filename): self
+ {
+ $logDir = rtrim((string)$config['logs_path'], '/');
+
+ return new self($logDir . '/' . $filename, (int)($config['dir_mode'] ?? 0775));
+ }
+
+ /**
+ * @param array $event
+ */
+ public function log(array $event): void
+ {
+ $dir = dirname($this->path);
+ try {
+ Filesystem::ensureDirectory($dir, $this->dirMode, 'Log directory');
+ } catch (RuntimeException $e) {
+ trigger_error($e->getMessage(), E_USER_WARNING);
+
+ return;
+ }
+
+ $result = file_put_contents(
+ $this->path,
+ json_encode($event, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n",
+ FILE_APPEND
+ );
+ if ($result === false) {
+ trigger_error("Could not write to log file: {$this->path}", E_USER_WARNING);
+ }
+ }
+}
diff --git a/tests/AmoCrmClientTest.php b/tests/AmoCrmClientTest.php
new file mode 100644
index 0000000..4429d71
--- /dev/null
+++ b/tests/AmoCrmClientTest.php
@@ -0,0 +1,77 @@
+writeTokens(['access_token' => 'old', 'refresh_token' => 'refresh']);
+ $calls = [];
+ $http = function (string $method, string $url, array $headers, $payload, int $timeout) use (&$calls): array {
+ $calls[] = compact('method', 'url', 'headers', 'payload', 'timeout');
+
+ if (count($calls) === 1) {
+ return [401, '{"error":"expired"}'];
+ }
+ if (count($calls) === 2) {
+ return [200, '{"access_token":"new","refresh_token":"refresh2"}'];
+ }
+
+ return [200, '{"id":123}'];
+ };
+
+ $client = new AmoCrmClient($this->config(), $tokenPath, $http);
+ $response = $client->get('/api/v4/leads/123');
+
+ $this->assertSame(['id' => 123], $response);
+ $this->assertCount(3, $calls);
+ $this->assertSame('https://example.amocrm.ru/oauth2/access_token', $calls[1]['url']);
+ $this->assertContains('Authorization: Bearer new', $calls[2]['headers']);
+
+ $saved = json_decode((string)file_get_contents($tokenPath), true);
+ $this->assertSame('new', $saved['access_token']);
+ $this->assertArrayHasKey('created_at', $saved);
+ }
+
+ public function testThrowsNormalizedErrorForFailedAmoResponse(): void
+ {
+ $tokenPath = $this->writeTokens(['access_token' => 'token']);
+ $client = new AmoCrmClient($this->config(), $tokenPath, static function (): array {
+ return [500, '{"detail":"boom"}'];
+ });
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('AMO 500: {"detail":"boom"}');
+
+ $client->get('/api/v4/leads/1');
+ }
+
+ /**
+ * @param array $tokens
+ */
+ private function writeTokens(array $tokens): string
+ {
+ $path = tempnam(sys_get_temp_dir(), 'amo_tokens_');
+ self::assertIsString($path);
+ file_put_contents($path, json_encode($tokens));
+
+ return $path;
+ }
+
+ /**
+ * @return array
+ */
+ private function config(): array
+ {
+ return [
+ 'base_domain' => 'https://example.amocrm.ru',
+ 'client_id' => 'client',
+ 'client_secret' => 'secret',
+ 'redirect_uri' => 'https://example.test/oauth.php',
+ ];
+ }
+}
diff --git a/tests/AmoCrmNoteServiceTest.php b/tests/AmoCrmNoteServiceTest.php
new file mode 100644
index 0000000..f9c9074
--- /dev/null
+++ b/tests/AmoCrmNoteServiceTest.php
@@ -0,0 +1,72 @@
+dir(), static function (): void {
+ }, static function (): array {
+ return [];
+ });
+
+ $this->assertSame([
+ 'entity_id' => 123,
+ 'entity_type' => 'leads',
+ 'note_type' => 'common',
+ 'params' => ['text' => 'Акт приёма-передачи №123: https://docs.example/doc.docx'],
+ ], $service->payload(123, 'act', 'https://docs.example/doc.docx'));
+ }
+
+ public function testDeletesPreviousNoteAndStoresNewNoteId(): void
+ {
+ $dir = $this->dir();
+ mkdir($dir, 0777, true);
+ file_put_contents($dir . '/lead_123_meta.json', json_encode(['note_id' => 10]));
+ $deleted = [];
+ $posted = [];
+
+ $service = new AmoCrmNoteService(
+ $dir,
+ static function (int $noteId) use (&$deleted): void {
+ $deleted[] = $noteId;
+ },
+ static function (array $payload) use (&$posted): array {
+ $posted[] = $payload;
+
+ return ['_embedded' => ['notes' => [['id' => 20]]]];
+ }
+ );
+
+ $service->replaceDocumentNote(123, 'order', 'https://docs.example/doc.docx');
+
+ $this->assertSame([10], $deleted);
+ $this->assertCount(1, $posted);
+ $meta = json_decode((string)file_get_contents($dir . '/lead_123_meta.json'), true);
+ $this->assertSame(20, $meta['note_id']);
+ }
+
+ public function testThrowsWhenMetadataDirectoryCannotBeCreated(): void
+ {
+ $blockedPath = $this->dir();
+ file_put_contents($blockedPath, 'not a directory');
+ $service = new AmoCrmNoteService($blockedPath . '/meta', static function (): void {
+ }, static function (): array {
+ return [];
+ });
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Parent directory');
+
+ $service->replaceDocumentNote(123, 'order', 'https://docs.example/doc.docx');
+ }
+
+ private function dir(): string
+ {
+ return str_replace('\\', '/', sys_get_temp_dir() . '/amodocs_notes_' . uniqid('', true));
+ }
+}
diff --git a/tests/CustomFieldMapperTest.php b/tests/CustomFieldMapperTest.php
new file mode 100644
index 0000000..cc962e5
--- /dev/null
+++ b/tests/CustomFieldMapperTest.php
@@ -0,0 +1,37 @@
+ 333]);
+
+ $value = $mapper->value([
+ [
+ 'field_id' => 333,
+ 'field_name' => 'VIN renamed in CRM',
+ 'values' => [['value' => 'XTA123']],
+ ],
+ ], 'vin');
+
+ $this->assertSame('XTA123', $value);
+ }
+
+ public function testReturnsEmptyStringWhenFieldIdIsNotConfigured(): void
+ {
+ $mapper = new CustomFieldMapper([]);
+
+ $this->assertSame('', $mapper->value([
+ [
+ 'field_id' => 333,
+ 'field_name' => 'VIN',
+ 'values' => [['value' => 'XTA123']],
+ ],
+ ], 'vin'));
+ }
+}
diff --git a/tests/DocumentGenerationServiceTest.php b/tests/DocumentGenerationServiceTest.php
new file mode 100644
index 0000000..cffc09a
--- /dev/null
+++ b/tests/DocumentGenerationServiceTest.php
@@ -0,0 +1,107 @@
+fixturePaths();
+ touch($paths['template'] . '/act_template.docx');
+ $usedTemplate = null;
+
+ $service = new DocumentGenerationService($this->config($paths), function (string $templatePath) use (&$usedTemplate) {
+ $usedTemplate = $templatePath;
+
+ return new FakeTemplateProcessor();
+ });
+
+ $result = $service->generate(['id' => 55], null, 'act', [['name' => 'Service', 'unit_price' => 100, 'qty' => 1]], 0);
+
+ $this->assertSame($paths['template'] . '/act_template.docx', $usedTemplate);
+ $this->assertFileExists($result['path']);
+ $this->assertStringStartsWith('https://docs.example/doc_55_', $result['url']);
+ }
+
+ public function testThrowsWhenTemplateFileDoesNotExist(): void
+ {
+ $service = new DocumentGenerationService($this->config($this->fixturePaths()));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Template not found');
+
+ $service->generate(['id' => 55], null, 'order', [['name' => 'Service', 'unit_price' => 100, 'qty' => 1]], 0);
+ }
+
+ public function testThrowsWhenDocumentDirectoryCannotBeCreated(): void
+ {
+ $paths = $this->fixturePaths();
+ touch($paths['template'] . '/order_template.docx');
+ $blockedDocumentPath = $paths['document'] . '/blocked-by-file';
+ file_put_contents($blockedDocumentPath, 'not a directory');
+ $config = $this->config($paths);
+ $config['document_path'] = $blockedDocumentPath . '/documents';
+ $service = new DocumentGenerationService($config, static function (): FakeTemplateProcessor {
+ return new FakeTemplateProcessor();
+ });
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Parent directory');
+
+ $service->generate(['id' => 55], null, 'order', [['name' => 'Service', 'unit_price' => 100, 'qty' => 1]], 0);
+ }
+
+ /**
+ * @return array{template:string,document:string}
+ */
+ private function fixturePaths(): array
+ {
+ $root = sys_get_temp_dir() . '/amodocs_' . uniqid('', true);
+ mkdir($root . '/templates', 0777, true);
+ mkdir($root . '/documents', 0777, true);
+
+ return [
+ 'template' => str_replace('\\', '/', $root . '/templates'),
+ 'document' => str_replace('\\', '/', $root . '/documents'),
+ ];
+ }
+
+ /**
+ * @param array{template:string,document:string} $paths
+ * @return array
+ */
+ private function config(array $paths): array
+ {
+ return [
+ 'template_path' => $paths['template'],
+ 'document_path' => $paths['document'],
+ 'public_documents_url' => 'https://docs.example',
+ 'dir_mode' => 0777,
+ 'file_mode' => 0644,
+ 'templates' => [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+ ],
+ ];
+ }
+}
+
+final class FakeTemplateProcessor
+{
+ /** @param mixed $value */
+ public function setValue(string $key, $value): void
+ {
+ }
+
+ public function cloneRow(string $search, int $numberOfClones): void
+ {
+ }
+
+ public function saveAs(string $path): void
+ {
+ file_put_contents($path, 'docx');
+ }
+}
diff --git a/tests/DocumentQuoteServiceTest.php b/tests/DocumentQuoteServiceTest.php
new file mode 100644
index 0000000..251067f
--- /dev/null
+++ b/tests/DocumentQuoteServiceTest.php
@@ -0,0 +1,25 @@
+quote([
+ ['name' => 'Диагностика', 'unit_price' => 1500, 'qty' => 1],
+ ['name' => 'Ремонт', 'price' => 6000, 'quantity' => 2, 'discount_percent' => 10],
+ ], 700);
+
+ $this->assertCount(2, $quote['rows']);
+ $this->assertSame(7500, $quote['sum_gross']);
+ $this->assertSame(6900, $quote['sum_after']);
+ $this->assertSame(700, $quote['discount']);
+ $this->assertSame(6200, $quote['total']);
+ $this->assertSame(2, $quote['count']);
+ $this->assertSame('шесть тысяч двести рублей', $quote['total_words']);
+ }
+}
diff --git a/tests/FrontendApiPathTest.php b/tests/FrontendApiPathTest.php
new file mode 100644
index 0000000..620f15d
--- /dev/null
+++ b/tests/FrontendApiPathTest.php
@@ -0,0 +1,31 @@
+assertIsString($script);
+ $this->assertStringContainsString("const API_BASE = '/api';", $script);
+ $this->assertStringContainsString("fetch(API_BASE + '/generate.php'", $script);
+ $this->assertStringContainsString("fetch(API_BASE + '/prefill.php?lead_id='", $script);
+ $this->assertStringContainsString("fetch(API_BASE + '/quote.php'", $script);
+ $this->assertStringContainsString('generate_token: generateToken', $script);
+ $this->assertStringNotContainsString('/api/generate.php/api/', $script);
+ $this->assertStringNotContainsString("const API = '/api/generate.php';", $script);
+ }
+
+ public function testFrontendDoesNotDuplicateRubleWordsFormatter(): void
+ {
+ $script = file_get_contents(__DIR__ . '/../public/app.js');
+
+ $this->assertIsString($script);
+ $this->assertStringNotContainsString('function toWords', $script);
+ $this->assertStringNotContainsString('function morph', $script);
+ }
+}
diff --git a/tests/GenerateTokenStoreTest.php b/tests/GenerateTokenStoreTest.php
new file mode 100644
index 0000000..8e5ea4d
--- /dev/null
+++ b/tests/GenerateTokenStoreTest.php
@@ -0,0 +1,51 @@
+path(), 60);
+ $token = $store->issue(123);
+
+ $this->assertTrue($store->validate(123, $token));
+ $this->assertFalse($store->validate(124, $token));
+ $this->assertFalse($store->validate(123, 'bad-token'));
+ }
+
+ public function testExpiredTokenIsRejected(): void
+ {
+ $path = $this->path();
+ file_put_contents($path, json_encode([
+ 'old' => ['lead_id' => 123, 'expires_at' => time() - 1],
+ ]));
+
+ $store = new GenerateTokenStore($path, 60);
+
+ $this->assertFalse($store->validate(123, 'old'));
+ }
+
+ public function testThrowsWhenTokenDirectoryCannotBeCreated(): void
+ {
+ $blockedPath = sys_get_temp_dir() . '/amodocs_token_blocked_' . uniqid('', true);
+ file_put_contents($blockedPath, 'not a directory');
+ $store = new GenerateTokenStore(str_replace('\\', '/', $blockedPath . '/tokens.json'), 60);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Token directory');
+
+ $store->issue(123);
+ }
+
+ private function path(): string
+ {
+ $dir = sys_get_temp_dir() . '/amodocs_tokens_' . uniqid('', true);
+ mkdir($dir, 0777, true);
+
+ return str_replace('\\', '/', $dir . '/tokens.json');
+ }
+}
diff --git a/tests/JsonLoggerTest.php b/tests/JsonLoggerTest.php
new file mode 100644
index 0000000..8c95428
--- /dev/null
+++ b/tests/JsonLoggerTest.php
@@ -0,0 +1,43 @@
+log(['EX' => 'boom', 'line' => 12]);
+
+ $this->assertStringContainsString('"EX": "boom"', (string)file_get_contents($path));
+ $this->assertStringContainsString('"line": 12', (string)file_get_contents($path));
+ }
+
+ public function testTriggersWarningWhenLogDirectoryCannotBeCreated(): void
+ {
+ $blockedPath = str_replace('\\', '/', sys_get_temp_dir() . '/amodocs_log_blocked_' . uniqid('', true));
+ file_put_contents($blockedPath, 'not a directory');
+ $logger = new JsonLogger($blockedPath . '/generate.log');
+ $warning = null;
+
+ set_error_handler(static function (int $severity, string $message) use (&$warning): bool {
+ $warning = ['severity' => $severity, 'message' => $message];
+
+ return true;
+ });
+
+ try {
+ $logger->log(['EX' => 'boom']);
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame(E_USER_WARNING, $warning['severity'] ?? null);
+ $this->assertStringContainsString('Log directory', $warning['message'] ?? '');
+ }
+}
diff --git a/tests/PrefillCacheTest.php b/tests/PrefillCacheTest.php
new file mode 100644
index 0000000..d061450
--- /dev/null
+++ b/tests/PrefillCacheTest.php
@@ -0,0 +1,52 @@
+dir());
+
+ $this->assertSame([
+ 'template' => 'order',
+ 'discount' => 0,
+ 'products' => [],
+ 'saved_at' => 0,
+ ], $cache->read(123));
+ }
+
+ public function testWritesAndReadsPayload(): void
+ {
+ $cache = new PrefillCache($this->dir());
+ $products = [['name' => 'Service', 'qty' => 1, 'unit_price' => 100]];
+
+ $cache->write(123, 'act', 50, $products);
+ $payload = $cache->read(123);
+
+ $this->assertSame('act', $payload['template']);
+ $this->assertSame(50, $payload['discount']);
+ $this->assertSame($products, $payload['products']);
+ $this->assertGreaterThan(0, $payload['saved_at']);
+ }
+
+ public function testThrowsWhenCacheDirectoryCannotBeCreated(): void
+ {
+ $blockedPath = $this->dir();
+ file_put_contents($blockedPath, 'not a directory');
+ $cache = new PrefillCache($blockedPath . '/cache');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Parent directory');
+
+ $cache->write(123, 'order', 0, []);
+ }
+
+ private function dir(): string
+ {
+ return str_replace('\\', '/', sys_get_temp_dir() . '/amodocs_cache_' . uniqid('', true));
+ }
+}
diff --git a/tests/RequestAuthenticatorTest.php b/tests/RequestAuthenticatorTest.php
new file mode 100644
index 0000000..985db9a
--- /dev/null
+++ b/tests/RequestAuthenticatorTest.php
@@ -0,0 +1,67 @@
+path(), 60));
+
+ $this->assertFalse($auth->isAuthorized('{"lead_id":123}', ['lead_id' => 123], []));
+ }
+
+ public function testRejectsBrowserTokenForAnotherLead(): void
+ {
+ $store = new GenerateTokenStore($this->path(), 60);
+ $token = $store->issue(123);
+ $auth = new RequestAuthenticator([], $store);
+
+ $this->assertFalse($auth->isAuthorized('', ['lead_id' => 124, 'generate_token' => $token], []));
+ }
+
+ public function testAcceptsValidBrowserToken(): void
+ {
+ $store = new GenerateTokenStore($this->path(), 60);
+ $token = $store->issue(123);
+ $auth = new RequestAuthenticator([], $store);
+
+ $this->assertTrue($auth->isAuthorized('', ['lead_id' => 123, 'generate_token' => $token], []));
+ }
+
+ public function testHmacModeRequiresConfiguredSecret(): void
+ {
+ $auth = new RequestAuthenticator(['security' => ['generate_auth_mode' => 'hmac']], new GenerateTokenStore($this->path(), 60));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('HMAC secret is required');
+
+ $auth->isAuthorized('{"lead_id":123}', ['lead_id' => 123], ['HTTP_X_SIGNATURE' => 'abc']);
+ }
+
+ public function testAcceptsValidHmacSignatureInHmacMode(): void
+ {
+ $raw = '{"lead_id":123}';
+ $secret = 'secret';
+ $auth = new RequestAuthenticator(
+ ['security' => ['generate_auth_mode' => 'hmac', 'hmac_secret' => $secret]],
+ new GenerateTokenStore($this->path(), 60)
+ );
+
+ $this->assertTrue($auth->isAuthorized($raw, ['lead_id' => 123], [
+ 'HTTP_X_SIGNATURE' => hash_hmac('sha256', $raw, $secret),
+ ]));
+ }
+
+ private function path(): string
+ {
+ $dir = sys_get_temp_dir() . '/amodocs_auth_' . uniqid('', true);
+ mkdir($dir, 0777, true);
+
+ return str_replace('\\', '/', $dir . '/tokens.json');
+ }
+}
diff --git a/tests/SmokeTest.php b/tests/SmokeTest.php
index 3ab1072..dfe6eae 100644
--- a/tests/SmokeTest.php
+++ b/tests/SmokeTest.php
@@ -11,6 +11,7 @@ public function testEntryScriptsExist(): void
{
$this->assertFileExists(__DIR__ . '/../api/generate.php');
$this->assertFileExists(__DIR__ . '/../api/prefill.php');
+ $this->assertFileExists(__DIR__ . '/../api/quote.php');
$this->assertFileExists(__DIR__ . '/../oauth.php');
}
diff --git a/tests/TemplateRegistryTest.php b/tests/TemplateRegistryTest.php
new file mode 100644
index 0000000..d0a5c75
--- /dev/null
+++ b/tests/TemplateRegistryTest.php
@@ -0,0 +1,57 @@
+templateDir(['order_template.docx', 'act_template.docx']);
+ $registry = new TemplateRegistry($dir, [
+ 'order' => 'order_template.docx',
+ 'act' => 'act_template.docx',
+ ]);
+
+ $this->assertSame($dir . '/order_template.docx', $registry->path('order'));
+ $this->assertSame($dir . '/act_template.docx', $registry->path('act'));
+ }
+
+ public function testRejectsUnknownTemplateKey(): void
+ {
+ $registry = new TemplateRegistry($this->templateDir(['order_template.docx']), [
+ 'order' => 'order_template.docx',
+ ]);
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unknown template');
+
+ $registry->path('invoice');
+ }
+
+ public function testCanAddThirdTemplateThroughConfigOnly(): void
+ {
+ $dir = $this->templateDir(['invoice_template.docx']);
+ $registry = new TemplateRegistry($dir, [
+ 'invoice' => 'invoice_template.docx',
+ ]);
+
+ $this->assertSame($dir . '/invoice_template.docx', $registry->path('invoice'));
+ }
+
+ /**
+ * @param array $files
+ */
+ private function templateDir(array $files): string
+ {
+ $dir = str_replace('\\', '/', sys_get_temp_dir() . '/amodocs_tpl_' . uniqid('', true));
+ mkdir($dir, 0777, true);
+ foreach ($files as $file) {
+ touch($dir . '/' . $file);
+ }
+
+ return $dir;
+ }
+}