Версия 1
This commit is contained in:
parent
280de1a095
commit
8b7201500e
46
GITEA.md
Normal file
46
GITEA.md
Normal file
@ -0,0 +1,46 @@
|
||||
# 🚀 Как бесплатно опубликовать свой сайт (Gitea Pages)
|
||||
|
||||
На нашем сервере Gitea работает функция **Pages**. Вы можете выложить любой статический сайт (HTML, CSS, JS), и он будет доступен всему миру по ссылке:
|
||||
`https://<ваше-имя-пользователя>.mydomain.com/<имя-репозитория>/`
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Пошаговая инструкция для новичков
|
||||
|
||||
### Шаг 1. Подготовьте файлы сайта
|
||||
Убедитесь, что главный файл вашего сайта называется **`index.html`** (все буквы строчные!). Он должен находиться в самом корне проекта, а не внутри папок вроде `src/` или `dist/`.
|
||||
|
||||
### Шаг 2. Создайте специальную ветку (Branch)
|
||||
Gitea ищет сайт в строго определенном месте. Нам нужно создать ветку с именем **`gh-pages`**:
|
||||
|
||||
1. Откройте ваш репозиторий в браузере.
|
||||
2. В левом верхнем углу найдите выпадающий список веток (там обычно написано `main` или `master`).
|
||||
3. Нажмите на него, введите в строку поиска **`gh-pages`** и нажмите **«Создать ветку»** (Create branch).
|
||||
|
||||
### Шаг 3. Загрузите файлы в эту ветку
|
||||
Переключитесь на ветку `gh-pages` (если вы еще не в ней) и добавьте туда файлы вашего сайта (`index.html`, картинки, стили).
|
||||
* *Через Git:* `git checkout -b gh-pages`, закинуть файлы, `git commit` и `git push origin gh-pages`.
|
||||
* *Через сайт:* Нажмите кнопку **«Добавить файл»** -> **«Загрузить файлы»** прямо в ветку `gh-pages`.
|
||||
|
||||
### Шаг 4. Включите Pages в настройках
|
||||
1. Перейдите в **Настройки** (Settings) вашего репозитория (шестеренка справа вверху).
|
||||
2. В левом меню найдите вкладку **Pages** (Страницы).
|
||||
3. В выпадающем списке выбора ветки выберите **`gh-pages`** и нажмите **Сохранить** (Update / Save).
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Где искать мой сайт?
|
||||
|
||||
Как только вы сохраните настройки, на этой же странице (вкладка *Pages*) появится ваша персональная ссылка. Она выглядит так:
|
||||
|
||||
> 🌐 **`https://<ваш_логин>.mydomain.com/<имя_репозитория>/`**
|
||||
|
||||
*Обновление сайта происходит автоматически при каждом новом пуше (изменении файлов) в ветку `gh-pages`!*
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Частые ошибки (Почему не работает?)
|
||||
|
||||
* **Ошибка 404 (Not Found):** Проверьте, что ваш главный файл называется именно `index.html` (не `Index.html` и не `main.html`) и лежит в корне ветки `gh-pages`.
|
||||
* **Сайт не обновился:** Браузер мог закэшировать старую версию. Нажмите `Ctrl + F5` (или `Cmd + Shift + R` на Mac) для полной очистки кэша страницы.
|
||||
* **Картинки/Стили «сломались»:** Убедитесь, что пути к картинкам и CSS указаны относительно (например, `./images/photo.png`, а не `/images/photo.png`).
|
||||
161
PRD.md
161
PRD.md
@ -21,3 +21,164 @@
|
||||
## Что НЕ входит в задачу
|
||||
<!-- Чтобы не разрастаться -->
|
||||
|
||||
# Бизнес-идея: Валидатор БИН в налоговых регистрах Excel
|
||||
|
||||
> **Статус:** Концепция · **Рынок:** Казахстан · **Формат:** B2B SaaS / надстройка Excel
|
||||
|
||||
---
|
||||
|
||||
## 1. Проблема
|
||||
|
||||
Налоговые регистры в формате Excel заполняются вручную. Столбец **БИН** содержит системообразующий идентификатор — на его основе строятся:
|
||||
|
||||
- строки декларации по КПН (100.00.019 и др.)
|
||||
- сверка с реестром КГД
|
||||
- контроль аффилированности контрагентов
|
||||
- ФЛК (форматно-логический контроль) при подаче отчётности
|
||||
|
||||
**Типичные ошибки при ручном вводе:**
|
||||
|
||||
| Тип ошибки | Пример | Последствие |
|
||||
|---|---|---|
|
||||
| Неверное количество цифр | `94124000019` (11 цифр) | Ошибка ФЛК при сдаче |
|
||||
| Опечатка в цифре | `941240000194` → `941240000184` | Несуществующий контрагент |
|
||||
| Замена БИН на ИИН | `830215350421` (ИИН ФЛ) | Несоответствие типа субъекта |
|
||||
| Неверная контрольная цифра | `941240000195` | Технически невалидный БИН |
|
||||
| Пробелы, лишние символы | `941 240 000 193` | Не воспринимается системой |
|
||||
|
||||
Выявляются эти ошибки **только на этапе сдачи декларации** — исправление требует пересмотра всего регистра.
|
||||
|
||||
---
|
||||
|
||||
## 2. Решение
|
||||
|
||||
**Excel-надстройка (Add-in) + Telegram-бот** для мгновенной проверки графы БИН непосредственно в рабочем файле.
|
||||
|
||||
### 2.1 Алгоритм ФЛК для БИН (локально, без интернета)
|
||||
|
||||
БИН Казахстана — 12-значный код. Структура:
|
||||
|
||||
```
|
||||
YYMMТSSSSSС
|
||||
│││││││ └─ контрольная цифра (12-й знак)
|
||||
│││││└──────── серийный номер (знаки 8–11)
|
||||
││││└───────── тип субъекта (7-й знак)
|
||||
│││└────────── дата регистрации (знаки 1–6: YYMMDD)
|
||||
```
|
||||
|
||||
**7-й знак (тип субъекта):**
|
||||
|
||||
| Значение | Тип |
|
||||
|---|---|
|
||||
| 4 | Юридическое лицо |
|
||||
| 5 | Филиал / представительство |
|
||||
| 6 | ИП |
|
||||
|
||||
> ⚠️ Если в графе БИН стоит значение с 7-м знаком 1–3 — это **ИИН физлица**, а не БИН организации.
|
||||
|
||||
**Контрольная цифра:** вычисляется по алгоритму Министерства юстиции РК (два прохода весовых коэффициентов, аналогично ИИН).
|
||||
|
||||
### 2.2 Расширенная проверка (онлайн, через КГД)
|
||||
|
||||
- Наличие в реестре налогоплательщиков
|
||||
- Статус: действующий / ликвидированный / приостановленный
|
||||
- Соответствие наименования (защита от подмены)
|
||||
- Признак аффилированности (дополнительный модуль)
|
||||
|
||||
---
|
||||
|
||||
## 3. Продукт
|
||||
|
||||
### Вариант A — Excel Add-in (быстрый запуск)
|
||||
|
||||
Надстройка на VBA или Python (xlwings), запускается кнопкой прямо в файле:
|
||||
|
||||
1. Пользователь открывает регистр → нажимает **«Проверить БИН»**
|
||||
2. Надстройка сканирует указанный столбец
|
||||
3. Невалидные ячейки подсвечиваются красным, рядом — пояснение ошибки
|
||||
4. Опционально: онлайн-запрос к КГД через Telegram-бот
|
||||
|
||||
**Стек:** Python + xlwings / VBA + requests
|
||||
|
||||
### Вариант B — Telegram-бот (уже реализован частично)
|
||||
|
||||
Расширение существующего бота проверки задолженностей:
|
||||
|
||||
- Команда `/checkbin <БИН>` — мгновенная проверка
|
||||
- Загрузка Excel-файла → бот возвращает отчёт по всем БИН
|
||||
- Подписка на мониторинг: уведомление при смене статуса контрагента
|
||||
|
||||
### Вариант C — Веб-сервис (масштабирование)
|
||||
|
||||
SaaS-платформа с API для интеграции в 1С, SAP, собственные ERP.
|
||||
|
||||
---
|
||||
|
||||
## 4. Монетизация
|
||||
|
||||
| Уровень | Возможности | Цена |
|
||||
|---|---|---|
|
||||
| **Free** | Локальная ФЛК, до 50 БИН/день | Бесплатно |
|
||||
| **Pro** | Онлайн КГД + история статусов | 5 000 ₸/мес |
|
||||
| **Business** | API + мониторинг + Excel Add-in | 25 000 ₸/мес |
|
||||
| **Enterprise** | Белая метка + интеграция 1С/SAP | Договорная |
|
||||
|
||||
---
|
||||
|
||||
## 5. Целевая аудитория
|
||||
|
||||
- **Бухгалтеры крупных компаний** — ведут регистры КПН с сотнями контрагентов
|
||||
- **Аудиторы и консультанты** — проверяют регистры клиентов перед сдачей
|
||||
- **Бухгалтерские аутсорсинговые компании** — работают с множеством юрлиц
|
||||
- **Малый бизнес** — через Telegram-бот без установки надстроек
|
||||
|
||||
---
|
||||
|
||||
## 6. Конкурентные преимущества
|
||||
|
||||
| Параметр | Существующие решения | Данный продукт |
|
||||
|---|---|---|
|
||||
| Скорость проверки | Ручная, при сдаче | Мгновенно, в процессе заполнения |
|
||||
| Работа в Excel | Нет | Нативная надстройка |
|
||||
| ФЛК офлайн | Нет | Да, без интернета |
|
||||
| Интеграция с ботом | Нет | Да, уже готовая база |
|
||||
| Понимание рынка РК | Общее | Глубокое (18+ лет в налогах) |
|
||||
|
||||
---
|
||||
|
||||
## 7. MVP — план на 30 дней
|
||||
|
||||
```
|
||||
Неделя 1 Алгоритм ФЛК БИН на Python (локальная валидация)
|
||||
Неделя 2 Excel-файл с VBA-макросом / xlwings-скриптом
|
||||
Неделя 3 Интеграция в Telegram-бот (команда /checkbin + загрузка файла)
|
||||
Неделя 4 Тест на реальных регистрах → первые 5 клиентов (бесплатно)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Риски и ограничения
|
||||
|
||||
| Риск | Вероятность | Митигация |
|
||||
|---|---|---|
|
||||
| КГД закроет API | Средняя | Playwright как fallback (уже реализован) |
|
||||
| Конкуренты скопируют | Высокая | Скорость выхода + экспертиза |
|
||||
| Нежелание платить | Средняя | Free-tier с реальной ценностью |
|
||||
| Изменение структуры БИН | Низкая | Обновление алгоритма в боте |
|
||||
|
||||
---
|
||||
|
||||
## 9. Связь с текущими проектами
|
||||
|
||||
Данная идея является **органическим расширением** уже реализованных компонентов:
|
||||
|
||||
- ✅ Telegram-бот на Python (Playwright + APScheduler + 2captcha)
|
||||
- ✅ Проверка задолженностей через КГД
|
||||
- ✅ GPU-станция для локальных моделей
|
||||
- ✅ Экспертиза в налоговом учёте (18 лет)
|
||||
|
||||
Добавить валидацию БИН = **1–2 недели разработки** поверх готовой инфраструктуры.
|
||||
|
||||
---
|
||||
|
||||
*Документ подготовлен: май 2026*
|
||||
|
||||
409
TRD.md
409
TRD.md
@ -13,3 +13,412 @@
|
||||
|
||||
Полученный текст положи сюда (замени этот комментарий).
|
||||
-->
|
||||
# TRD: Валидатор БИН на ZorroBPM — MVP
|
||||
|
||||
> **Источники:** PRD «Валидатор БИН в налоговых регистрах Excel» + ZORRO.md (ZorroBPM Development Guide)
|
||||
> **Цель:** Рабочий MVP за ~1 час
|
||||
> **Принцип:** минимум кода, максимум готовой инфраструктуры ZorroBPM
|
||||
|
||||
---
|
||||
|
||||
## 0. Идея в одном абзаце
|
||||
|
||||
Пользователь загружает Excel-файл с графой БИН через простой HTML-интерфейс.
|
||||
Python-воркер читает файл, валидирует каждый БИН (ФЛК локально),
|
||||
затем для подозрительных — дополнительно проверяет через КГД.
|
||||
ZorroBPM управляет этим потоком как BPMN-процессом:
|
||||
запуск → service task (ФЛК) → service task (КГД) → результат.
|
||||
Итог — таблица с цветовой разметкой прямо в браузере.
|
||||
|
||||
---
|
||||
|
||||
## 1. Сущности данных
|
||||
|
||||
### 1.1 Переменные процесса ZorroBPM (Variables)
|
||||
|
||||
Передаются при старте инстанса и обновляются воркерами.
|
||||
|
||||
| Имя переменной | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `file_id` | `STRING` | UUID загруженного файла (tmp-хранилище) |
|
||||
| `file_name` | `STRING` | Оригинальное имя файла |
|
||||
| `bin_column` | `STRING` | Имя или индекс столбца с БИН |
|
||||
| `total_rows` | `LONG` | Кол-во строк для проверки |
|
||||
| `flc_result_json` | `STRING` | JSON-массив результатов ФЛК |
|
||||
| `kgd_result_json` | `STRING` | JSON-массив онлайн-проверок КГД |
|
||||
| `has_errors` | `BOOLEAN` | Есть ли ошибки после ФЛК |
|
||||
| `report_id` | `STRING` | UUID итогового отчёта |
|
||||
|
||||
### 1.2 Структура `flc_result_json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"row": 3,
|
||||
"bin": "941240000193",
|
||||
"normalized": "941240000193",
|
||||
"is_valid": true,
|
||||
"error_code": null,
|
||||
"error_msg": null,
|
||||
"entity_type": "ЮЛ",
|
||||
"reg_year": 1994
|
||||
},
|
||||
{
|
||||
"row": 7,
|
||||
"bin": "941240000195",
|
||||
"normalized": "941240000195",
|
||||
"is_valid": false,
|
||||
"error_code": "ERROR_CHECK_DIGIT",
|
||||
"error_msg": "Неверная контрольная цифра",
|
||||
"entity_type": null,
|
||||
"reg_year": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 1.3 Структура `kgd_result_json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"bin": "941240000193",
|
||||
"kgd_name": "АО «Казахтелеком»",
|
||||
"kgd_status": "Действующий",
|
||||
"kgd_reg_date": "1994-12-01",
|
||||
"from_cache": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 1.4 Локальное хранилище (tmp, без БД)
|
||||
|
||||
```
|
||||
tmp/
|
||||
├── uploads/
|
||||
│ └── {file_id}.xlsx # загруженный файл
|
||||
└── reports/
|
||||
└── {report_id}.json # итоговый отчёт
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. BPMN-процесс
|
||||
|
||||
### 2.1 Схема (линейная, без gateway — проще для MVP)
|
||||
|
||||
```
|
||||
[StartEvent]
|
||||
│
|
||||
▼
|
||||
[ServiceTask: srv_flc_validate] job="srv_flc_validate"
|
||||
│ Воркер читает файл, валидирует все БИН локально
|
||||
│ Пишет: flc_result_json, has_errors, total_rows
|
||||
│
|
||||
▼
|
||||
[ServiceTask: srv_kgd_check] job="srv_kgd_check"
|
||||
│ Воркер проверяет невалидные БИН через КГД
|
||||
│ Пишет: kgd_result_json, report_id
|
||||
│ (если has_errors=false — просто пропускает КГД-запросы)
|
||||
│
|
||||
▼
|
||||
[EndEvent]
|
||||
```
|
||||
|
||||
### 2.2 BPMN XML для деплоя
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:zorrobpm="http://zorrobpm.dev/schema"
|
||||
id="bin-validation-def"
|
||||
targetNamespace="http://zorrobpm.dev">
|
||||
|
||||
<bpmn:process id="bin-validation" name="Валидация БИН" isExecutable="true">
|
||||
|
||||
<bpmn:startEvent id="start" name="Старт"/>
|
||||
|
||||
<bpmn:sequenceFlow id="sf1" sourceRef="start" targetRef="srv_flc"/>
|
||||
|
||||
<bpmn:serviceTask id="srv_flc" name="ФЛК валидация"
|
||||
zorrobpm:job="srv_flc_validate"/>
|
||||
|
||||
<bpmn:sequenceFlow id="sf2" sourceRef="srv_flc" targetRef="srv_kgd"/>
|
||||
|
||||
<bpmn:serviceTask id="srv_kgd" name="Проверка КГД"
|
||||
zorrobpm:job="srv_kgd_check"/>
|
||||
|
||||
<bpmn:sequenceFlow id="sf3" sourceRef="srv_kgd" targetRef="end"/>
|
||||
|
||||
<bpmn:endEvent id="end" name="Готово"/>
|
||||
|
||||
</bpmn:process>
|
||||
</bpmn:definitions>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API ZorroBPM — что вызываем
|
||||
|
||||
Base URL: `http://localhost:9092` (local) / `https://api.m11s.zorrobpm.dev` (demo)
|
||||
|
||||
### 3.1 Деплой процесса (один раз при старте приложения)
|
||||
|
||||
```http
|
||||
POST /process-definitions
|
||||
Content-Type: application/json
|
||||
|
||||
{ "bpmn": "<содержимое bpmn/bin_validation.bpmn>" }
|
||||
```
|
||||
|
||||
Сохрани `key` из ответа → нужен для старта инстансов.
|
||||
|
||||
### 3.2 Запуск процесса при загрузке файла
|
||||
|
||||
```http
|
||||
POST /process-instances
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"processDefinitionKey": "bin-validation",
|
||||
"variables": [
|
||||
{ "name": "file_id", "value": "{uuid}", "type": "STRING" },
|
||||
{ "name": "file_name", "value": "Книга1.xlsx", "type": "STRING" },
|
||||
{ "name": "bin_column", "value": "БИН", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ответ: `{ "id": "instance-uuid" }` — сохранить для polling.
|
||||
|
||||
### 3.3 Polling незавершённых задач (воркер)
|
||||
|
||||
```http
|
||||
GET /service-tasks?completed=false
|
||||
```
|
||||
|
||||
Фильтровать по `job` в ответе: `srv_flc_validate` или `srv_kgd_check`.
|
||||
|
||||
### 3.4 Завершение service task
|
||||
|
||||
```http
|
||||
POST /service-tasks/{task_id}/complete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"variables": [
|
||||
{ "name": "flc_result_json", "value": "[...]", "type": "STRING" },
|
||||
{ "name": "has_errors", "value": "true", "type": "BOOLEAN" },
|
||||
{ "name": "total_rows", "value": "13", "type": "LONG" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Проверка завершения процесса (фронт → наш API)
|
||||
|
||||
ZorroBPM не возвращает переменные напрямую через публичный эндпоинт.
|
||||
Workaround: воркер сохраняет отчёт в `tmp/reports/{report_id}.json`,
|
||||
наш FastAPI отдаёт его по GET-запросу фронта.
|
||||
|
||||
Статус инстанса проверяем так:
|
||||
```http
|
||||
GET /service-tasks?processInstanceId={uuid}&completed=false
|
||||
```
|
||||
Если список пуст → процесс завершён.
|
||||
|
||||
---
|
||||
|
||||
## 4. Стек
|
||||
|
||||
Python + FastAPI — тот же стек что и у существующего бота.
|
||||
|
||||
| Слой | Технология |
|
||||
|---|---|
|
||||
| Backend | Python 3.10 + FastAPI + uvicorn |
|
||||
| Воркер | asyncio background task (HTTP polling ZorroBPM) |
|
||||
| Excel | openpyxl + pandas |
|
||||
| ZorroBPM client | httpx (async) |
|
||||
| Frontend | HTML + Tailwind CDN (один файл, без сборки) |
|
||||
| Хранилище | Файловая система `./tmp/` |
|
||||
| BPM | ZorroBPM (docker-compose) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Структура папок
|
||||
|
||||
```
|
||||
bin-validator-zorro/
|
||||
│
|
||||
├── docker-compose.yaml # ZorroBPM стек + наш app
|
||||
├── .env
|
||||
│
|
||||
├── bpmn/
|
||||
│ └── bin_validation.bpmn # BPMN (раздел 2.2)
|
||||
│
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI: /upload /status /report
|
||||
│ ├── deploy.py # Деплой BPMN при старте
|
||||
│ ├── worker.py # asyncio polling + обработка задач
|
||||
│ │
|
||||
│ ├── core/
|
||||
│ │ ├── validator.py # validate_bin() — ФЛК алгоритм
|
||||
│ │ └── models.py # ValidationResult dataclass
|
||||
│ │
|
||||
│ ├── parsers/
|
||||
│ │ └── excel_parser.py # openpyxl, авто-детект столбца БИН
|
||||
│ │
|
||||
│ ├── online/
|
||||
│ │ └── kgd_client.py # Playwright → КГД (из существующего бота)
|
||||
│ │
|
||||
│ └── tmp/ # Создаётся автоматически
|
||||
│ ├── uploads/
|
||||
│ └── reports/
|
||||
│
|
||||
└── frontend/
|
||||
└── index.html # Загрузка файла + polling + таблица результатов
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Наши FastAPI эндпоинты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | Отдать `frontend/index.html` |
|
||||
| `POST` | `/upload` | Принять xlsx → сохранить → запустить ZorroBPM инстанс |
|
||||
| `GET` | `/status/{instance_id}` | Проверить завершённость (polling фронтом) |
|
||||
| `GET` | `/report/{report_id}` | Вернуть JSON-отчёт |
|
||||
|
||||
**`POST /upload` — ответ:**
|
||||
```json
|
||||
{ "instance_id": "uuid", "file_id": "uuid", "file_name": "Книга1.xlsx" }
|
||||
```
|
||||
|
||||
**`GET /status/{instance_id}` — ответ:**
|
||||
```json
|
||||
{ "status": "running", "report_id": null }
|
||||
{ "status": "done", "report_id": "uuid" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Логика воркера
|
||||
|
||||
```python
|
||||
# worker.py — запускается как asyncio task при старте FastAPI
|
||||
|
||||
async def poll_loop():
|
||||
while True:
|
||||
tasks = await zorro.get(
|
||||
"/service-tasks", params={"completed": "false"}
|
||||
)
|
||||
for task in tasks["data"]:
|
||||
if task["job"] == "srv_flc_validate":
|
||||
await handle_flc(task)
|
||||
elif task["job"] == "srv_kgd_check":
|
||||
await handle_kgd(task)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def handle_flc(task):
|
||||
file_id = memory[task["processInstanceId"]]["file_id"]
|
||||
rows = excel_parser.read(f"tmp/uploads/{file_id}.xlsx")
|
||||
results = [validate_bin(r) for r in rows]
|
||||
has_errors = any(not r.is_valid for r in results)
|
||||
await zorro.post(f"/service-tasks/{task['id']}/complete", json={
|
||||
"variables": [
|
||||
{"name": "flc_result_json", "value": json.dumps(results), "type": "STRING"},
|
||||
{"name": "has_errors", "value": str(has_errors).lower(), "type": "BOOLEAN"},
|
||||
{"name": "total_rows", "value": len(results), "type": "LONG"},
|
||||
]
|
||||
})
|
||||
|
||||
async def handle_kgd(task):
|
||||
# Читаем flc_result_json из памяти (воркер сохранил на предыдущем шаге)
|
||||
flc = memory[task["processInstanceId"]]["flc_result"]
|
||||
invalid_bins = [r["bin"] for r in flc if not r["is_valid"]]
|
||||
kgd_results = [await kgd_client.check(b) for b in invalid_bins]
|
||||
report_id = str(uuid4())
|
||||
# Сохраняем итоговый отчёт
|
||||
report = {"flc": flc, "kgd": kgd_results}
|
||||
Path(f"tmp/reports/{report_id}.json").write_text(json.dumps(report))
|
||||
memory[task["processInstanceId"]]["report_id"] = report_id
|
||||
await zorro.post(f"/service-tasks/{task['id']}/complete", json={
|
||||
"variables": [
|
||||
{"name": "report_id", "value": report_id, "type": "STRING"},
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**Примечание:** `memory` — обычный `dict` в памяти процесса. Достаточно для MVP.
|
||||
|
||||
---
|
||||
|
||||
## 8. docker-compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
||||
api:
|
||||
image: zorrodev/zorrobpm-rest
|
||||
environment:
|
||||
- DB_URL=jdbc:postgresql://db/zorrobpm
|
||||
- DB_USERNAME=zorrobpm
|
||||
- DB_PASSWORD=zorrobpm
|
||||
- RABBITMQ_HOST=rabbitmq
|
||||
- RABBITMQ_USERNAME=guest
|
||||
- RABBITMQ_PASSWORD=guest
|
||||
ports:
|
||||
- "9092:80"
|
||||
|
||||
db:
|
||||
image: postgres:18
|
||||
environment:
|
||||
- POSTGRES_DB=zorrobpm
|
||||
- POSTGRES_USER=zorrobpm
|
||||
- POSTGRES_PASSWORD=zorrobpm
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.0.7-management
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_USER=guest
|
||||
- RABBITMQ_DEFAULT_PASS=guest
|
||||
|
||||
app:
|
||||
build: ./app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./app/tmp:/app/tmp
|
||||
- ./bpmn:/app/bpmn
|
||||
environment:
|
||||
- ZORRO_API=http://api
|
||||
depends_on:
|
||||
- api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Порядок сборки MVP (~1 час)
|
||||
|
||||
| # | Шаг | Время | Файл |
|
||||
|---|---|---|---|
|
||||
| 1 | ФЛК алгоритм + тесты | 10 мин | `core/validator.py` |
|
||||
| 2 | BPMN + deploy.py | 10 мин | `bpmn/bin_validation.bpmn`, `deploy.py` |
|
||||
| 3 | FastAPI: upload + status + report | 15 мин | `main.py` |
|
||||
| 4 | Воркер: poll + handle_flc + handle_kgd | 15 мин | `worker.py` |
|
||||
| 5 | Frontend: форма + polling + таблица | 10 мин | `frontend/index.html` |
|
||||
| 6 | docker-compose up + тест Книга1_тест.xlsx | 5 мин | — |
|
||||
|
||||
---
|
||||
|
||||
## 10. Что НЕ делаем в MVP
|
||||
|
||||
- Авторизация
|
||||
- База данных
|
||||
- Тарифы и лимиты
|
||||
- ExclusiveGateway в BPMN (линейный процесс проще)
|
||||
- История запусков
|
||||
- Telegram-уведомления (добавить в следующей итерации)
|
||||
|
||||
---
|
||||
|
||||
*TRD сгенерирован из PRD + ZORRO.md · май 2026*
|
||||
|
||||
598
ZORRO.md
598
ZORRO.md
@ -1,598 +0,0 @@
|
||||
# ZorroBPM Development Guide
|
||||
|
||||
Единый справочник для разработки процессов на ZorroBPM.
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Архитектура](#архитектура)
|
||||
- [Окружения](#окружения)
|
||||
- [Локальный запуск](#локальный-запуск)
|
||||
- [Docker Compose — полный стек](#docker-compose--полный-стек)
|
||||
- [Docker Compose — только фронт (dev)](#docker-compose--только-фронт-dev)
|
||||
- [Docker Compose — фронт + воркеры](#docker-compose--фронт--воркеры)
|
||||
- [API Reference](#api-reference)
|
||||
- [RabbitMQ и внешние воркеры](#rabbitmq-и-внешние-воркеры)
|
||||
- [Разработка процессов](#разработка-процессов)
|
||||
- [Тестирование](#тестирование)
|
||||
- [Примеры](#примеры)
|
||||
- [Известные ограничения](#известные-ограничения)
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ UI │────▶│ REST API │────▶│ PostgreSQL │
|
||||
│ (nginx) │ │ (Spring Boot)│ │ │
|
||||
└──────────┘ └──────┬───────┘ └────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌────────────────┐
|
||||
│ RabbitMQ │◀───▶│ External │
|
||||
│ │ │ Workers (опц.) │
|
||||
└──────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
| Компонент | Образ | Описание |
|
||||
|-----------|-------|----------|
|
||||
| **api** | `zorrodev/zorrobpm-rest` | BPM движок. Управляет определениями, инстансами, задачами, инцидентами |
|
||||
| **ui** | `zorrodev/zorrobpm-ui` | Nginx SPA — мониторинг процессов, задач, деплоев |
|
||||
| **db** | `postgres:18` | Хранит определения, инстансы, переменные, инциденты |
|
||||
| **rabbitmq** | `rabbitmq:4.0.7-management` | Очереди для serviceTask и событий жизненного цикла |
|
||||
|
||||
---
|
||||
|
||||
## Окружения
|
||||
|
||||
| Окружение | URL | Примечание |
|
||||
|-----------|-----|------------|
|
||||
| **LOCAL** | http://localhost:9092 | Docker Compose |
|
||||
| **PROD** | https://api.bpms.telecom.kz | Через VPN |
|
||||
| **DEV Studio** | https://studio.zorrobpm.dev | Визуальный редактор |
|
||||
| **DEV Station** | https://station.zorrobpm.dev | Мониторинг |
|
||||
| **DEMO (m11s)** | https://api.m11s.zorrobpm.dev | Микросервисная демо-среда |
|
||||
|
||||
Swagger UI:
|
||||
- Local: http://localhost:9092/swagger-ui/index.html
|
||||
- Demo: https://api.m11s.zorrobpm.dev/swagger-ui/index.html
|
||||
|
||||
Аутентификация: не требуется (dev-окружение).
|
||||
|
||||
---
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
# Запуск полного стека
|
||||
docker compose -f docker-compose.yaml --env-file .env up -d
|
||||
|
||||
# Логи API
|
||||
docker compose logs api -f
|
||||
|
||||
# Остановка
|
||||
docker compose down
|
||||
|
||||
# Остановка + удаление данных
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose — полный стек
|
||||
|
||||
Полный стек для самостоятельного запуска движка. Используй только если нужен свой локальный инстанс BPM.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yaml — полный стек ZorroBPM
|
||||
services:
|
||||
|
||||
api:
|
||||
image: zorrodev/zorrobpm-rest
|
||||
environment:
|
||||
- DB_URL=jdbc:postgresql://db/${DB_NAME}
|
||||
- DB_USERNAME=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- APP_FILES_DIR=/files
|
||||
- RABBITMQ_HOST=rabbitmq
|
||||
- RABBITMQ_USERNAME=${RABBITMQ_USER}
|
||||
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
|
||||
- RABBITMQ_PORT=5672
|
||||
volumes:
|
||||
- files:/files
|
||||
ports:
|
||||
- 9092:80
|
||||
|
||||
db:
|
||||
image: postgres:18
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- db:/var/lib/postgresql/18/docker
|
||||
ports:
|
||||
- "0.0.0.0:5435:5432"
|
||||
|
||||
ui:
|
||||
image: zorrodev/zorrobpm-ui
|
||||
ports:
|
||||
- 9091:80
|
||||
environment:
|
||||
- ZORROBPM_API_URL=http://zorrobpm.local
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.0.7-management
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
volumes:
|
||||
- rabbitmq-data:/var/lib/rabbitmq
|
||||
|
||||
volumes:
|
||||
rabbitmq-data:
|
||||
db:
|
||||
files:
|
||||
```
|
||||
|
||||
`.env`:
|
||||
```env
|
||||
DB_NAME=zorrobpm
|
||||
DB_USER=zorrobpm
|
||||
DB_PASSWORD=zorrobpm
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose — только фронт (dev)
|
||||
|
||||
**Для разработки процессов без локального движка.** Подключается к удалённому API (demo, dev или prod). Не нужны PostgreSQL, RabbitMQ, API — только UI.
|
||||
|
||||
```yaml
|
||||
# docker-compose.front.yaml — фронт без движка
|
||||
services:
|
||||
|
||||
ui:
|
||||
image: zorrodev/zorrobpm-ui
|
||||
ports:
|
||||
- 9091:80
|
||||
environment:
|
||||
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||
```
|
||||
|
||||
`.env.front`:
|
||||
```env
|
||||
# Указать один из:
|
||||
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev # demo
|
||||
# ZORROBPM_API_URL=https://api.bpms.telecom.kz # prod (VPN)
|
||||
# ZORROBPM_API_URL=http://localhost:9092 # local
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.front.yaml --env-file .env.front up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose — фронт + воркеры
|
||||
|
||||
**Когда процесс содержит serviceTask с внешними воркерами.** Фронт для мониторинга + RabbitMQ для получения задач + твой воркер.
|
||||
|
||||
```yaml
|
||||
# docker-compose.workers.yaml — фронт + RabbitMQ воркеры
|
||||
services:
|
||||
|
||||
ui:
|
||||
image: zorrodev/zorrobpm-ui
|
||||
ports:
|
||||
- 9091:80
|
||||
environment:
|
||||
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||
|
||||
# Пример Java-воркера
|
||||
worker:
|
||||
build: ./worker
|
||||
environment:
|
||||
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
|
||||
- RABBITMQ_HOST=${RABBITMQ_HOST}
|
||||
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
|
||||
- RABBITMQ_USERNAME=${RABBITMQ_USER}
|
||||
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
|
||||
depends_on:
|
||||
- ui
|
||||
```
|
||||
|
||||
`.env.workers`:
|
||||
```env
|
||||
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev
|
||||
|
||||
# RabbitMQ — указать хост удалённого брокера, если он открыт,
|
||||
# или поднять локально (добавить rabbitmq сервис)
|
||||
RABBITMQ_HOST=rabbitmq.m11s.zorrobpm.dev
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
```
|
||||
|
||||
> **Примечание:** если удалённый RabbitMQ недоступен извне, добавь сервис `rabbitmq` из полного стека и настрой API на использование этого брокера.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
Base URL: `http://localhost:9092` (local) или `https://api.m11s.zorrobpm.dev` (demo)
|
||||
|
||||
### Process Definitions
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `POST` | `/process-definitions` | Деплой BPMN |
|
||||
| `GET` | `/process-definitions` | Список (с пагинацией) |
|
||||
| `GET` | `/process-definitions/{id}` | По ID |
|
||||
| `GET` | `/process-definitions/{id}/xml` | BPMN XML |
|
||||
|
||||
**Деплой:**
|
||||
```http
|
||||
POST /process-definitions
|
||||
Content-Type: application/json
|
||||
|
||||
{ "bpmn": "<bpmn:definitions>...</bpmn:definitions>" }
|
||||
```
|
||||
|
||||
Ответ — `ProcessDefinition`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"key": "string",
|
||||
"version": 1,
|
||||
"name": "string",
|
||||
"sha256": "string",
|
||||
"createdAt": "date-time",
|
||||
"startFormKey": "string"
|
||||
}
|
||||
```
|
||||
|
||||
Query params: `pageIndex`, `pageSize`, `processDefinitionKey`, `processDefinitionVersion`, `latestVersionOnly`
|
||||
|
||||
---
|
||||
|
||||
### Process Instances
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `POST` | `/process-instances` | Запуск инстанса |
|
||||
| `GET` | `/process-instances` | Список (с пагинацией) |
|
||||
|
||||
**Запуск:**
|
||||
```http
|
||||
POST /process-instances
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"processDefinitionId": "uuid",
|
||||
"processDefinitionKey": "string",
|
||||
"processDefinitionVersion": 1,
|
||||
"variables": [
|
||||
{ "name": "var1", "value": "val1", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Можно указать `processDefinitionId` ИЛИ `processDefinitionKey` + `processDefinitionVersion`.
|
||||
|
||||
Ответ: `{ "id": "uuid" }`
|
||||
|
||||
Query params: `id`, `pageIndex`, `pageSize`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`
|
||||
|
||||
Объект `ProcessInstance`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"parentActivityId": "uuid",
|
||||
"processDefinitionId": "uuid",
|
||||
"startedAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Service Tasks
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/service-tasks` | Список сервисных задач |
|
||||
| `POST` | `/service-tasks/{id}/complete` | Завершить задачу |
|
||||
|
||||
Query params: `id`, `jobType`, `completed`, `pageIndex`, `pageSize`, `processInstanceId`
|
||||
|
||||
Объект `ServiceTask`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"code": "string",
|
||||
"name": "string",
|
||||
"processInstanceId": "uuid",
|
||||
"processDefinitionId": "uuid",
|
||||
"job": "string",
|
||||
"createdAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Завершение:**
|
||||
```http
|
||||
POST /service-tasks/{id}/complete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"variables": [
|
||||
{ "name": "result", "value": "done", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### User Tasks
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/user-tasks` | Список пользовательских задач |
|
||||
| `POST` | `/user-tasks/{id}/complete` | Завершить задачу |
|
||||
|
||||
Query params: `id`, `assignee`, `candidateGroup`, `candidateUser`, `completed`, `assigned`, `pageIndex`, `pageSize`, `processInstanceId`
|
||||
|
||||
Объект `UserTask`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"code": "string",
|
||||
"name": "string",
|
||||
"processInstanceId": "uuid",
|
||||
"processDefinitionId": "uuid",
|
||||
"formKey": "string",
|
||||
"createdAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Завершение:**
|
||||
```http
|
||||
POST /user-tasks/{id}/complete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"variables": [
|
||||
{ "name": "action", "value": "approve", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Incidents
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/incidents` | Список инцидентов |
|
||||
| `POST` | `/incidents/{id}/resolve` | Разрешить инцидент |
|
||||
|
||||
Query params: `id`, `processInstanceId`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`, `bpmnElementId`, `pageIndex`, `pageSize`
|
||||
|
||||
Объект `Incident`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"activityId": "uuid",
|
||||
"message": "string",
|
||||
"createdAt": "date-time",
|
||||
"completedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Разрешение:**
|
||||
```http
|
||||
POST /incidents/{id}/resolve
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "uuid",
|
||||
"variables": [
|
||||
{ "name": "fix", "value": "retried", "type": "STRING" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Типы переменных
|
||||
|
||||
`STRING`, `UUID`, `LONG`, `BOOLEAN`
|
||||
|
||||
### Формат пагинации
|
||||
|
||||
Все list-эндпоинты возвращают:
|
||||
```json
|
||||
{
|
||||
"pageIndex": 0,
|
||||
"pageSize": 20,
|
||||
"totalElements": 100,
|
||||
"data": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RabbitMQ и внешние воркеры
|
||||
|
||||
### Очереди
|
||||
|
||||
| Очередь | Описание |
|
||||
|---------|----------|
|
||||
| `zorrobpm.engine.definition-events` | Жизненный цикл определений (deployed, updated) |
|
||||
| `zorrobpm.engine.runtime-events` | Жизненный цикл инстансов (created, completed) и задач |
|
||||
| `zorrobpm.jobs.<job_type>` | Очередь для каждого типа serviceTask |
|
||||
|
||||
### Как это работает
|
||||
|
||||
Когда BPMN `serviceTask` с атрибутом `job="srv_child_work"` достигается движком:
|
||||
1. Движок публикует сообщение в очередь `zorrobpm.jobs.srv_child_work`
|
||||
2. Внешний воркер читает из этой очереди
|
||||
3. Воркер выполняет работу
|
||||
4. Воркер вызывает `POST /service-tasks/{id}/complete`
|
||||
|
||||
### Пример воркера (Java + Spring AMQP)
|
||||
|
||||
```java
|
||||
@RabbitListener(queues = "zorrobpm.jobs.srv_child_work")
|
||||
public void handleChildWork(ServiceTaskMessage msg) {
|
||||
// выполнить работу...
|
||||
api.completeServiceTask(msg.getTaskId());
|
||||
}
|
||||
```
|
||||
|
||||
### Инспекция очередей
|
||||
|
||||
```bash
|
||||
docker exec zorrobpm-rabbitmq-1 rabbitmqctl list_queues name messages consumers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Разработка процессов
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
1. **Нарисуй BPMN** — в Studio (https://studio.zorrobpm.dev) или любом BPMN-редакторе
|
||||
2. **Деплой** — `POST /process-definitions` с BPMN XML
|
||||
3. **Запусти** — `POST /process-instances` с ключом или ID определения
|
||||
4. **Обрабатывай задачи**:
|
||||
- `serviceTask` — воркер через RabbitMQ или ручной `POST /service-tasks/{id}/complete`
|
||||
- `userTask` — UI или ручной `POST /user-tasks/{id}/complete`
|
||||
|
||||
### Выбор docker-compose
|
||||
|
||||
```
|
||||
Нужен ли локальный BPM движок?
|
||||
├── Да → docker-compose.yaml (полный стек)
|
||||
└── Нет, использую удалённый API
|
||||
├── Есть serviceTask с воркерами? → docker-compose.workers.yaml
|
||||
└── Только UI/мониторинг? → docker-compose.front.yaml
|
||||
```
|
||||
|
||||
### Структура проекта (рекомендуемая)
|
||||
|
||||
```
|
||||
my-process/
|
||||
├── docker-compose.yaml # выбери нужный вариант
|
||||
├── .env
|
||||
├── bpmn/
|
||||
│ ├── myProcess.bpmn
|
||||
│ └── childProcess.bpmn
|
||||
├── worker/ # если есть serviceTask
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
└── tests/
|
||||
└── e2e/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Dev-контейнер
|
||||
|
||||
```yaml
|
||||
# docker-compose.dev.yaml
|
||||
services:
|
||||
app:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: examples-dev
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- maven-cache:/root/.m2
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "5005:5005" # remote debug
|
||||
command: sleep infinity
|
||||
|
||||
volumes:
|
||||
maven-cache:
|
||||
```
|
||||
|
||||
### Режимы тестирования
|
||||
|
||||
| Режим | Команда | Зависимости | Описание |
|
||||
|-------|---------|-------------|----------|
|
||||
| **embedded** | `./test.sh` | Только dev контейнер | Unit тесты, встроенный движок + H2 |
|
||||
| **local** | `./test.sh local` | Dev + ZorroBPM docker | E2E против `localhost:9092` |
|
||||
| **remote** | `./test.sh remote` | Dev + интернет | E2E против `api.m11s.zorrobpm.dev` |
|
||||
| **custom** | `./test.sh http://my-server:8080` | Dev + сервер | E2E против произвольного URL |
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
./test.sh # embedded
|
||||
./test.sh local # E2E local
|
||||
./test.sh remote # E2E remote
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Примеры
|
||||
|
||||
### call-activity — вызов дочернего процесса
|
||||
|
||||
**Родительский процесс** (`parentProcess.bpmn`):
|
||||
```
|
||||
[Start] → [CallActivity: childProcess] → [End]
|
||||
```
|
||||
|
||||
**Дочерний процесс** (`childProcess.bpmn`):
|
||||
```
|
||||
[Start] → [ServiceTask: srv_child_work] → [End]
|
||||
```
|
||||
|
||||
**Поток выполнения:**
|
||||
1. Деплой обоих BPMN
|
||||
2. Запуск родительского — он входит в `callActivity`, создаёт дочерний инстанс
|
||||
3. Дочерний работает в **отдельном processInstance**
|
||||
4. Завершение serviceTask в дочернем → callActivity возобновляется → родитель завершается
|
||||
|
||||
**Файлы:**
|
||||
```
|
||||
src/main/resources/examples/call-activity/
|
||||
parentProcess.bpmn
|
||||
childProcess.bpmn
|
||||
|
||||
src/test/java/.../examples/callactivity/
|
||||
CallActivityTest.java # unit (embedded)
|
||||
|
||||
src/test/java/.../e2e/
|
||||
CallActivityE2ETest.java # E2E
|
||||
ZorroWebAPI.java # HTTP клиент
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
### Нет parentProcessInstanceId
|
||||
|
||||
API не поддерживает фильтр `parentProcessInstanceId` в `ProcessInstance`. Поле `parentActivityId` — это UUID активити (callActivity элемента), а не ID родительского инстанса.
|
||||
|
||||
**Нельзя напрямую запросить:** "дай все дочерние процессы родителя X".
|
||||
|
||||
**Workaround:** найти последний инстанс с `parentActivityId != null` на последней странице. Работает для последовательных тестов, ненадёжно при параллельных.
|
||||
|
||||
**Желаемое улучшение API:**
|
||||
```
|
||||
GET /process-instances?parentProcessInstanceId={uuid}
|
||||
```
|
||||
|
||||
Плюс добавить поле `parentProcessInstanceId` в объект `ProcessInstance`.
|
||||
886
index.html
Normal file
886
index.html
Normal file
@ -0,0 +1,886 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Валидатор БИН и ИИН — MVP</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f6fa;
|
||||
--card: #ffffff;
|
||||
--text: #1a2233;
|
||||
--muted: #6b7689;
|
||||
--border: #e1e6ef;
|
||||
--primary: #2b6cb0;
|
||||
--primary-dark: #1e4e80;
|
||||
--ok: #15803d;
|
||||
--ok-bg: #dcfce7;
|
||||
--warn: #b45309;
|
||||
--warn-bg: #fef3c7;
|
||||
--err: #b91c1c;
|
||||
--err-bg: #fee2e2;
|
||||
--info: #1e40af;
|
||||
--info-bg: #dbeafe;
|
||||
--purple: #6d28d9;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
background: linear-gradient(135deg, #2b6cb0, #1e4e80);
|
||||
color: white;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
header h1 { margin: 0 0 4px 0; font-size: 22px; }
|
||||
header p { margin: 0; opacity: 0.85; font-size: 14px; }
|
||||
main {
|
||||
max-width: 1280px;
|
||||
margin: 24px auto;
|
||||
padding: 0 24px 60px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 14px 0;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
button, .btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--primary-dark); }
|
||||
button.secondary {
|
||||
background: white;
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
button.secondary:hover { background: #eef4fb; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
input[type="text"] {
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 240px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
input[type="file"] { display: none; }
|
||||
.file-label { display: inline-block; }
|
||||
.hint { color: var(--muted); font-size: 13px; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat {
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat .num { font-size: 28px; font-weight: 600; line-height: 1.1; }
|
||||
.stat .lbl { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||
.stat .sub { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
.stat.ok .num { color: var(--ok); }
|
||||
.stat.err .num { color: var(--err); }
|
||||
.stat.warn .num { color: var(--warn); }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
}
|
||||
th, td {
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
tr.row-ok { background: var(--ok-bg); }
|
||||
tr.row-err { background: var(--err-bg); }
|
||||
tr.row-warn { background: var(--warn-bg); }
|
||||
tr:hover { filter: brightness(0.97); }
|
||||
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.ok { background: var(--ok); color: white; }
|
||||
.badge.err { background: var(--err); color: white; }
|
||||
.badge.warn { background: var(--warn); color: white; }
|
||||
.badge.info { background: var(--info); color: white; }
|
||||
.badge.muted { background: var(--muted); color: white; }
|
||||
.badge.bin { background: #0e7490; color: white; } /* teal — БИН */
|
||||
.badge.iin { background: var(--purple); color: white; } /* фиолетовый — ИИН */
|
||||
.err-msg { color: var(--err); font-size: 12px; }
|
||||
.meta { color: var(--muted); font-size: 12px; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-state .icon { font-size: 36px; margin-bottom: 8px; opacity: 0.4; }
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 65vh;
|
||||
}
|
||||
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); flex-wrap: wrap; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
details { margin-top: 10px; }
|
||||
summary { cursor: pointer; color: var(--primary); font-size: 13px; }
|
||||
details pre {
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#columnInfo {
|
||||
background: #eef4fb;
|
||||
border: 1px solid #bcd6ee;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Валидатор БИН и ИИН в налоговых регистрах</h1>
|
||||
<p>Автоматическое определение типа по 5-му знаку · ФЛК-проверка локально · сверка с реестром КГД (demo-данные)</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Загрузить данные</h2>
|
||||
<div class="row" style="margin-bottom: 12px;">
|
||||
<label class="file-label">
|
||||
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" />
|
||||
<span class="btn">Загрузить Excel / CSV</span>
|
||||
</label>
|
||||
<button class="secondary" id="sampleBtn">Демо-данные</button>
|
||||
<button class="secondary" id="clearBtn">Очистить</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="text" id="manualBin" placeholder="Введите БИН или ИИН (12 цифр)" maxlength="20" />
|
||||
<button class="secondary" id="addBinBtn">Добавить</button>
|
||||
<span class="hint">Тип (БИН / ИИН) определяется автоматически по 5-му знаку. Столбец в Excel ищется по заголовку или по содержимому.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultsSection" style="display:none;">
|
||||
|
||||
<div id="columnInfo" style="display:none;">
|
||||
<span id="columnInfoText"></span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="num" id="statTotal">0</div>
|
||||
<div class="lbl">Всего строк</div>
|
||||
</div>
|
||||
<div class="stat ok">
|
||||
<div class="num" id="statValid">0</div>
|
||||
<div class="lbl">Прошли ФЛК</div>
|
||||
<div class="sub" id="statValidSub">—</div>
|
||||
</div>
|
||||
<div class="stat err">
|
||||
<div class="num" id="statErr">0</div>
|
||||
<div class="lbl">Ошибки ФЛК</div>
|
||||
</div>
|
||||
<div class="stat warn">
|
||||
<div class="num" id="statKgd">0</div>
|
||||
<div class="lbl">КГД: внимание</div>
|
||||
<div class="sub">только для БИН</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 style="margin:0;">2. Результаты проверки</h2>
|
||||
<div class="legend" style="margin-top:6px;">
|
||||
<span><i style="background:var(--ok-bg); border:1px solid var(--ok);"></i> Валиден</span>
|
||||
<span><i style="background:var(--err-bg); border:1px solid var(--err);"></i> Ошибка ФЛК</span>
|
||||
<span><i style="background:var(--warn-bg);border:1px solid var(--warn);"></i> КГД: внимание</span>
|
||||
<span><span class="badge bin">БИН</span> юр.лицо/филиал/ИП</span>
|
||||
<span><span class="badge iin">ИИН</span> физлицо</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="secondary" id="exportBtn">Экспорт в CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="resultsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Исходное значение</th>
|
||||
<th>Нормализ.</th>
|
||||
<th>Тип</th>
|
||||
<th>ФЛК</th>
|
||||
<th>Подтип</th>
|
||||
<th>Дата</th>
|
||||
<th>КГД: статус</th>
|
||||
<th>КГД: наименование</th>
|
||||
<th>Комментарий</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="card empty-state">
|
||||
<div class="icon">📊</div>
|
||||
<div>Загрузите Excel-файл, добавьте номер вручную или нажмите <b>«Демо-данные»</b>.</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<details>
|
||||
<summary>Как работает проверка БИН и ИИН</summary>
|
||||
<pre>Оба номера — 12-значные. Тип определяется по <b>5-му знаку</b>:
|
||||
|
||||
5-й знак = 0, 1, 2, 3 → ИИН (физлицо)
|
||||
5-й знак = 4, 5, 6 → БИН (юр.лицо / филиал / ИП)
|
||||
5-й знак = 7, 8, 9 → невалидный тип
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
БИН (юр.лицо) — структура: YY MM T SSSSSS C
|
||||
|
||||
знаки 1–2 : год регистрации (YY)
|
||||
знаки 3–4 : месяц регистрации (MM, 01–12)
|
||||
знак 5 : тип субъекта
|
||||
4 = ЮЛ-резидент
|
||||
5 = филиал / представительство / ЮЛ-нерезидент
|
||||
6 = ИП без образования юр.лица
|
||||
знаки 6–11: серийный номер (SSSSSS)
|
||||
знак 12 : контрольная цифра
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
ИИН (физлицо) — структура: YY MM DD G NNN N C
|
||||
|
||||
знаки 1–2 : год рождения (YY)
|
||||
знаки 3–4 : месяц рождения (MM, 01–12)
|
||||
знаки 5–6 : день рождения (DD, 01–31, с учётом високосных лет)
|
||||
знак 7 : пол + век:
|
||||
1 = М, 1900–1999
|
||||
2 = Ж, 1900–1999
|
||||
3 = М, 2000–2099
|
||||
4 = Ж, 2000–2099
|
||||
5 = М, 2100–2199
|
||||
6 = Ж, 2100–2199
|
||||
знаки 8–10: порядковый номер в дне рождения
|
||||
знак 11 : код национальности (информационный)
|
||||
знак 12 : контрольная цифра
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
Контрольная цифра — алгоритм Министерства юстиции РК (одинаков для БИН и ИИН):
|
||||
|
||||
s1 = Σ d[i] × w1[i], где w1 = [1,2,3,4,5,6,7,8,9,10,11]
|
||||
если s1 mod 11 != 10 → контрольная цифра = s1 mod 11
|
||||
иначе:
|
||||
s2 = Σ d[i] × w2[i], где w2 = [3,4,5,6,7,8,9,10,11,1,2]
|
||||
если s2 mod 11 != 10 → контрольная цифра = s2 mod 11
|
||||
иначе → номер невалиден
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
КГД-сверка (реестр налогоплательщиков): в этом MVP используется встроенный
|
||||
demo-словарь из нескольких компаний. В продакшене подменяется на вызов
|
||||
api.kgd.gov.kz или Playwright-сценарий.</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
// =============================================================
|
||||
// Mock КГД-реестр — только БИН юр.лиц (ИИН физлиц не проверяются)
|
||||
// =============================================================
|
||||
const KGD_MOCK = {
|
||||
"941240000193": { name: "АО «Казахтелеком (demo)»", status: "Действующий" },
|
||||
"020340000013": { name: "ТОО «Демо-Холдинг»", status: "Действующий" },
|
||||
"980540000199": { name: "АО «КазТрансОйл (demo)»", status: "Действующий" },
|
||||
"991240001200": { name: "АО «Эйр Астана (demo)»", status: "Действующий" },
|
||||
"020340000452": { name: "АО «Народный Банк (demo)»", status: "Действующий" },
|
||||
"060460000013": { name: "ИП Иванов И.И. (demo)", status: "Приостановлен" },
|
||||
"920150000029": { name: "Филиал «Демо-Банк»", status: "Ликвидирован" },
|
||||
};
|
||||
|
||||
// =============================================================
|
||||
// Общие утилиты
|
||||
// =============================================================
|
||||
function normalizeDigits(raw) {
|
||||
if (raw == null) return "";
|
||||
return String(raw).replace(/\D+/g, "");
|
||||
}
|
||||
|
||||
function pad2(n) { return String(n).padStart(2, "0"); }
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
// month: 1..12
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
// Алгоритм контрольной цифры (Мин-юст РК), одинаков для БИН и ИИН
|
||||
function computeControlDigit(d11) {
|
||||
const w1 = [1,2,3,4,5,6,7,8,9,10,11];
|
||||
let s = 0;
|
||||
for (let i = 0; i < 11; i++) s += d11[i] * w1[i];
|
||||
let c = s % 11;
|
||||
if (c === 10) {
|
||||
const w2 = [3,4,5,6,7,8,9,10,11,1,2];
|
||||
s = 0;
|
||||
for (let i = 0; i < 11; i++) s += d11[i] * w2[i];
|
||||
c = s % 11;
|
||||
if (c === 10) return -1; // невалиден
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Авто-определение типа по 5-му знаку (digits[4])
|
||||
// =============================================================
|
||||
function detectIdKind(digits) {
|
||||
const d5 = digits[4];
|
||||
if (d5 >= 0 && d5 <= 3) return "ИИН";
|
||||
if (d5 >= 4 && d5 <= 6) return "БИН";
|
||||
return null; // 7,8,9 — недопустимо
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Валидация БИН
|
||||
// =============================================================
|
||||
const BIN_SUBTYPE = { 4: "ЮЛ-резидент", 5: "Филиал / ЮЛ-нерезидент", 6: "ИП" };
|
||||
|
||||
function validateBin(digits) {
|
||||
const yy = digits[0] * 10 + digits[1];
|
||||
const mm = digits[2] * 10 + digits[3];
|
||||
const t = digits[4];
|
||||
|
||||
if (mm < 1 || mm > 12) {
|
||||
return {
|
||||
kind: "БИН", is_valid: false,
|
||||
error_code: "ERROR_BIN_MONTH",
|
||||
error_msg: `БИН: некорректный месяц регистрации (${mm})`,
|
||||
subtype: BIN_SUBTYPE[t] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// computeControlDigit на первых 11 знаках
|
||||
const expected = computeControlDigit(digits.slice(0, 11));
|
||||
if (expected === -1 || expected !== digits[11]) {
|
||||
return {
|
||||
kind: "БИН", is_valid: false,
|
||||
error_code: "ERROR_CHECK_DIGIT",
|
||||
error_msg: `БИН: неверная контрольная цифра (ожидалось ${expected === -1 ? "—" : expected}, в номере ${digits[11]})`,
|
||||
subtype: BIN_SUBTYPE[t] ?? null,
|
||||
year: 1900 + (yy < 30 ? 100 + yy : yy),
|
||||
month: mm,
|
||||
};
|
||||
}
|
||||
|
||||
const year = yy < 30 ? 2000 + yy : 1900 + yy;
|
||||
return {
|
||||
kind: "БИН", is_valid: true,
|
||||
subtype: BIN_SUBTYPE[t],
|
||||
year, month: mm,
|
||||
date_str: `${year}-${pad2(mm)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Валидация ИИН
|
||||
// =============================================================
|
||||
function validateIin(digits) {
|
||||
const yy = digits[0] * 10 + digits[1];
|
||||
const mm = digits[2] * 10 + digits[3];
|
||||
const dd = digits[4] * 10 + digits[5];
|
||||
const g = digits[6];
|
||||
|
||||
// Век и пол по 7-му знаку
|
||||
const GENDER_MAP = {
|
||||
1: { gender: "М", century: 1900 },
|
||||
2: { gender: "Ж", century: 1900 },
|
||||
3: { gender: "М", century: 2000 },
|
||||
4: { gender: "Ж", century: 2000 },
|
||||
5: { gender: "М", century: 2100 },
|
||||
6: { gender: "Ж", century: 2100 },
|
||||
};
|
||||
const gm = GENDER_MAP[g];
|
||||
if (!gm) {
|
||||
return {
|
||||
kind: "ИИН", is_valid: false,
|
||||
error_code: "ERROR_IIN_GENDER",
|
||||
error_msg: `ИИН: 7-й знак (${g}) не соответствует коду пола/века (ожидается 1–6)`,
|
||||
};
|
||||
}
|
||||
const year = gm.century + yy;
|
||||
|
||||
if (mm < 1 || mm > 12) {
|
||||
return {
|
||||
kind: "ИИН", is_valid: false,
|
||||
error_code: "ERROR_IIN_MONTH",
|
||||
error_msg: `ИИН: некорректный месяц рождения (${mm})`,
|
||||
subtype: gm.gender,
|
||||
};
|
||||
}
|
||||
|
||||
const maxDay = daysInMonth(year, mm);
|
||||
if (dd < 1 || dd > maxDay) {
|
||||
return {
|
||||
kind: "ИИН", is_valid: false,
|
||||
error_code: "ERROR_IIN_DAY",
|
||||
error_msg: `ИИН: некорректный день рождения (${dd}.${pad2(mm)}.${year}, в месяце ${maxDay} дн.)`,
|
||||
subtype: gm.gender,
|
||||
};
|
||||
}
|
||||
|
||||
const expected = computeControlDigit(digits.slice(0, 11));
|
||||
if (expected === -1 || expected !== digits[11]) {
|
||||
return {
|
||||
kind: "ИИН", is_valid: false,
|
||||
error_code: "ERROR_CHECK_DIGIT",
|
||||
error_msg: `ИИН: неверная контрольная цифра (ожидалось ${expected === -1 ? "—" : expected}, в номере ${digits[11]})`,
|
||||
subtype: gm.gender,
|
||||
birth_date: `${pad2(dd)}.${pad2(mm)}.${year}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ИИН", is_valid: true,
|
||||
subtype: gm.gender,
|
||||
year, month: mm, day: dd,
|
||||
birth_date: `${pad2(dd)}.${pad2(mm)}.${year}`,
|
||||
date_str: `${pad2(dd)}.${pad2(mm)}.${year}`,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Top-level: принимает raw value, возвращает результат
|
||||
// =============================================================
|
||||
function validateId(rawValue) {
|
||||
const original = String(rawValue ?? "").trim();
|
||||
const norm = normalizeDigits(original);
|
||||
|
||||
if (original === "") {
|
||||
return { original, normalized: "", kind: null, is_valid: false,
|
||||
error_code: "EMPTY", error_msg: "Пустое значение" };
|
||||
}
|
||||
if (norm.length !== 12) {
|
||||
return { original, normalized: norm, kind: null, is_valid: false,
|
||||
error_code: "ERROR_LENGTH",
|
||||
error_msg: `Должно быть 12 цифр (сейчас ${norm.length})` };
|
||||
}
|
||||
|
||||
const digits = norm.split("").map(Number);
|
||||
const kind = detectIdKind(digits);
|
||||
if (kind === null) {
|
||||
return { original, normalized: norm, kind: null, is_valid: false,
|
||||
error_code: "ERROR_TYPE_DIGIT",
|
||||
error_msg: `5-й знак (${digits[4]}) не соответствует ни БИН (4/5/6), ни ИИН (0/1/2/3)` };
|
||||
}
|
||||
|
||||
const sub = (kind === "БИН") ? validateBin(digits) : validateIin(digits);
|
||||
return { original, normalized: norm, ...sub };
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Mock КГД — только для валидных БИН
|
||||
// =============================================================
|
||||
function kgdLookup(result) {
|
||||
if (result.kind !== "БИН" || !result.is_valid) return null;
|
||||
const found = KGD_MOCK[result.normalized];
|
||||
if (found) return { ...found, in_registry: true };
|
||||
return { name: null, status: "Не найден", in_registry: false };
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Авто-детект столбца в Excel: по шапке или по содержимому
|
||||
// =============================================================
|
||||
const ID_HEADER_PATTERNS = [
|
||||
/^бин$/i, /^bin$/i, /^иин$/i, /^iin$/i,
|
||||
/^иин\s*\/?\s*бин$/i, /^бин\s*\/?\s*иин$/i,
|
||||
/бин/i, /bin/i, /иин/i
|
||||
];
|
||||
|
||||
function colLetter(idx) {
|
||||
let s = "", n = idx;
|
||||
do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
function looksLikeId12(cell) {
|
||||
if (cell == null) return false;
|
||||
const d = String(cell).replace(/\D+/g, "");
|
||||
return d.length >= 10 && d.length <= 14;
|
||||
}
|
||||
|
||||
function isHeaderRow(row) {
|
||||
if (!row) return false;
|
||||
const hasLetters = row.some(c => {
|
||||
if (c == null) return false;
|
||||
const s = String(c).trim();
|
||||
return s !== "" && /[A-Za-zА-Яа-я]/.test(s);
|
||||
});
|
||||
if (!hasLetters) return false;
|
||||
return !row.some(c => looksLikeId12(c));
|
||||
}
|
||||
|
||||
function scoreColumn(rows, colIdx, startRow) {
|
||||
let hits = 0, nonEmpty = 0;
|
||||
for (let i = startRow; i < rows.length; i++) {
|
||||
const v = rows[i][colIdx];
|
||||
if (v == null || String(v).trim() === "") continue;
|
||||
nonEmpty++;
|
||||
if (looksLikeId12(v)) hits++;
|
||||
}
|
||||
return { hits, nonEmpty };
|
||||
}
|
||||
|
||||
function detectIdColumn(rows) {
|
||||
if (!rows || rows.length === 0) return null;
|
||||
const width = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
const hasHeader = isHeaderRow(rows[0]);
|
||||
const startRow = hasHeader ? 1 : 0;
|
||||
|
||||
if (hasHeader) {
|
||||
for (const pat of ID_HEADER_PATTERNS) {
|
||||
for (let c = 0; c < width; c++) {
|
||||
const h = rows[0][c];
|
||||
if (h != null && pat.test(String(h).trim())) {
|
||||
const { hits } = scoreColumn(rows, c, startRow);
|
||||
return { colIdx: c, source: "header",
|
||||
headerName: String(h).trim(),
|
||||
hits, hasHeader };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best = { colIdx: -1, hits: -1, nonEmpty: 0 };
|
||||
for (let c = 0; c < width; c++) {
|
||||
const { hits, nonEmpty } = scoreColumn(rows, c, startRow);
|
||||
if (hits > best.hits || (hits === best.hits && nonEmpty > best.nonEmpty)) {
|
||||
best = { colIdx: c, hits, nonEmpty };
|
||||
}
|
||||
}
|
||||
if (best.hits <= 0) return null;
|
||||
const headerName = hasHeader && rows[0][best.colIdx] != null
|
||||
? String(rows[0][best.colIdx]).trim() : "";
|
||||
return { colIdx: best.colIdx, source: "scan", headerName,
|
||||
hits: best.hits, hasHeader };
|
||||
}
|
||||
|
||||
function parseSheet(rows) {
|
||||
if (!rows || rows.length === 0) return { items: [], info: null };
|
||||
const det = detectIdColumn(rows);
|
||||
if (!det) return { items: [], info: null };
|
||||
const startRow = det.hasHeader ? 1 : 0;
|
||||
const items = [];
|
||||
for (let i = startRow; i < rows.length; i++) {
|
||||
const value = rows[i][det.colIdx];
|
||||
if (value == null || String(value).trim() === "") continue;
|
||||
items.push({ rowNum: i + 1, value });
|
||||
}
|
||||
return { items, info: { ...det, letter: colLetter(det.colIdx) } };
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Состояние и рендер
|
||||
// =============================================================
|
||||
let currentResults = []; // [{ rowNum, res, kgd }]
|
||||
|
||||
function runChecks(items) {
|
||||
currentResults = items.map(({ rowNum, value }) => {
|
||||
const res = validateId(value);
|
||||
const kgd = kgdLookup(res);
|
||||
return { rowNum, res, kgd };
|
||||
});
|
||||
renderResults();
|
||||
}
|
||||
|
||||
function rowClass(res, kgd) {
|
||||
if (!res.is_valid) return "row-err";
|
||||
if (res.kind === "БИН" && kgd) {
|
||||
if (!kgd.in_registry) return "row-warn";
|
||||
if (kgd.status !== "Действующий") return "row-warn";
|
||||
}
|
||||
return "row-ok";
|
||||
}
|
||||
|
||||
function kindBadge(res) {
|
||||
if (res.kind === "БИН") return `<span class="badge bin">БИН</span>`;
|
||||
if (res.kind === "ИИН") return `<span class="badge iin">ИИН</span>`;
|
||||
return `<span class="badge muted">—</span>`;
|
||||
}
|
||||
|
||||
function flcBadge(res) {
|
||||
if (res.is_valid) return `<span class="badge ok">✓ OK</span>`;
|
||||
return `<span class="badge err" title="${escapeHtml(res.error_code)}">✗ ${escapeHtml(res.error_code || "ERROR")}</span>`;
|
||||
}
|
||||
|
||||
function kgdBadge(res, kgd) {
|
||||
if (res.kind !== "БИН" || !res.is_valid) return `<span class="badge muted">—</span>`;
|
||||
if (!kgd || !kgd.in_registry) return `<span class="badge warn">Не найден</span>`;
|
||||
if (kgd.status === "Действующий") return `<span class="badge ok">Действующий</span>`;
|
||||
if (kgd.status === "Ликвидирован") return `<span class="badge err">Ликвидирован</span>`;
|
||||
if (kgd.status === "Приостановлен") return `<span class="badge warn">Приостановлен</span>`;
|
||||
return `<span class="badge info">${escapeHtml(kgd.status)}</span>`;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const body = document.getElementById("resultsBody");
|
||||
body.innerHTML = "";
|
||||
|
||||
if (currentResults.length === 0) {
|
||||
document.getElementById("resultsSection").style.display = "none";
|
||||
document.getElementById("emptyState").style.display = "block";
|
||||
return;
|
||||
}
|
||||
document.getElementById("resultsSection").style.display = "block";
|
||||
document.getElementById("emptyState").style.display = "none";
|
||||
|
||||
let okBin = 0, okIin = 0, errCount = 0, kgdProblems = 0;
|
||||
|
||||
currentResults.forEach(({ rowNum, res, kgd }) => {
|
||||
if (res.is_valid) {
|
||||
if (res.kind === "БИН") okBin++; else if (res.kind === "ИИН") okIin++;
|
||||
} else {
|
||||
errCount++;
|
||||
}
|
||||
const kgdProblem = res.kind === "БИН" && res.is_valid && kgd && (
|
||||
!kgd.in_registry || kgd.status !== "Действующий"
|
||||
);
|
||||
if (kgdProblem) kgdProblems++;
|
||||
|
||||
const dateCell = res.kind === "БИН" && res.date_str ? res.date_str
|
||||
: res.kind === "ИИН" && res.date_str ? res.date_str
|
||||
: "—";
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = rowClass(res, kgd);
|
||||
tr.innerHTML = `
|
||||
<td>${rowNum}</td>
|
||||
<td class="mono">${escapeHtml(res.original)}</td>
|
||||
<td class="mono">${escapeHtml(res.normalized)}</td>
|
||||
<td>${kindBadge(res)}</td>
|
||||
<td>${flcBadge(res)}</td>
|
||||
<td>${res.subtype ? escapeHtml(res.subtype) : "—"}</td>
|
||||
<td>${escapeHtml(dateCell)}</td>
|
||||
<td>${kgdBadge(res, kgd)}</td>
|
||||
<td>${res.kind === "БИН" && res.is_valid && kgd && kgd.name ? escapeHtml(kgd.name) : "—"}</td>
|
||||
<td>${res.is_valid ? "" : `<span class="err-msg">${escapeHtml(res.error_msg || "")}</span>`}</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById("statTotal").textContent = currentResults.length;
|
||||
document.getElementById("statValid").textContent = okBin + okIin;
|
||||
document.getElementById("statValidSub").textContent = `БИН: ${okBin} · ИИН: ${okIin}`;
|
||||
document.getElementById("statErr").textContent = errCount;
|
||||
document.getElementById("statKgd").textContent = kgdProblems;
|
||||
}
|
||||
|
||||
function showColumnInfo(info, fileName) {
|
||||
const box = document.getElementById("columnInfo");
|
||||
const text = document.getElementById("columnInfoText");
|
||||
if (!info) { box.style.display = "none"; return; }
|
||||
const headerPart = info.headerName ? `«${escapeHtml(info.headerName)}»` : "(без заголовка)";
|
||||
const sourcePart = info.source === "header"
|
||||
? "найден по шапке"
|
||||
: `найден автоматически по содержимому (${info.hits} значений похожих на 12-значный номер)`;
|
||||
text.innerHTML =
|
||||
`<b>Файл:</b> ${escapeHtml(fileName)} · ` +
|
||||
`<b>Столбец:</b> <span class="mono">${info.letter}</span> ${headerPart} — ${sourcePart}.`;
|
||||
box.style.display = "block";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// События UI
|
||||
// =============================================================
|
||||
document.getElementById("fileInput").addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" });
|
||||
const { items, info } = parseSheet(rows);
|
||||
if (items.length === 0) {
|
||||
alert(
|
||||
"В файле не найдено столбца с БИН/ИИН.\n\n" +
|
||||
"Программа ищет столбец двумя способами:\n" +
|
||||
" 1) шапка содержит «БИН», «BIN», «ИИН» или сочетание;\n" +
|
||||
" 2) если шапки нет — берётся столбец с наибольшим числом 10–14-значных значений.\n\n" +
|
||||
"Проверьте, что в файле есть колонка с 12-значными номерами."
|
||||
);
|
||||
return;
|
||||
}
|
||||
showColumnInfo(info, file.name);
|
||||
runChecks(items);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Не удалось прочитать файл: " + err.message);
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("sampleBtn").addEventListener("click", () => {
|
||||
document.getElementById("columnInfo").style.display = "none";
|
||||
|
||||
// Демо-набор: БИН, ИИН и ошибки
|
||||
const demo = [
|
||||
// Валидные БИН
|
||||
{ rowNum: 2, value: "941240000193" }, // ЮЛ, в реестре «Действующий»
|
||||
{ rowNum: 3, value: "020340000013" }, // ЮЛ, в реестре «Действующий»
|
||||
{ rowNum: 4, value: "980540000199" }, // ЮЛ, в реестре «Действующий»
|
||||
{ rowNum: 5, value: "991240001200" }, // ЮЛ, в реестре «Действующий»
|
||||
{ rowNum: 6, value: "060460000013" }, // ИП, в реестре «Приостановлен»
|
||||
{ rowNum: 7, value: "920150000029" }, // Филиал, в реестре «Ликвидирован»
|
||||
{ rowNum: 8, value: "050640001236" }, // ЮЛ, валиден ФЛК, НЕ найден в КГД
|
||||
// Валидные ИИН
|
||||
{ rowNum: 9, value: "850615110054" }, // М, 15.06.1985
|
||||
{ rowNum: 10, value: "920320200124" }, // Ж, 20.03.1992
|
||||
{ rowNum: 11, value: "031108300114" }, // М, 08.11.2003
|
||||
{ rowNum: 12, value: "100214400127" }, // Ж, 14.02.2010
|
||||
// Ошибки
|
||||
{ rowNum: 13, value: "01031540001" }, // ERROR_LENGTH (11 цифр)
|
||||
{ rowNum: 14, value: "020340000451" }, // БИН с битой контрольной цифрой
|
||||
{ rowNum: 15, value: "941340000193" }, // БИН: месяц 13 → ERROR_BIN_MONTH
|
||||
{ rowNum: 16, value: "850632110054" }, // ИИН: день 32 → ERROR_IIN_DAY
|
||||
{ rowNum: 17, value: "850615070054" }, // ИИН: G=0 → ERROR_IIN_GENDER
|
||||
{ rowNum: 18, value: "941270000193" }, // 5-й знак=7 → ERROR_TYPE_DIGIT
|
||||
{ rowNum: 19, value: "941 240 000 193" }, // с пробелами → нормализуется → OK
|
||||
{ rowNum: 20, value: "" }, // EMPTY
|
||||
];
|
||||
runChecks(demo);
|
||||
});
|
||||
|
||||
document.getElementById("clearBtn").addEventListener("click", () => {
|
||||
currentResults = [];
|
||||
document.getElementById("columnInfo").style.display = "none";
|
||||
renderResults();
|
||||
});
|
||||
|
||||
document.getElementById("addBinBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("manualBin");
|
||||
const v = input.value.trim();
|
||||
if (!v) return;
|
||||
const nextRow = (currentResults.at(-1)?.rowNum ?? 1) + 1;
|
||||
const res = validateId(v);
|
||||
const kgd = kgdLookup(res);
|
||||
currentResults.push({ rowNum: nextRow, res, kgd });
|
||||
renderResults();
|
||||
input.value = "";
|
||||
input.focus();
|
||||
});
|
||||
|
||||
document.getElementById("manualBin").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") document.getElementById("addBinBtn").click();
|
||||
});
|
||||
|
||||
document.getElementById("exportBtn").addEventListener("click", () => {
|
||||
if (currentResults.length === 0) return;
|
||||
const header = [
|
||||
"№","Исходное значение","Нормализованный","Тип","ФЛК","Код ошибки",
|
||||
"Подтип","Дата","КГД статус","КГД наименование","Комментарий"
|
||||
];
|
||||
const rows = currentResults.map(({ rowNum, res, kgd }) => [
|
||||
rowNum,
|
||||
res.original,
|
||||
res.normalized,
|
||||
res.kind ?? "",
|
||||
res.is_valid ? "OK" : "ERROR",
|
||||
res.error_code ?? "",
|
||||
res.subtype ?? "",
|
||||
res.date_str ?? "",
|
||||
res.kind === "БИН" && res.is_valid && kgd ? (kgd.in_registry ? kgd.status : "Не найден") : "",
|
||||
res.kind === "БИН" && res.is_valid && kgd && kgd.name ? kgd.name : "",
|
||||
res.error_msg ?? "",
|
||||
]);
|
||||
const csv = [header, ...rows]
|
||||
.map(r => r.map(c => {
|
||||
const s = String(c ?? "");
|
||||
return /[",;\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}).join(";"))
|
||||
.join("\r\n");
|
||||
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8;" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `bin_iin_validation_${new Date().toISOString().slice(0,10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user