Перейти до вмісту

Регістри в K2 ERP

Матеріал з K2 ERP Wiki

1. Паспорт документа

Атрибут Значення
Назва Технічне завдання на розробку підсистеми «Регістри» платформи K2 ERP
Шифр K2-ERP-TZ-REG-001
Версія 1.2 (чернетка на узгодження; мова ядра — Python; +Додатки Д, Е)
Статус На розгляді
Замовник [підрозділ-замовник]
Виконавець [команда платформи K2 ERP]
Тип документа Технічне завдання (ГОСТ 34.602 адаптовано)
Аналог / прототип Механізм регістрів платформи «1С:Підприємство 8.3»
Мова розробки Python 3.12+ (див. розділ 4.3)
Технологічний стек SQLAlchemy Core 2.x, Pydantic v2, Alembic, lark, psycopg 3 / pyodbc
СКБД PostgreSQL 14+ (основна), MS SQL Server 2019+ (додаткова)

1.1. Необхідні розширення MediaWiki

Розширення Теги / шаблони Використано в документі Постачання
Extension:Diagrams <graphviz>, <uml> 4 діаграми GraphViz + 5 діаграм PlantUML Composer: samwilson/diagrams
Extension:SyntaxHighlight <syntaxhighlight> 64 блоки коду (Python, SQL, YAML, lark) Входить до складу MediaWiki — окремо встановлювати не потрібно
Template:Note {{Note|текст}} Примітки та проєктні рішення Створюється вручну (код нижче)

Налаштування

// LocalSettings.php — конфігурація ВІКІ, у якій публікується це ТЗ.
// MediaWiki написана на PHP; до мови розробки K2 ERP (Python, розділ 4.3) це не стосується.
wfLoadExtension( 'Diagrams' );

// ОБОВ'ЯЗКОВО. За замовчуванням Diagrams рендерить у PNG; ER-діаграми розділу 9
// містять десятки колонок і в растрі нечитабельні.
$wgDiagramsDefaultFormat = 'svg';

// Варіант А — локальний рендеринг (потрібні бінарники на веб-сервері):
//   apt install graphviz plantuml
// Варіант Б — зовнішній сервіс рендерингу (з версії 0.4.0 — опційний):
// $wgDiagramsServiceUrl = 'https://diagrams.example.com/subdir';

Шаблон Template:Note, якщо його немає у вікі:

<div style="border-left:4px solid #36c; background:#eaf3ff; padding:8px 12px; margin:8px 0;">
'''ⓘ''' {{{1}}}
</div>

Вимоги до версії

Шаблон:Note

Шаблон:Note

Кодування: обов'язкове налаштування

Шаблон:Note

Як відрізнити дві різні проблеми:

Симптом Діагноз Що робити
���� — ромби зі знаком питання (U+FFFD). Кількість символів більша за кількість літер оригіналу: 1 кирилична літера → 2 ромби, стрілка → 3 ромби Кодування. Байти UTF-8 декодовані як ASCII/Latin-1 — по одному на байт Розділ нижче, п. «Виправлення»
□□□□ — порожні прямокутники (tofu). Кількість збігається з кількістю літер Шрифти. Кодування правильне, але у шрифті немає гліфів кирилиці apt install fonts-dejavu fonts-liberation. У документі вже проставлено skinparam defaultFontName "DejaVu Sans" і fontname="DejaVu Sans" для GraphViz
пÑивÑÑ — «мохібейк» із латиниці з діакритикою Подвійне перекодування UTF-8 ↔ Latin-1 Перевірити $wgDiagramsServiceUrl — сервіс віддає не UTF-8

Діагностика (на сервері вікі):

# 1. Що каже сам PlantUML про своє кодування
java -jar /usr/share/plantuml/plantuml.jar -version | grep -i "Default Encoding"
#   Default Encoding: UTF-8            <- норма
#   Default Encoding: ANSI_X3.4-1968   <- ЦЕ ПРИЧИНА (це US-ASCII)
#   Default Encoding: ISO-8859-1       <- ЦЕ ПРИЧИНА

# 2. Локаль процесу веб-сервера (а не вашого shell — вони різні!)
sudo -u www-data env | grep -E 'LANG|LC_'
#   порожньо або LANG=C / LANG=POSIX -> причина

# 3. Відтворення поза MediaWiki — ізолює проблему від вікі
printf '@startuml
A -> B : кирилиця та стрілка →
@enduml
'   | java -jar /usr/share/plantuml/plantuml.jar -pipe -tsvg > /tmp/test.svg
grep -c 'кирилиця' /tmp/test.svg      # 1 = добре; 0 = поламано

# 4. Чи є шрифти з кирилицею
java -jar /usr/share/plantuml/plantuml.jar -printfonts | grep -i dejavu

Виправлення (за спаданням надійності — досить одного, але п. 1–2 разом надійніші):

// LocalSettings.php

// СПОСІБ 1 (рекомендований): задати кодування явно в команді PlantUML.
// $wgDiagramsLocalCommands перевизначає команди локального рендерингу.
$wgDiagramsLocalCommands = [
    'plantuml' => '/usr/bin/java -Dfile.encoding=UTF-8 '
                . '-jar /usr/share/plantuml/plantuml.jar -charset UTF-8',
];
# СПОСІБ 2: локаль процесу веб-сервера (systemd drop-in)
#   /etc/systemd/system/php8.2-fpm.service.d/locale.conf
[Service]
Environment=LANG=C.UTF-8
Environment=LC_ALL=C.UTF-8
#   далі: systemctl daemon-reload && systemctl restart php8.2-fpm

# СПОСІБ 3 (найчистіший): Java 18+.
# JEP 400 зробив UTF-8 кодуванням за замовчуванням незалежно від локалі ОС —
# проблема зникає структурно, без прапорців.
java -version

Шаблон:Note

Смоук-тест перед публікацією

Прогнати на цільовій вікі — по одному представнику кожного типу:

# Що перевіряємо Де в документі Ознака успіху
1 Кодування кирилиці 9.1 (підписи зв'язків) Читається «є регістром», а не ���. Перевіряти першим — решта тестів без цього безглузді
2 Гліфи кирилиці у шрифті 5.1, 6.1 Немає □□□
3 <graphviz>: rankdir, shape=record, cylinder 5.1, 6.1, 8.8, 9.5, Д.1, Д.8 Діаграма рендериться, підписи не обрізані
4 <uml> + @startuml, entity, skinparam linetype ortho 9.1–9.4 ER-модель із колонками та позначками PK/FK
5 <uml> + @startgantt 7.5.1 Діаграма Ганта. Головний ризик: якщо розширення обгортає вміст у @startuml автоматично, @startgantt не спрацює
6 $wgDiagramsDefaultFormat = 'svg' будь-яка У HTML сторінки <img src="….svg">, а не .png
7 GraphViz присутній для PlantUML 9.1–9.4 Немає повідомлення «Dot executable does not exist». PlantUML використовує dot для розкладки — plantuml без graphviz зрендерить лише sequence-діаграми (7.1), а ER-моделі — ні

Шаблон:Note

Альтернативи та запасний варіант

Варіант Оцінка
Extension:Diagrams ≥ 1.0.0 (обрано) Одне розширення на всі формати документа; не зберігає рендери як файли вікі; є дві реалізації сервісу рендерингу — PHP (Symfony) та Python (остання узгоджується зі стеком проєкту, розділ 4.3).
Extension:GraphViz + Extension:PlantUML окремо Не рекомендується. Extension:GraphViz за даними трекера Wikimedia не супроводжується й потребує правок для роботи з актуальними версіями MediaWiki. Два розширення замість одного.
Mermaid (<mermaid> у складі Diagrams або Extension:Mermaid) Рендериться на клієнті, не потребує бінарників на сервері. Але erDiagram Mermaid суттєво менш виразний за entity PlantUML: ER-моделі розділу 9 містять типи, ознаки PK/FK та коментарі до полів — це не переноситься без втрат. Придатний для простих схем (5.1, Д.1).
Без розширень Блоки діаграм відображатимуться як вихідний текст. Документ втрачає 9 із 12 схем — на відміну від версій ≤ 1.2, ASCII-дублів більше немає (див. нижче).

Що навмисно залишено ASCII-схемами

Після переходу на Diagrams дублювання «діаграма + ASCII» прибрано: воно розходилося при правках. Але 8 блоків <pre> залишено свідомо — для них Diagrams не дає виграшу:

Розділ Блок Чому не діаграма
6.8.1–6.8.3 Макети конструктора PlantUML має @startsalt для wireframe'ів, але ASCII тут комунікує краще: він явно читається як ескіз і не створює хибного враження затвердженого дизайну. Salt додає рендер-ризик без користі.
7.4.1 Timeline підсумків і ТА Схема несе формулу («Залишок = Підсумок(P) + Σ рухів») поряд із віссю часу. Gantt це не передає, а @startuml timing — надмірність.
Д.9 Наскрізний розрахунок ЗП Це покроковий наратив із числами, а не структура. Діаграма не додасть змісту.
Д.11, Е.8 Чек-лист і дерево репозиторію Список і файлове дерево. <pre> — рівно те, що треба.

2. Терміни, визначення та скорочення

Термін Визначення
Регістр Об'єкт метаданих, призначений для зберігання та накопичення структурованої облікової інформації у вигляді записів фіксованої структури, з механізмами агрегації, зрізів і транзакційного зв'язку з документами-реєстраторами.
РВ Регістр відомостей (аналог «Регистр сведений»).
РН Регістр накопичення (аналог «Регистр накопления»).
РБ Регістр бухгалтерії (аналог «Регистр бухгалтерии»).
РР Регістр розрахунку (аналог «Регистр расчёта»).
Вимірювання (Dimension) Поле регістру, що визначає розріз аналітики; входить у ключ запису/підсумку.
Ресурс (Resource) Поле регістру, значення якого підсумовується (для РН/РБ/РР) або зберігається як значення (для РВ).
Реквізит (Attribute) Довідкове поле запису, що не бере участі в ключі та не агрегується.
Реєстратор (Recorder) Документ, який породив набір записів регістру та володіє ним.
Рухи (Movements) Записи регістру, підпорядковані конкретному реєстратору.
Набір записів (RecordSet) Прикладний об'єкт для читання/запису групи записів регістру за заданим відбором.
Підсумки (Totals) Попередньо розраховані агрегати (залишки/обороти), що зберігаються в окремих таблицях.
ТА Точка актуальності підсумків — момент часу, до якого підсумки розраховані.
Зріз останніх (SliceLast) Вибірка останніх за періодом записів РВ у розрізі кожної комбінації вимірювань.
Субконто (ExtDimension, ED) Додатковий розріз аналітики бухгалтерського рахунку.
ПВР План видів розрахунку.
Витіснення Механізм РР: запис із вищим пріоритетом скорочує фактичний період дії запису з нижчим.
Перерахунок (Recalculation) Підпорядкований РР об'єкт, що реєструє записи, які потребують повторного розрахунку.
Віртуальна таблиця Обчислювана таблиця в мові запитів, що не має фізичного відповідника (Залишки, Обороти, ЗрізОстанніх тощо).

3. Призначення, цілі та підстава розробки

3.1. Підстава

Поточна модель K2 ERP не має уніфікованого декларативного механізму накопичувального обліку. Регістроподібні структури реалізуються ad hoc — окремими таблицями та процедурами під кожну прикладну задачу, що спричиняє:

  • дублювання коду розрахунку залишків та оборотів;
  • відсутність гарантованої узгодженості «документ ↔ рухи»;
  • деградацію продуктивності на великих обсягах (повний перерахунок з початку обліку);
  • високу вартість введення нової аналітики (зміна схеми БД + правка десятків процедур).

3.2. Мета

Створити в K2 ERP підсистему декларативно описуваних регістрів чотирьох типів, функціонально еквівалентну механізму регістрів «1С:Підприємство 8.3», з автоматичною генерацією схеми БД, підтримкою підсумків, віртуальних таблиць у мові запитів і транзакційного проведення документів.

3.3. Цілі проєкту (вимірювані)

Ціль Критерій досягнення
Ц1 Декларативне визначення регістру Новий регістр з 5 вимірюваннями та 3 ресурсами створюється в конструкторі без написання коду; DDL генерується автоматично.
Ц2 Автоматична агрегація Залишки/обороти доступні запитом без ручних процедур перерахунку.
Ц3 Продуктивність Отримання залишків на дату за таблицею 1 млрд рухів — ≤ 200 мс (P95) на еталонному стенді.
Ц4 Цілісність Неможливість існування рухів без активного реєстратора (100 % сценаріїв у тесті цілісності).
Ц5 Міграція Перенесення ≥ 90 % наявних «регістроподібних» структур K2 на новий механізм без втрати даних.

3.4. Що НЕ входить до обсягу (Out of scope)

  • Механізм розподілених інформаційних баз / реплікації рухів (окреме ТЗ).
  • Візуальний конструктор звітів поверх регістрів (окреме ТЗ, залежність).
  • OLAP-куби та зовнішні аналітичні вітрини.
  • Автоматичне перенесення прикладного коду замовника (тільки дані + мапінг).

4. Припущення та обмеження щодо платформи K2 ERP

Шаблон:Note

4.1. Припущення щодо платформи

Припущення Наслідок для ТЗ
П1 K2 ERP має підсистему метаданих із власним репозиторієм об'єктів (документи, довідники, перерахування) і механізмом версіонування конфігурації. Регістр вбудовується як новий рід об'єкта метаданих у наявний репозиторій (розділ 6).
П2 Об'єкти-документи мають унікальний ідентифікатор посилання (GUID/UUID) та проведення як окрему операцію. Реєстратор адресується парою «код типу + UUID» (розділ 8.2).
П3 Платформа має власну мову запитів або транслятор у SQL. Віртуальні таблиці реалізуються на рівні транслятора (розділ 7.6). Якщо мови запитів немає — потрібен окремий етап Е0 (розділ 14).
П4 Цільова СКБД підтримує MERGE/INSERT ... ON CONFLICT, віконні функції, часткові та покривні індекси. Алгоритми підсумків (розділ 10) використовують ці можливості.
П5 Прикладний код виконується на сервері застосунків із керованими транзакціями. Проведення документа — одна транзакція БД (розділ 7.1).
П6 Максимальна довжина ідентифікатора об'єкта БД — 63 символи (PostgreSQL). Фізичні імена таблиць будуються за сурогатним числовим кодом, а не за іменем регістру (розділ 8.1).
П7 Ядро платформи розробляється на Python 3.12+; допускаються C-розширення (binary-драйвери) та, за потреби, власні нативні модулі. Визначає стек (4.3) і архітектурні принципи АП-1…АП-6 (4.4). [верифікувати: greenfield чи є наявний Python-код K2]
П8 Прикладний (конфігураційний) код пишеться також на Python і виконується в тому самому процесі. Породжує питання ізоляції та безпеки виконання довільного коду — відкрите питання В12. Якщо припущення хибне (потрібна вбудована мова) — обсяг Е1 зростає на ~2 чол.-міс.

4.2. Обмеження

  • О1. Зміна складу вимірювань/ресурсів існуючого регістру — операція реструктуризації з простоєм; онлайн-міграція не гарантується для таблиць > 100 млн записів.
  • О2. Підсистема не гарантує коректність прикладної логіки проведення — лише цілісність механізму.
  • О3. Мінімальна дискретність періоду — 1 секунда (тип timestamp(0)) [уточнити: чи потрібна мілісекундна].

4.3. Технологічний стек

Шар Технологія Обґрунтування вибору
Мова ядра Python 3.12+ (цільова 3.12; щодо 3.13 — див. В14) Рішення замовника. Наслідки — розділ 4.4.
Статична типізація mypy --strict як gate у CI Ядро метаданих у динамічній мові без статичної перевірки — неприйнятний ризик (Р11).
Опис і валідація метаданих Pydantic v2 Маніфест регістру (розділ 6.7) стає типізованою моделлю: валідація, крос-полеві правила та JSON Schema для конструктора — «безкоштовно». Покриває ФТ-К-01. Приклад — Е.1.
Генерація DDL, абстракція діалектів СКБД SQLAlchemy Core 2.x (без ORM) Ключовий вибір. MetaData/Table/Column — готова модель схеми; діалекти PostgreSQL і MS SQL із коробки. Прямо закриває НФВ-16, НФВ-17 і знижує ризик Р7. Приклад — Е.2.
Міграції / реструктуризація Alembic Автогенерація ALTER при зміні складу полів (Е10, НФВ-13, НФВ-15).
Драйвери psycopg 3 (binary, pipeline mode) або asyncpg; pyodbc для MS SQL Pipeline mode критичний для АП-1: пакетна вставка без round-trip на рядок.
Парсер мови запитів lark (LALR) → AST → SQLAlchemy Core Етап Е5. Дешевше за власний парсер; граматика декларативна. Ескіз — Е.4.
Гарячі шляхи Cython / mypyc; за потреби Rust (PyO3 + maturin) Тільки за результатами бенчмарку (АП-3, Р10, В13).
Сервер застосунків FastAPI + uvicorn; окремий пул процесів для проведення
Паралелізм Процеси + asyncio для IO. Потоки — ні (GIL) Принцип АП-5.
Тестування pytest, pytest-benchmark, hypothesis (property-based), testcontainers Hypothesis обов'язковий для витіснення (Р5): інтервальна арифметика — ідеальний кандидат на property-based тести.
Профілювання py-spy, scalene, EXPLAIN (ANALYZE, BUFFERS) Етап Е11.
Збірка uv, ruff, wheels із нативними розширеннями під усі цільові платформи

Явні заборони:

  • ORM SQLAlchemy не використовується. Регістри працюють наборами записів, а не об'єктами; identity map і lazy loading тут шкідливі й провокують порушення АП-1. Використовується лише Core.
  • PyPy не розглядається — несумісність із binary-драйверами та нативними розширеннями.
  • Free-threaded build (PEP 703) — моніторити, але не закладати в архітектуру (В14).

4.4. Наслідки вибору Python: архітектурні принципи

Python на CPU-навантаженні повільніший за компільовані мови приблизно в 30–80 разів. Проєктована архітектура цьому не суперечить: основну роботу виконує СКБД, а ядро лише формує SQL. Але це вірно тільки за умови дотримання наведених принципів — їх порушення робить цілі НФВ недосяжними.

ID Принцип Формулювання
АП-1 «Жодного рядка за раз» Заборонено: цикл Python із запитом до БД усередині; вставка рухів по одному; читання підсумків по одному ключу. Обов'язково: executemany / COPY / pipeline, set-based MERGE, один запит на набір записів. Детектор порушень — Е.7.
АП-2 Ядро — оркестратор, СКБД — обчислювач Python формує SQL і передає керування. Агрегація, з'єднання, сортування — в СКБД. Виняток — гарячі шляхи АП-4, де це неможливо.
АП-3 Гарячі шляхи ізольовані за інтерфейсом Кожен кандидат на нативне переписування реалізується за Protocol-інтерфейсом з еталонною Python-реалізацією та бенчмарком у CI. Заміна на Cython/Rust не змінює виклик. Приклад — Е.5.
АП-4 Бюджет Python-часу зафіксовано Див. 4.4.1. Перевищення бюджету — блокер приймання етапу.
АП-5 Паралелізм — процесами GIL робить потоки марними для CPU-роботи. Проведення IO-bound → asyncio; масштабування — пулом процесів за кількістю ядер.
АП-6 Типізація на межах mypy --strict на ядрі; Pydantic v2 на всіх межах. Динамічний доступ до полів регістру компенсується генерацією .pyi-стабів (ФТ-К-07, Е.6).

4.4.1. Бюджет Python-часу на гарячих шляхах (АП-4)

Шлях Розділ Обсяг операцій Оцінка Python Ризик Мітигація
Формування рухів документа (100 рядків × 5 регістрів) 7.1, Д.8 ~500 конструювань об'єктів 2–5 мс Низький
Серіалізація рухів у пакетну вставку 7.1 ~500 кортежів 1–3 мс Низький psycopg 3 pipeline / COPY
Оновлення підсумків 10.2 1 SQL MERGE на регістр ≈ 0 мс Низький set-based (Е.3)
Трансляція віртуальної таблиці 7.3, 10.3 парсинг + побудова AST 1–10 мс Середній LRU-кеш скомпільованих запитів за хешем (текст + сигнатура параметрів)
Витіснення періодів дії 10.4 Z\|²) на ключ; 10 тис. ключів × ~10 записів ≈ 106 інтервальних операцій 1–3 с Високий Sweep-line O(n log n); ізоляція за АП-3; кандидат №1 на Cython/Rust; альтернатива — перенести в SQL на daterange + GiST (PostgreSQL)
Розрахунок ЗП Д.9 80 тис. записів × формула 4–15 с Високий Заборона per-record запитів (АП-1); векторизація; кандидат №2 на нативний код
Реєстрація перерахунків 10.5 1 SQL INSERT … SELECT ≈ 0 мс Низький set-based
Генерація DDL, реструктуризація 8, Е.2 холодний шлях, разова операція Низький

4.4.2. Висновок щодо НФВ

НФВ Ціль Частка Python Вердикт
НФВ-01…03 (залишки, обороти) 200 мс – 2 с < 1 % (робота в СКБД) Не залежить від мови ядра
НФВ-04 (проведення 100 рядків) 300 мс 5–15 мс ≈ 5 % Досяжно за дотримання АП-1
НФВ-05 (ОСВ) 5 с < 1 % Не залежить від мови ядра
НФВ-06 (ЗП на 10 тис. осіб) 10 хв 4–15 с ≈ 2,5 % Досяжно; запас ×40
НФВ-07 (зріз останніх) 500 мс < 1 % Не залежить від мови ядра
НФВ-08 (50 док/с) 50 × 10 мс = 0,5 с CPU/с ≈ 50 % одного ядра Досяжно; пул процесів (АП-5)
НФВ-09 (перерахунок підсумків) 4 год < 1 % Не залежить від мови ядра

Підсумок: цілі НФВ не переглядаються у зв'язку з вибором Python. Натомість вводиться жорстка вимога АП-1 — її порушення робить цілі недосяжними на будь-якій мові, просто на Python розплата настає на порядок раніше й помітніше.

5. Класифікація регістрів

5.1. Дерево типів

5.2. Порівняльна матриця типів

Характеристика РВ РН (Залишки) РН (Обороти) РБ РР
Ключ запису Вимірювання (+Період) або Реєстратор+№ Реєстратор+№ Реєстратор+№ Реєстратор+№ Реєстратор+№
Незалежний запис Так Ні Ні Ні Ні
Періодичність Опційна Обов'язкова (Період) Обов'язкова Обов'язкова Обов'язкова
Ресурси підсумовуються Ні Так (±) Так (+) Так (Дт/Кт) Так
Вид руху (Прихід/Витрата) Так Дт/Кт
Таблиця залишків Так Так
Таблиця оборотів Так (опц.) Так Так
Зв'язок із планом рахунків Так
Зв'язок із ПВР Так
Період дії / витіснення Так
Базовий період Так
Графік часу Так
Субконто Так
Перерахунки Так
Типові приклади Курси валют, ціни, кадрова історія, графік роботи Товари на складах, взаєморозрахунки Продажі, витрати за статтями Госпрозрахунковий, управлінський, податковий облік Нарахування ЗП, утримання

6. Метамодель: як регістр задається

6.1. Місце в репозиторії метаданих

6.2. Загальні властивості будь-якого регістру

Властивість Тип Обов'язк. Опис / допустимі значення
Ім'я Ідентифікатор Так Унікальне в межах роду. Латиниця/кирилиця, ≤ 80 симв., без пробілів.
Синонім Рядок Ні Подання для користувача.
Коментар Текст Ні
ВидРегістру Перелік Так Відомостей / Накопичення / Бухгалтерії / Розрахунку. Незмінна після створення.
РежимЗапису Перелік Так Незалежний / ПідпорядкованийРеєстратору. Для РН/РБ/РР — примусово ПідпорядкованийРеєстратору.
Періодичність Перелік Так Неперіодичний, ВМежахСекунди, ВМежахДня, ВМежахМісяця, ВМежахКварталу, ВМежахРоку, ПоПозиціїРеєстратора.
Реєстратори Список посилань на типи документів Умовно Обов'язково для РН/РБ/РР та підпорядкованого РВ. Порожній список = регістр непридатний до запису.
БлокуванняДаних Перелік Ні Автоматичне / Кероване (за замовчуванням Кероване).
ВключатиДоЗвітів Булево Ні Показувати в універсальному конструкторі звітів.
ПравоДоступуЗаЗамовчуванням Перелік Ні Заборонено / Читання.
ВикористовуватиРLS Булево Ні Row-level security (розділ 7.9).
Партиціонування Структура Ні Немає / ЗаПеріодом(RANGE, крок) / ЗаВимірюванням(HASH, n).

6.3. Поля регістру

Кожне поле має:

Властивість Опис
Ім'я, Синонім Ідентифікація.
Роль Вимірювання / Ресурс / Реквізит.
Тип Примітивний (Число(p,s), Рядок(n), Дата, Булево, УНІ), посилальний (Довідник.X, Документ.Y, Перерахування.Z, ПланРахунків.A, ПланВидівХарактеристик.B) або складений (список типів).
Індексувати Не індексувати / Індексувати / ІндексуватиЗДодаткомВимірювань.
Ведуче (Master) Тільки для вимірювань. Якщо Істина — записи регістру видаляються разом з об'єктом, на який посилається вимірювання; регістр показується у формі цього об'єкта.
ОсновнийВідбір Брати участь у стандартному відборі форми списку.
ЗаборонитиНезаповненіЗначення Валідація на запис.

Специфічні прапорці полів по типах регістрів:

Прапорець Застосовний до Опис
Балансовий Ресурс/Вимірювання РБ Ресурс бере участь у контролі рівності Дт=Кт; має єдине значення на запис (а не пару Дт/Кт). Вимірювання балансове — однакове для Дт і Кт.
Облік (AccountingFlag) Ресурс РБ Посилання на ознаку обліку рахунка з ПР; ресурс заповнюється, лише якщо ознака встановлена.
ОблікСубконто Ресурс РБ Посилання на ознаку обліку субконто (напр. кількісний облік у розрізі субконто).
Базове (BaseDimension) Вимірювання РР Використовується для зіставлення записів під час отримання бази розрахунку.
ВикористовуватиПоточніЗначення Ресурс/Реквізит РР

6.4. Специфічні властивості за типами

6.4.1. Регістр відомостей

Властивість Значення Примітка
Періодичність Неперіодичний … ПоПозиціїРеєстратора Визначає наявність поля Період і його гранулярність.
РежимЗапису Незалежний / ПідпорядкованийРеєстратору У незалежному ключ = (Період) + Вимірювання.
ОсновнийВідбір
ДозволитиПідсумки У РВ підсумків немає; замість них — зрізи.

Ключ унікальності:

  • Незалежний неперіодичний: UNIQUE(Вимірювання…)
  • Незалежний періодичний: UNIQUE(Період, Вимірювання…)
  • Підпорядкований: PK(Реєстратор, НомерРядка), унікальність за вимірюваннями не контролюється.

6.4.2. Регістр накопичення

Властивість Значення
ВидРегістру Залишки / Обороти
ДозволитиПідсумкиЗалишків Булево (тільки для виду «Залишки»)
ДозволитиПідсумкиОборотів Булево
ПеріодичністьПідсумківОборотів День / Місяць (тільки для виду «Обороти»)
Агрегати Список визначень агрегатів (тільки для виду «Обороти»), див. 7.4.4
РежимАгрегатів Агрегати / Підсумки

6.4.3. Регістр бухгалтерії

Властивість Значення Примітка
ПланРахунків Посилання на об'єкт «План рахунків» Обов'язково. Незмінне після створення.
Кореспонденція Булево Істина → подвійний запис (поля РахунокДт/РахунокКт); Хиба → уніграфічний (Рахунок + ВидРуху).
ДозволитиРозділенняПідсумків Булево
МаксКількістьСубконто Число (успадковується з ПР) Ліміт платформи: 5 [узгодити; 1С — 3 за замовчуванням, до 5]

Пов'язаний об'єкт План рахунків повинен підтримувати:

  • ієрархію рахунків, Код, Найменування, Вид (Активний/Пасивний/Активно-пасивний);
  • Забалансовий (булево);
  • Ознаки обліку (набір булевих реквізитів, напр. «Кількісний», «Валютний»);
  • табличну частину ВидиСубконто (ВидСубконто → ПВХ, Тільки обороти, Підсумкова сума, ознаки обліку субконто);
  • МаксКількістьСубконто.

6.4.4. Регістр розрахунку

Властивість Значення Примітка
ПланВидівРозрахунку Посилання Обов'язково.
Періодичність День/Місяць/Квартал/Рік Період реєстрації.
ПеріодДії Булево Вмикає поля ПеріодДіїПочаток/Кінець, фактичні періоди дії та витіснення.
БазовийПеріод БезБазовогоПеріоду / ЗаПеріодДії / ЗаПеріодРеєстрації Режим отримання бази.
Графік Посилання на РВ Регістр-графік часу.
ЗначенняГрафіка Ресурс регістру-графіка Напр. «Значення» (1/0 — робочий день) або «Годин».
ДатаГрафіка Вимірювання регістру-графіка типу Дата
Перерахунки Список підпорядкованих об'єктів «Перерахунок» Див. 7.5.3.

Об'єкт Перерахунок (підпорядкований РР):

  • Ім'я;
  • Вимірювання — список, кожне зіставляється з вимірюванням основного РР (ВимірюванняРегістру);
  • правило: перерахунок реєструє «об'єкт перерахунку» = (Реєстратор, ВидРозрахунку, Вимірювання…).

План видів розрахунку повинен підтримувати:

  • ВикористовуєПеріодДії;
  • табличні частини БазовіВидиРозрахунку, ВитісняючіВидиРозрахунку, ВедучіВидиРозрахунку;
  • ЗалежністьВідБази: НеЗалежить / ЗаПеріодДії / ЗаПеріодРеєстрації.

6.5. Стандартні реквізити (генеруються автоматично)

Реквізит РВ РН РБ РР Тип
Період якщо періодичний + + + (період реєстрації) Дата/час
Реєстратор якщо підпорядкований + + + Складене посилання (ДокументПосилання)
НомерРядка якщо підпорядкований + + + Число(9,0)
Активність якщо підпорядкований + + + Булево
ВидРуху тільки «Залишки» Перелік {Прихід, Витрата}
Рахунок + (без кореспонденції) ПланРахунківПосилання
РахунокДт, РахунокКт + (з кореспонденцією) ПланРахунківПосилання
ВидРухуРахунку + (без кореспонденції) Перелік {Дебет, Кредит}
Субконто (колекція) + Відповідність(ВидСубконто→Значення)
ВидРозрахунку + ПВРПосилання
ПеріодДіїПочаток, ПеріодДіїКінець + (якщо ПеріодДії) Дата
БазовийПеріодПочаток, БазовийПеріодКінець + (якщо БазовийПеріод) Дата
Сторно + Булево

6.6. Кількісні обмеження метамоделі

Параметр Ліміт Примітка
Кількість вимірювань 20 Понад 8 — попередження конструктора (деградація підсумків)
Кількість ресурсів 20
Кількість реквізитів 30
Кількість типів у складеному типі поля 32
Кількість реєстраторів Не обмежено
Максимум субконто на рахунок 5
Довжина ключа підсумків ≤ 900 байт (обмеження індексу MS SQL) Валідація в конструкторі
Глибина періодичності підсумків День / Місяць Тиждень/Квартал — не підтримуються (обчислюються згортанням)

6.7. Декларативний опис (DSL)

Регістр зберігається в репозиторії у вигляді XML/YAML-маніфесту. Приклад:

# --- Регістр накопичення (вид: Залишки) ---
register:
  name: ТовариНаСкладах
  kind: Accumulation
  accumulationKind: Balance
  synonym: "Товари на складах"
  periodicity: WithinSecond
  writeMode: SubordinateToRecorder
  enableBalanceTotals: true
  enableTurnoverTotals: true
  partitioning: { mode: RangeByPeriod, step: month }

  dimensions:
    - { name: Номенклатура, type: "Довідник.Номенклатура", master: true,  index: true }
    - { name: Склад,        type: "Довідник.Склади",       master: true,  index: true }
    - { name: Партія,       type: ["Документ.ПрихіднаНакладна", "Документ.ВведенняЗалишків"], index: false }
    - { name: Характеристика, type: "ПланВидівХарактеристик.Характеристики", nullable: true }

  resources:
    - { name: Кількість, type: "Число(15,3)" }
    - { name: Сума,      type: "Число(15,2)" }

  attributes:
    - { name: Коментар, type: "Рядок(200)" }

  recorders:
    - Документ.ПрихіднаНакладна
    - Документ.ВидатковаНакладна
    - Документ.ПереміщенняТоварів
    - Документ.Інвентаризація
# --- Регістр відомостей (періодичний, незалежний) ---
register:
  name: КурсиВалют
  kind: Information
  periodicity: WithinDay
  writeMode: Independent
  dimensions:
    - { name: Валюта, type: "Довідник.Валюти", master: true }
  resources:
    - { name: Курс,     type: "Число(15,4)" }
    - { name: Кратність, type: "Число(10,0)", default: 1 }
# --- Регістр бухгалтерії ---
register:
  name: Госпрозрахунковий
  kind: Accounting
  chartOfAccounts: ПланРахунків.Госпрозрахунковий
  correspondence: true
  periodicity: WithinSecond
  dimensions:
    - { name: Організація, type: "Довідник.Організації", balance: true }
    - { name: Підрозділ,   type: "Довідник.Підрозділи",  balance: false }
  resources:
    - { name: Сума,       type: "Число(15,2)", balance: true }
    - { name: Кількість,  type: "Число(15,3)", balance: false, accountingFlag: Кількісний }
    - { name: ВалютнаСума, type: "Число(15,2)", balance: false, accountingFlag: Валютний }
  attributes:
    - { name: Зміст, type: "Рядок(255)" }
    - { name: Валюта, type: "Довідник.Валюти" }
  recorders: [Документ.БухгалтерськаОперація, Документ.ПрихіднаНакладна]
# --- Регістр розрахунку ---
register:
  name: Нарахування
  kind: Calculation
  calculationTypesPlan: ПланВидівРозрахунку.ОсновніНарахування
  periodicity: Month
  actionPeriod: true
  basePeriod: ByActionPeriod
  schedule:
    register: РегістрВідомостей.ГрафікРоботи
    valueResource: Значення
    dateDimension: ДатаГрафіка
  dimensions:
    - { name: Співробітник, type: "Довідник.Співробітники", baseDimension: true, master: true }
    - { name: Підрозділ,    type: "Довідник.Підрозділи" }
  resources:
    - { name: Результат,        type: "Число(15,2)" }
    - { name: ВідпрацьованоДнів, type: "Число(5,0)" }
    - { name: НормаДнів,        type: "Число(5,0)" }
  recalculations:
    - name: ПерерахунокЗаБазою
      dimensions:
        - { name: Співробітник, registerDimension: Співробітник }
  recorders: [Документ.НарахуванняЗарплати]

Шаблон:Note

6.8. UI конструктора метаданих

6.8.1. Загальний макет

┌─ Конфігуратор K2 ERP ─────────────────────────────────────────────────────────┐
│ Файл  Правка  Конфігурація  Адміністрування  Сервіс  Довідка                  │
├──────────────────────┬────────────────────────────────────────────────────────┤
│ Дерево конфігурації  │  Регістр накопичення: ТовариНаСкладах                  │
│                      │  ┌──────┬─────────┬──────────┬──────────┬───────────┐  │
│ ▾ Регістри           │  │Основ.│Дані     │Реєстратор│Форми     │Права      │  │
│   ▸ відомостей       │  └──────┴─────────┴──────────┴──────────┴───────────┘  │
│   ▾ накопичення      │                                                        │
│     ▾ ТовариНаСклад. │  Ім'я           [ТовариНаСкладах.............]         │
│       ▾ Вимірювання  │  Синонім        [Товари на складах...........]         │
│           Номенклат. │  Коментар       [.............................]        │
│           Склад      │                                                        │
│           Партія     │  Вид регістру   ( ) Обороти   (•) Залишки              │
│       ▾ Ресурси      │  Періодичність  [ У межах секунди        ▾]            │
│           Кількість  │  Режим запису   [ Підпорядкований реєстратору ▾]       │
│           Сума       │                                                        │
│       ▸ Реквізити    │  ☑ Дозволити підсумки залишків                        │
│       ▸ Форми        │  ☑ Дозволити підсумки оборотів                        │
│     ▸ Взаєморозрах.  │      Періодичність підсумків оборотів [Місяць  ▾]      │
│   ▸ бухгалтерії      │  ☐ Дозволити розділення підсумків                     │
│   ▸ розрахунку       │                                                        │
│                      │  Блокування даних  [Кероване ▾]                        │
│                      │  Партиціонування   [За періодом, крок: місяць ▾]       │
│                      │                                                        │
│                      │  [ Конструктор рухів... ]  [ Перевірити ]  [ Закрити ] │
├──────────────────────┴────────────────────────────────────────────────────────┤
│ ⚠ Попередження: 4 вимірювання + місячні підсумки → оцінка таблиці підсумків    │
│   ~ 12 млн записів/рік. Розгляньте виключення вимірювання «Партія» з підсумків.│
└───────────────────────────────────────────────────────────────────────────────┘

6.8.2. Палітра властивостей поля

┌─ Властивості: Вимірювання «Партія» ────────────────┐
│ Ім'я                  [Партія...................]  │
│ Синонім               [Партія...................]  │
│ Тип                   [Складений тип........ [...]]│
│    ├ Документ.ПрихіднаНакладна                     │
│    └ Документ.ВведенняЗалишків                     │
│ Індексувати           [Не індексувати         ▾]   │
│ ☐ Ведуче                                           │
│ ☐ Основний відбір                                  │
│ ☐ Заборонити незаповнені значення                  │
│ ☑ Використовувати в підсумках                      │
│ Подання                                            │
│    Підказка           [Партія товару...........]   │
└────────────────────────────────────────────────────┘

6.8.3. Вкладка «Реєстратори»

┌─ ТовариНаСкладах → Реєстратори ────────────────────────────┐
│ ☑ Документ.ПрихіднаНакладна                                │
│ ☑ Документ.ВидатковаНакладна                               │
│ ☑ Документ.ПереміщенняТоварів                              │
│ ☑ Документ.Інвентаризація                                  │
│ ☐ Документ.Замовлення                                      │
│ ☐ Документ.РахунокНаОплату                                 │
│                                     [Позначити всі] [Зняти]│
│ ─────────────────────────────────────────────────────────  │
│ ⓘ Знімання прапорця для документа, що має рухи, вимагає    │
│   очищення рухів. Знайдено рухів: 0                        │
└────────────────────────────────────────────────────────────┘

6.8.4. Вимоги до конструктора

  • ФТ-К-01. Валідація метаопису в реальному часі з переліком помилок/попереджень.
  • ФТ-К-02. Попередній перегляд згенерованого DDL (кнопка «Показати SQL»).
  • ФТ-К-03. Калькулятор оцінки обсягу таблиць підсумків (добуток кардинальностей вимірювань × кількість періодів).
  • ФТ-К-04. Заборона неприпустимих комбінацій (напр. «Незалежний» + «Регістр накопичення»).
  • ФТ-К-05. «Конструктор рухів» — генерація коду формування рухів за табличною частиною документа.
  • ФТ-К-06. Порівняння/об'єднання конфігурацій має коректно обробляти об'єкти-регістри.
  • ФТ-К-07. Генерація .pyi-стабів для кожного регістру за метаописом. Прикладний код звертається до полів динамічно (__getattr__), що недоступне для mypy; згенеровані стаби повертають статичну перевірку. Перегенеровуються при кожній зміні метаданих, перевіряються в CI (див. Е.6). Вимога специфічна для Python, аналога в 1С не має.

7. Функціональні вимоги

7.1. Набір записів і проведення документа

ФТ-01. Платформа надає прикладний об'єкт НабірЗаписів для кожного регістру з властивостями: Відбір, Записувати, і методами Прочитати(), Записати(Заміщати), Очистити(), ЗаблокуватиДляЗміни(), Вивантажити(), Завантажити().

ФТ-02. Для документа доступна колекція Рухи з наборами записів усіх регістрів, де документ вказано реєстратором.

ФТ-03. Сценарій проведення:

ФТ-04. Запис набору записів у режимі Заміщати = Істина (за замовчуванням для рухів) виконує DELETE усіх записів за відбором + INSERT нових — атомарно.

ФТ-05. Скасування проведення документа: усі набори записів за реєстратором очищуються, підсумки коригуються, документ отримує Проведений = Хиба.

ФТ-06. Видалення документа-реєстратора (у т.ч. позначеного на видалення) зобов'язане каскадно видалити всі його рухи в межах однієї транзакції. Реалізація — на рівні платформи (не FK, оскільки посилання поліморфне) + фонова перевірка цілісності (розділ 7.10).

ФТ-07. Режими проведення:

Режим Умова Поведінка
Оперативне Дата документа = поточна дата, документ у межах «оперативної точки» Дозволено контроль залишків «на зараз»; дата може перепризначатися на поточну.
Неоперативне Дата в минулому/майбутньому або документ перепроводиться Контроль залишків виконується на дату документа; можливий негативний залишок за налаштуванням.

7.2. Активність записів

ФТ-08. Реквізит Активність = Хиба означає, що запис фізично існує, але не впливає на підсумки та не потрапляє до віртуальних таблиць. ФТ-09. Зміна активності будь-якого запису тягне коригування підсумків. ФТ-10. Скасування проведення документа переводить усі його записи в Активність = Хиба або видаляє їх — залежно від налаштування регістру ПоведінкаПриСкасуванні (Видаляти — за замовчуванням / Деактивувати).

7.3. Віртуальні таблиці

ФТ-11. Мова запитів надає для кожного регістру набір віртуальних таблиць. Транслятор перетворює звернення до них на SQL із використанням таблиць підсумків та «живих» рухів після ТА.

Регістр Віртуальна таблиця Параметри Повертає
РВ ЗрізОстанніх (Період, Умова) Останній запис на кожну комбінацію вимірювань з періодом ≤ Період
ЗрізПерших (Період, Умова) Перший запис з періодом ≥ Період
(основна) Усі записи
РН.Залишки Залишки (Період, Умова) Вимірювання + <Ресурс>Залишок
Обороти (Початок, Кінець, Періодичність, Умова) <Ресурс>Прихід, <Ресурс>Витрата, <Ресурс>Оборот
ЗалишкиІОбороти (Початок, Кінець, Періодичність, МетодДоповнення, Умова) <Р>ПочатковийЗалишок, <Р>Прихід, <Р>Витрата, <Р>Оборот, <Р>КінцевийЗалишок
(основна) Рухи
РН.Обороти Обороти (Початок, Кінець, Періодичність, Умова) <Ресурс>Оборот
РБ Залишки (Період, УмоваРахунку, Субконто, Умова) <Р>Залишок, <Р>ЗалишокДт, <Р>ЗалишокКт
Обороти (Початок, Кінець, Періодичність, УмоваРахунку, Субконто, Умова, УмоваКорРахунку, КорСубконто) <Р>Оборот, <Р>ОборотДт, <Р>ОборотКт
ЗалишкиІОбороти (…) Повний набір
ОборотиДтКт (Початок, Кінець, Періодичність, УмоваРахункуДт, СубконтоДт, УмоваРахункуКт, СубконтоКт) Кореспонденції
ДвиженияССубконто (Початок, Кінець, УмоваРахунку, Субконто, Умова) Рухи з розшифровкою субконто
Субконто (Умова, Порядок…) Значення субконто записів
РР ДаніГрафіка (Умова) ЗначенняПеріодуДії, ЗначенняПеріодуРеєстрації, ЗначенняБазовогоПеріоду, ЗначенняФактичногоПеріодуДії
ФактичнийПеріодДії (Умова) Записи з фактичними (після витіснення) межами
БазаРозрахунку (ВимірюванняБази, ВимірюванняОсновногоРегістру, Розрізи, ВидиРозрахунку, Умова) Агреговані значення ресурсів базових видів розрахунку
(основна) Рухи

ФТ-12. Приклад запиту:

ВИБРАТИ
    Залишки.Номенклатура,
    Залишки.Склад,
    Залишки.КількістьЗалишок,
    Обороти.КількістьПрихід,
    Обороти.КількістьВитрата
З
    РегістрНакопичення.ТовариНаСкладах.ЗалишкиІОбороти(
        &ПочатокПеріоду,
        &КінецьПеріоду,
        Місяць,
        Рух,
        Склад = &Склад
            І Номенклатура В ІЄРАРХІЇ (&ГрупаНоменклатури)
    ) ЯК Залишки

ФТ-13. Параметри віртуальних таблиць зобов'язані транслюватися у WHERE підзапиту до звернення до підсумків (push-down), а не фільтруватися після агрегації.

7.4. Підсумки

7.4.1. Модель підсумків залишків

Таблиця залишків зберігає кумулятивний залишок на початок кожного періоду + спеціальний рядок «підсумок за всі періоди» з period = '5999-11-01' (маркер MAXPERIOD).

                     Таблиця підсумків (місячна)                  ТА
                     ┌────┬────┬────┬────┬────┐                    ▼
  ─────┬────┬────┬───┴────┴────┴────┴────┴────┴────────────────────┬──────────►
       │    │    │  01.10  01.11  01.12  01.01  01.02             15.02      t
       │    │    │   ●      ●      ●      ●      ●
       │    │    │  бал.   бал.   бал.   бал.   бал.
                                                    └─ «живі» рухи ─┘
                                                      (сканування)
  Залишок на 10.02 = Підсумок(01.02) + Σ рухів (01.02 … 10.02]
  Залишок на 20.02 = Підсумок(01.02) + Σ рухів (01.02 … 20.02]   // після ТА — теж

ФТ-14. Регламентна операція «Перерахунок підсумків» перебудовує таблиці підсумків з нуля. ФТ-15. Регламентна операція «Встановлення періоду розрахунку підсумків» переносить ТА вперед/назад. ФТ-16. При записі рухів раніше ТА підсумки коригуються інкрементально (див. 10.2), без повного перерахунку.

7.4.2. Розділення підсумків

ФТ-17. За увімкненого ДозволитиРозділенняПідсумків у таблиці підсумків додається колонка splitter smallint. Одночасні транзакції пишуть у різні «сплітери», що усуває конкуренцію за рядок. Читання агрегує по всіх сплітерах. Регламентне завдання зливає сплітери.

7.4.3. Обороти

ФТ-18. Таблиця оборотів зберігає агрегати за календарний період (день або місяць — за налаштуванням), без кумуляції.

7.4.4. Агрегати (тільки РН виду «Обороти»)

ФТ-19. Агрегат — додаткова матеріалізована таблиця з підмножиною вимірювань і власною періодичністю (День/Місяць/Квартал/Рік). Оптимізатор запитів обирає найдешевший агрегат, що покриває запит. ФТ-20. Режими: Реальний час (оновлюється при записі рухів) / Регламентний (оновлюється завданням; запит враховує рухи після дати актуальності агрегату). ФТ-21. «Порадник агрегатів» аналізує статистику запитів і пропонує склад агрегатів (ВизначитиОптимальніАгрегати()).

7.5. Механіка регістру розрахунку

7.5.1. Витіснення за періодом дії

ФТ-22. Якщо у ПВР для виду розрахунку A вказано B у складі «Витісняючі види розрахунку», то запис виду B скорочує фактичний період дії запису виду A на інтервал перетину.

Diagrams error (with plantuml command): Error line 2 in file: /tmp/diagrams_ina7db0da747e5.plantuml Some diagram description contains errors

Шаблон:Note

ФТ-23. Фактичні періоди дії матеріалізуються у службовій таблиці rg_c_<код>_ap під час запису набору записів. ФТ-24. Витіснення обчислюється в межах збігу усіх вимірювань регістру та перетину періодів реєстрації, визначених ПВР.

7.5.2. Базовий період і база розрахунку

ФТ-25. Віртуальна таблиця БазаРозрахунку повертає суму ресурсів записів базових видів розрахунку, зіставлених за базовими вимірюваннями, з урахуванням режиму ЗалежністьВідБази:

  • ЗаПеріодДії — беруться записи, чий період дії перетинається з базовим періодом поточного запису; результат пропорційно розподіляється за часткою перетину (за графіком, якщо заданий);
  • ЗаПеріодРеєстрації — беруться записи, чий період реєстрації потрапляє в базовий період.

7.5.3. Перерахунки

ФТ-26. При записі/зміні/видаленні запису базового виду розрахунку платформа автоматично формує записи у таблиці перерахунку для всіх залежних записів (за збігом вимірювань перерахунку та потраплянням у базовий період). ФТ-27. Прикладний код читає перерахунок як набір записів, перепроводить відповідні документи та очищує оброблені записи. ФТ-28. Перерахунок доступний як об'єкт для звіту «Документи до перерахунку».

7.5.4. Графік часу

ФТ-29. Графік — це РВ з обов'язковим вимірюванням типу Дата та числовим ресурсом. Віртуальна таблиця ДаніГрафіка повертає суму значень графіка за відповідний період (дії / реєстрації / базовий / фактичний період дії). ФТ-30. Якщо у графіку є додаткові вимірювання (напр. ВидГрафіка), вони зіставляються з однойменними реквізитами/вимірюваннями РР.

7.6. Прикладний API

from datetime import date
from decimal import Decimal

from k2.registers import AccumulationMovementType, DataLock, DataLockMode, Registers
from k2.documents import Document, PostingContext

# ============ Регістр відомостей: незалежний запис ============
mgr = Registers.information("КурсиВалют")

rm = mgr.create_record_manager()
rm.Період = date(2026, 7, 17)
rm.Валюта = usd
rm.Курс = Decimal("41.85")
rm.Кратність = 1
rm.write(replace=True)                    # INSERT ... ON CONFLICT DO UPDATE

# зріз останніх
row = mgr.get_last(date.today(), {"Валюта": usd})
курс: Decimal = row.Курс

# АП-1: масове завантаження — ОДИН запит, а не цикл із write() усередині
rs = mgr.create_record_set()
rs.load([{"Період": d, "Валюта": v, "Курс": k, "Кратність": 1}
         for d, v, k in nbu_rates])       # 3 000 курсів
rs.write(replace=True)                    # 1 × executemany, ~40 мс


# ============ Регістр накопичення: рухи документа ============
class ВидатковаНакладна(Document):

    def on_posting(self, ctx: PostingContext) -> None:
        rs = self.movements.ТовариНаСкладах
        rs.write = True                   # набір буде записано наприкінці транзакції
        rs.clear()

        # АП-1: набір наповнюється генератором, запис — одним batch
        rs.extend(
            rs.new(
                Період=self.Дата,
                ВидРуху=AccumulationMovementType.EXPENSE,
                Номенклатура=r.Номенклатура,
                Склад=self.Склад,
                Партія=r.Партія,
                Кількість=r.Кількість,
                Сума=r.Сума,
            )
            for r in self.Товари
        )

        # керовані блокування перед контролем залишків
        with DataLock() as dl:            # __enter__ виконує lock()
            li = dl.add("РегістрНакопичення.ТовариНаСкладах.Залишки")
            li.mode = DataLockMode.EXCLUSIVE
            li.data_source = rs.unload("Номенклатура", "Склад")
            li.use_from_data_source("Номенклатура", "Номенклатура")
            li.use_from_data_source("Склад", "Склад")

            rs.write_now()                # примусовий запис до контролю
            self._control_balances(ctx)   # прикладна перевірка від'ємних залишків

    # ============ Регістр бухгалтерії ============
    def _accounting(self) -> None:
        rs = self.movements.Госпрозрахунковий
        rs.write = True
        for r in self.Товари:
            e = rs.add_accounting_entry(
                Період=self.Дата,
                Організація=self.Організація,
                РахунокДт=accounts["281"],
                РахунокКт=accounts["631"],
                Сума=r.Сума,
                КількістьДт=r.Кількість,
            )
            e.subconto_dt[ВидиСубконто.Номенклатура] = r.Номенклатура
            e.subconto_dt[ВидиСубконто.Склади] = self.Склад
            e.subconto_kt[ВидиСубконто.Контрагенти] = self.Контрагент

    # ============ Регістр розрахунку ============
    def _calculation(self) -> None:
        rs = self.movements.Нарахування
        rs.write = True
        rs.append(rs.new(
            ВидРозрахунку=calc_types["Оклад"],
            Період=self.Дата,                      # період реєстрації
            ПеріодДіїПочаток=date(2026, 7, 1),
            ПеріодДіїКінець=date(2026, 7, 31),
            БазовийПеріодПочаток=date(2026, 6, 1),
            БазовийПеріодКінець=date(2026, 6, 30),
            Співробітник=self.Співробітник,
            Результат=Decimal(0),                  # заповниться після розрахунку
        ))
        rs.write_now()

# Отримання даних графіка та бази розрахунку — запитом (розділ 7.3), не циклом.

Шаблон:Note

Шаблон:Note

7.7. Блокування та конкурентність

Вимога
ФТ-31 Режим керованих блокувань — за замовчуванням. Рівень ізоляції транзакцій: READ COMMITTED (PostgreSQL) / READ COMMITTED SNAPSHOT (MS SQL).
ФТ-32 Об'єкт DataLock дозволяє встановити розділювальне/виняткове блокування на «простір» регістру за значеннями вимірювань (реалізація — pg_advisory_xact_lock за хешем ключа або SELECT ... FOR UPDATE по таблиці підсумків).
ФТ-33 Запис рухів документа блокує тільки записи цього реєстратора (PK містить реєстратор першим).
ФТ-34 Оновлення підсумків не повинно призводити до дедлоків: усі MERGE-операції в межах транзакції виконуються у детермінованому порядку сортування ключа.
ФТ-35 Гарантія: паралельне проведення двох документів з різними наборами вимірювань не конкурує (за увімкненого розділення підсумків).
ФТ-36 Таймаут очікування блокування — 20 с (налаштовується); при перевищенні — керована помилка з текстом конфліктного ключа.

7.8. Права доступу

ФТ-37. Для регістру визначаються права: Читання, Додавання, Зміна, Видалення, Перегляд, Редагування, ПереглядПідсумків. ФТ-38. Обмеження на рівні записів (RLS) задаються шаблоном умови по вимірюваннях; транслюються в WHERE усіх звернень, включно з віртуальними таблицями. ФТ-39. Ризик: RLS на віртуальних таблицях залишків потребує узгодженості з підсумками. Рішення — RLS дозволено тільки по вимірюваннях, що входять до ключа підсумків; конструктор валідує це правило.

7.9. Журналювання, аудит, службові сервіси

ФТ-40. Реєстрація в журналі: перерахунок підсумків, зміна ТА, реструктуризація регістру, злиття сплітерів. ФТ-41. Службовий сервіс «Перевірка цілісності регістрів»:

  • «висячі» рухи (реєстратор не існує або не проведений);
  • розбіжність підсумків і сум рухів (контрольний перерахунок у тіньову таблицю з порівнянням);
  • некоректні фактичні періоди дії РР;
  • для РБ — порушення рівності Дт = Кт за балансовим ресурсом у межах реєстратора.

ФТ-42. Звіт «Аналіз розміру регістрів»: кількість записів, розмір таблиць та індексів, дата останнього перерахунку підсумків.

8. Модель даних (як це виглядає в базі даних)

8.1. Конвенції іменування

Фізичні імена не успадковують прикладні імена (обмеження довжини ідентифікатора, кирилиця, перейменування об'єктів). Ім'я будується за сурогатним кодом об'єкта метаданих <id> (4-значний hex/dec).

Шаблон Призначення Приклад
rg_i_<id> Регістр відомостей — основна таблиця rg_i_0007
rg_a_<id> Регістр накопичення — рухи rg_a_0012
rg_a_<id>_tb РН — підсумки залишків (Totals Balance) rg_a_0012_tb
rg_a_<id>_tt РН — підсумки оборотів (Totals Turnover) rg_a_0012_tt
rg_a_<id>_ag<n> РН — агрегат № n rg_a_0031_ag1
rg_b_<id> Регістр бухгалтерії — проводки rg_b_0020
rg_b_<id>_ed РБ — субконто (якщо винесено в дочірню таблицю) rg_b_0020_ed
rg_b_<id>_tb<k> РБ — підсумки залишків за рівнем субконто k (0…N) rg_b_0020_tb2
rg_b_<id>_tt<k> РБ — підсумки оборотів за рівнем субконто k rg_b_0020_tt1
rg_b_<id>_tc РБ — обороти між рахунками (кореспонденції) rg_b_0020_tc
rg_c_<id> Регістр розрахунку — рухи rg_c_0040
rg_c_<id>_ap РР — фактичні періоди дії rg_c_0040_ap
rg_c_<id>_rc<m> РР — перерахунок № m rg_c_0040_rc1
rg_<*>_<id>_opt Службова: ТА підсумків, налаштування rg_a_0012_opt
md_* Таблиці метаданих md_register

Колонки полів: d<n> — вимірювання, r<n> — ресурс, a<n> — реквізит, де n — порядковий номер поля в метаописі (стабільний, не перевикористовується після видалення поля). Мапінг «прикладне ім'я ↔ фізична колонка» зберігається в md_register_field.

8.2. Типізація та зберігання значень

8.2.1. Примітивні типи

Тип платформи PostgreSQL MS SQL
Число(p,s) numeric(p,s) numeric(p,s)
Рядок(n) varchar(n) nvarchar(n)
Рядок(необмежена) text nvarchar(max)
Дата / ДатаЧас timestamp(0) datetime2(0)
Булево boolean bit
УНІ uuid uniqueidentifier

8.2.2. Посилальні типи

  • Простий (один тип): одна колонка d<n>_r uuid NOT NULL, зовнішній ключ до таблиці об'єкта. Порожнє посилання = '00000000-0000-0000-0000-000000000000' (а не NULL — щоб зберегти семантику «пустого значення» у ключах підсумків).
  • Складений (кілька типів / примітив + посилання): група колонок:
Колонка Тип Опис
d<n>_t smallint Код типу: 1=Невизначено, 2=Булево, 3=Число, 4=Рядок, 5=Дата, ≥100 = md_type.id посилального типу
d<n>_b boolean Значення, якщо _t=2
d<n>_n numeric(38,10) Значення, якщо _t=3
d<n>_s varchar(N) Значення, якщо _t=4
d<n>_d timestamp(0) Значення, якщо _t=5
d<n>_r uuid Значення, якщо _t≥100

Шаблон:Note

8.2.3. Реєстратор

Завжди складений (будь-який з дозволених документів):

  • rec_t smallint NOT NULL — код типу документа (md_type.id);
  • rec_r uuid NOT NULL — посилання.

FK неможливий (поліморфізм) → цілісність забезпечується платформою + сервісом перевірки (ФТ-41).

8.2.4. Маркери періодів

Константа Значення Використання
MINPERIOD 0001-01-01 00:00:00 Нижня межа
MAXPERIOD 5999-11-01 00:00:00 Рядок «підсумок за всі періоди» в таблицях залишків

8.3. Таблиці метаданих

-- ============ РЕПОЗИТОРІЙ МЕТАДАНИХ ============

CREATE TABLE md_type (                       -- реєстр усіх типів конфігурації
    id            smallint     PRIMARY KEY,
    kind          varchar(20)  NOT NULL,     -- Catalog|Document|Enum|ChartOfAccounts|ChartOfCalcTypes|Register|...
    name          varchar(80)  NOT NULL,
    table_name    varchar(63),
    UNIQUE (kind, name)
);

CREATE TABLE md_register (
    id                   smallint     PRIMARY KEY,          -- = md_type.id
    name                 varchar(80)  NOT NULL UNIQUE,
    synonym              varchar(255),
    comment              text,
    kind                 varchar(12)  NOT NULL
        CHECK (kind IN ('Information','Accumulation','Accounting','Calculation')),
    -- загальні
    write_mode           varchar(24)  NOT NULL DEFAULT 'SubordinateToRecorder'
        CHECK (write_mode IN ('Independent','SubordinateToRecorder')),
    periodicity          varchar(24)  NOT NULL DEFAULT 'Nonperiodical'
        CHECK (periodicity IN ('Nonperiodical','WithinSecond','WithinDay','WithinMonth',
                               'WithinQuarter','WithinYear','ByRecorderPosition')),
    lock_mode            varchar(12)  NOT NULL DEFAULT 'Managed',
    use_rls              boolean      NOT NULL DEFAULT false,
    partition_mode       varchar(20)  NOT NULL DEFAULT 'None',
    partition_step       varchar(10),
    -- РН
    accumulation_kind    varchar(10)  CHECK (accumulation_kind IN ('Balance','Turnover')),
    enable_bal_totals    boolean      NOT NULL DEFAULT true,
    enable_tno_totals    boolean      NOT NULL DEFAULT false,
    tno_totals_period    varchar(6)   CHECK (tno_totals_period IN ('Day','Month')),
    split_totals         boolean      NOT NULL DEFAULT false,
    split_count          smallint     NOT NULL DEFAULT 1,
    aggregates_mode      varchar(10),
    -- РБ
    chart_of_accounts_id smallint     REFERENCES md_type(id),
    correspondence       boolean      NOT NULL DEFAULT true,
    max_extdim_count     smallint     NOT NULL DEFAULT 3,
    -- РР
    calc_types_plan_id   smallint     REFERENCES md_type(id),
    use_action_period    boolean      NOT NULL DEFAULT false,
    base_period_mode     varchar(24)
        CHECK (base_period_mode IN ('None','ByActionPeriod','ByRegistrationPeriod')),
    schedule_register_id smallint     REFERENCES md_register(id),
    schedule_value_field smallint,
    schedule_date_field  smallint,
    -- службове
    physical_table       varchar(63)  NOT NULL,
    struct_version       int          NOT NULL DEFAULT 1,
    CONSTRAINT chk_kind_props CHECK (
        (kind = 'Accumulation' AND accumulation_kind IS NOT NULL) OR
        (kind = 'Accounting'   AND chart_of_accounts_id IS NOT NULL) OR
        (kind = 'Calculation'  AND calc_types_plan_id IS NOT NULL) OR
        (kind = 'Information')
    )
);

CREATE TABLE md_register_field (
    id                smallint    NOT NULL,        -- порядковий номер поля (стабільний)
    register_id       smallint    NOT NULL REFERENCES md_register(id) ON DELETE CASCADE,
    name              varchar(80) NOT NULL,
    synonym           varchar(255),
    role              varchar(12) NOT NULL CHECK (role IN ('Dimension','Resource','Attribute')),
    data_type_expr    text        NOT NULL,        -- серіалізований опис типу (може бути складеним)
    is_composite      boolean     NOT NULL DEFAULT false,
    precision_        smallint,
    scale_            smallint,
    length_           int,
    index_mode        varchar(28) NOT NULL DEFAULT 'None'
        CHECK (index_mode IN ('None','Index','IndexWithAddDimensions')),
    is_master         boolean     NOT NULL DEFAULT false,   -- «Ведуче»
    in_default_filter boolean     NOT NULL DEFAULT false,
    deny_empty        boolean     NOT NULL DEFAULT false,
    use_in_totals     boolean     NOT NULL DEFAULT true,
    -- РБ
    is_balance        boolean     NOT NULL DEFAULT false,
    accounting_flag   varchar(80),
    extdim_flag       varchar(80),
    -- РР
    is_base_dimension boolean     NOT NULL DEFAULT false,
    -- фізика
    column_prefix     varchar(8)  NOT NULL,        -- 'd3','r1','a2'
    PRIMARY KEY (register_id, id),
    UNIQUE (register_id, name)
);

CREATE TABLE md_register_recorder (
    register_id  smallint NOT NULL REFERENCES md_register(id) ON DELETE CASCADE,
    doc_type_id  smallint NOT NULL REFERENCES md_type(id),
    PRIMARY KEY (register_id, doc_type_id)
);

CREATE TABLE md_register_aggregate (
    id           smallint    NOT NULL,
    register_id  smallint    NOT NULL REFERENCES md_register(id) ON DELETE CASCADE,
    name         varchar(80) NOT NULL,
    periodicity  varchar(8)  NOT NULL CHECK (periodicity IN ('Day','Month','Quarter','Year')),
    use_mode     varchar(12) NOT NULL DEFAULT 'Auto',
    is_realtime  boolean     NOT NULL DEFAULT false,
    actual_upto  timestamp(0),
    physical_table varchar(63) NOT NULL,
    PRIMARY KEY (register_id, id)
);
CREATE TABLE md_register_aggregate_dim (
    register_id  smallint NOT NULL,
    aggregate_id smallint NOT NULL,
    field_id     smallint NOT NULL,
    PRIMARY KEY (register_id, aggregate_id, field_id),
    FOREIGN KEY (register_id, aggregate_id) REFERENCES md_register_aggregate(register_id, id) ON DELETE CASCADE
);

CREATE TABLE md_register_recalc (
    id             smallint    NOT NULL,
    register_id    smallint    NOT NULL REFERENCES md_register(id) ON DELETE CASCADE,
    name           varchar(80) NOT NULL,
    physical_table varchar(63) NOT NULL,
    PRIMARY KEY (register_id, id)
);
CREATE TABLE md_register_recalc_dim (
    register_id  smallint NOT NULL,
    recalc_id    smallint NOT NULL,
    name         varchar(80) NOT NULL,
    reg_field_id smallint NOT NULL,          -- вимірювання основного регістру
    column_prefix varchar(8) NOT NULL,
    PRIMARY KEY (register_id, recalc_id, name),
    FOREIGN KEY (register_id, recalc_id) REFERENCES md_register_recalc(register_id, id) ON DELETE CASCADE
);

8.4. Регістр відомостей

8.4.1. Незалежний періодичний (приклад: КурсиВалют)

CREATE TABLE rg_i_0007 (
    period   timestamp(0) NOT NULL,                  -- Період (усічений до дня: WithinDay)
    d1_r     uuid         NOT NULL,                  -- Вимірювання «Валюта»
    r1       numeric(15,4) NOT NULL,                 -- Ресурс «Курс»
    r2       numeric(10,0) NOT NULL DEFAULT 1,       -- Ресурс «Кратність»
    CONSTRAINT pk_rg_i_0007 PRIMARY KEY (period, d1_r)
);
-- Індекс для зрізу останніх у розрізі вимірювання
CREATE INDEX ix_rg_i_0007_d1 ON rg_i_0007 (d1_r, period DESC) INCLUDE (r1, r2);
ALTER TABLE rg_i_0007 ADD CONSTRAINT fk_rg_i_0007_d1
    FOREIGN KEY (d1_r) REFERENCES cat_0003(ref);      -- Довідник.Валюти

8.4.2. Підпорядкований реєстратору

CREATE TABLE rg_i_0008 (
    rec_t    smallint     NOT NULL,
    rec_r    uuid         NOT NULL,
    line_no  int          NOT NULL,
    period   timestamp(0) NOT NULL,
    active   boolean      NOT NULL DEFAULT true,
    d1_r     uuid         NOT NULL,
    r1       numeric(15,2) NOT NULL,
    CONSTRAINT pk_rg_i_0008 PRIMARY KEY (rec_t, rec_r, line_no)
);
CREATE INDEX ix_rg_i_0008_period ON rg_i_0008 (period, rec_t, rec_r, line_no);
CREATE INDEX ix_rg_i_0008_d1     ON rg_i_0008 (d1_r, period) WHERE active;

8.4.3. Незалежний неперіодичний

CREATE TABLE rg_i_0009 (
    d1_r  uuid          NOT NULL,
    d2_r  uuid          NOT NULL,
    r1    varchar(200),
    CONSTRAINT pk_rg_i_0009 PRIMARY KEY (d1_r, d2_r)
);

8.5. Регістр накопичення

8.5.1. Таблиця рухів

-- ТовариНаСкладах: dims = Номенклатура(d1), Склад(d2), Партія(d3, складений), Характеристика(d4)
--                  res  = Кількість(r1), Сума(r2);  attr = Коментар(a1)
CREATE TABLE rg_a_0012 (
    rec_t    smallint      NOT NULL,
    rec_r    uuid          NOT NULL,
    line_no  int           NOT NULL,
    period   timestamp(0)  NOT NULL,
    active   boolean       NOT NULL DEFAULT true,
    mvt      smallint      NOT NULL CHECK (mvt IN (0,1)),   -- 0=Прихід, 1=Витрата
    d1_r     uuid          NOT NULL,
    d2_r     uuid          NOT NULL,
    d3_t     smallint      NOT NULL,                        -- складене вимірювання «Партія»
    d3_r     uuid          NOT NULL,
    d4_r     uuid          NOT NULL,
    r1       numeric(15,3) NOT NULL DEFAULT 0,
    r2       numeric(15,2) NOT NULL DEFAULT 0,
    a1       varchar(200),
    CONSTRAINT pk_rg_a_0012 PRIMARY KEY (rec_t, rec_r, line_no)
) PARTITION BY RANGE (period);

CREATE TABLE rg_a_0012_p2026m07 PARTITION OF rg_a_0012
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- ... інші секції створюються регламентним завданням

CREATE INDEX ix_rg_a_0012_period ON rg_a_0012 (period, rec_t, rec_r, line_no);
CREATE INDEX ix_rg_a_0012_d1     ON rg_a_0012 (d1_r, period, d2_r) WHERE active;   -- Номенклатура (Індексувати з дод. вимірюваннями)
CREATE INDEX ix_rg_a_0012_d2     ON rg_a_0012 (d2_r, period, d1_r) WHERE active;   -- Склад

8.5.2. Таблиця підсумків залишків

CREATE TABLE rg_a_0012_tb (
    period   timestamp(0)  NOT NULL,     -- початок місяця; MAXPERIOD = підсумок за всі періоди
    splitter smallint      NOT NULL DEFAULT 0,
    d1_r     uuid          NOT NULL,
    d2_r     uuid          NOT NULL,
    d3_t     smallint      NOT NULL,
    d3_r     uuid          NOT NULL,
    d4_r     uuid          NOT NULL,
    r1       numeric(20,3) NOT NULL,     -- Кількість, знаковий залишок
    r2       numeric(20,2) NOT NULL,     -- Сума
    CONSTRAINT pk_rg_a_0012_tb PRIMARY KEY (period, d1_r, d2_r, d3_t, d3_r, d4_r, splitter)
);
-- покривний індекс для типового відбору «залишки по складу»
CREATE INDEX ix_rg_a_0012_tb_d2 ON rg_a_0012_tb (d2_r, period, d1_r) INCLUDE (r1, r2);

Семантика: рядок з period = P містить залишок на початок періоду P. Рядок з period = MAXPERIOD — поточний залишок за всі періоди (використовується для запитів «залишки на зараз», найчастіший кейс).

8.5.3. Таблиця підсумків оборотів

CREATE TABLE rg_a_0012_tt (
    period   timestamp(0)  NOT NULL,     -- початок періоду агрегації (день/місяць)
    splitter smallint      NOT NULL DEFAULT 0,
    d1_r     uuid          NOT NULL,
    d2_r     uuid          NOT NULL,
    d3_t     smallint      NOT NULL,
    d3_r     uuid          NOT NULL,
    d4_r     uuid          NOT NULL,
    r1_in    numeric(20,3) NOT NULL DEFAULT 0,   -- Кількість Прихід
    r1_out   numeric(20,3) NOT NULL DEFAULT 0,   -- Кількість Витрата
    r2_in    numeric(20,2) NOT NULL DEFAULT 0,
    r2_out   numeric(20,2) NOT NULL DEFAULT 0,
    CONSTRAINT pk_rg_a_0012_tt PRIMARY KEY (period, d1_r, d2_r, d3_t, d3_r, d4_r, splitter)
);

8.5.4. Службова таблиця налаштувань

CREATE TABLE rg_a_0012_opt (
    id                smallint     PRIMARY KEY DEFAULT 1 CHECK (id = 1),
    totals_actual_to  timestamp(0) NOT NULL,   -- ТА: підсумки розраховані до
    use_bal_totals    boolean      NOT NULL DEFAULT true,
    use_tno_totals    boolean      NOT NULL DEFAULT true,
    min_period        timestamp(0),            -- період першого руху
    last_recalc_at    timestamp(0),
    split_merge_at    timestamp(0)
);

8.5.5. Агрегат (для РН виду «Обороти»)

-- Агрегат «Продажі за місяцями по номенклатурі та складу» (без Партії, без Характеристики)
CREATE TABLE rg_a_0031_ag1 (
    period   timestamp(0)  NOT NULL,
    d1_r     uuid          NOT NULL,
    d2_r     uuid          NOT NULL,
    r1       numeric(20,3) NOT NULL,
    r2       numeric(20,2) NOT NULL,
    CONSTRAINT pk_rg_a_0031_ag1 PRIMARY KEY (period, d1_r, d2_r)
);

8.6. Регістр бухгалтерії

8.6.1. План рахунків (супутні таблиці)

CREATE TABLE coa_0005 (                            -- ПланРахунків.Госпрозрахунковий
    ref          uuid        PRIMARY KEY,
    parent_ref   uuid        REFERENCES coa_0005(ref),
    code         varchar(20) NOT NULL,
    order_code   varchar(40) NOT NULL,             -- впорядкований код для ієрархічних запитів
    name         varchar(150) NOT NULL,
    acc_type     smallint    NOT NULL,             -- 0=Активний,1=Пасивний,2=Активно-пасивний
    off_balance  boolean     NOT NULL DEFAULT false,
    ed_count     smallint    NOT NULL DEFAULT 0,   -- фактична кількість субконто
    fl_qty       boolean     NOT NULL DEFAULT false,  -- ознака обліку «Кількісний»
    fl_cur       boolean     NOT NULL DEFAULT false,  -- ознака обліку «Валютний»
    marked_del   boolean     NOT NULL DEFAULT false
);
CREATE UNIQUE INDEX ux_coa_0005_code ON coa_0005 (code);

CREATE TABLE coa_0005_ed (                         -- ТЧ «ВидиСубконто»
    ref        uuid     NOT NULL REFERENCES coa_0005(ref) ON DELETE CASCADE,
    line_no    int      NOT NULL,
    ed_type_r  uuid     NOT NULL,                  -- ПланВидівХарактеристик.ВидиСубконто
    only_tno   boolean  NOT NULL DEFAULT false,    -- «Тільки обороти»
    sum_flag   boolean  NOT NULL DEFAULT true,     -- «Підсумкова сума»
    fl_qty     boolean  NOT NULL DEFAULT false,    -- ознака обліку субконто «Кількісний»
    PRIMARY KEY (ref, line_no)
);

8.6.2. Таблиця проводок (з кореспонденцією)

-- Госпрозрахунковий: dims = Організація(d1, балансове), Підрозділ(d2, небалансове)
--   res = Сума(r1, балансовий), Кількість(r2, небалансовий), ВалютнаСума(r3, небалансовий)
--   attr = Зміст(a1), Валюта(a2);  max_extdim_count = 3
CREATE TABLE rg_b_0020 (
    rec_t     smallint      NOT NULL,
    rec_r     uuid          NOT NULL,
    line_no   int           NOT NULL,
    period    timestamp(0)  NOT NULL,
    active    boolean       NOT NULL DEFAULT true,

    acc_dt_r  uuid          NOT NULL REFERENCES coa_0005(ref),
    acc_kt_r  uuid          NOT NULL REFERENCES coa_0005(ref),

    -- балансове вимірювання: одне значення на проводку
    d1_r      uuid          NOT NULL,
    -- небалансове вимірювання: окремо для Дт і Кт
    d2_dt_r   uuid          NOT NULL,
    d2_kt_r   uuid          NOT NULL,

    -- субконто Дт (3 слоти, складений тип: ПВХ-значення)
    ed_dt1_t  smallint, ed_dt1_r uuid,
    ed_dt2_t  smallint, ed_dt2_r uuid,
    ed_dt3_t  smallint, ed_dt3_r uuid,
    ed_dt_h   bigint        NOT NULL DEFAULT 0,     -- хеш набору субконто Дт (для JOIN з підсумками)
    -- субконто Кт
    ed_kt1_t  smallint, ed_kt1_r uuid,
    ed_kt2_t  smallint, ed_kt2_r uuid,
    ed_kt3_t  smallint, ed_kt3_r uuid,
    ed_kt_h   bigint        NOT NULL DEFAULT 0,

    -- балансовий ресурс: одне значення
    r1        numeric(20,2) NOT NULL DEFAULT 0,     -- Сума
    -- небалансові ресурси: пара Дт/Кт
    r2_dt     numeric(20,3) NOT NULL DEFAULT 0,     -- Кількість Дт
    r2_kt     numeric(20,3) NOT NULL DEFAULT 0,     -- Кількість Кт
    r3_dt     numeric(20,2) NOT NULL DEFAULT 0,     -- ВалютнаСума Дт
    r3_kt     numeric(20,2) NOT NULL DEFAULT 0,

    a1        varchar(255),
    a2_r      uuid,
    CONSTRAINT pk_rg_b_0020 PRIMARY KEY (rec_t, rec_r, line_no)
) PARTITION BY RANGE (period);

CREATE INDEX ix_rg_b_0020_period  ON rg_b_0020 (period, rec_t, rec_r, line_no);
CREATE INDEX ix_rg_b_0020_acc_dt  ON rg_b_0020 (acc_dt_r, period) WHERE active;
CREATE INDEX ix_rg_b_0020_acc_kt  ON rg_b_0020 (acc_kt_r, period) WHERE active;
CREATE INDEX ix_rg_b_0020_ed_dt   ON rg_b_0020 (ed_dt1_t, ed_dt1_r, period) WHERE active;

Шаблон:Note

8.6.3. Таблиця проводок (без кореспонденції)

CREATE TABLE rg_b_0021 (
    rec_t    smallint     NOT NULL,
    rec_r    uuid         NOT NULL,
    line_no  int          NOT NULL,
    period   timestamp(0) NOT NULL,
    active   boolean      NOT NULL DEFAULT true,
    acc_mvt  smallint     NOT NULL CHECK (acc_mvt IN (0,1)),  -- 0=Дебет, 1=Кредит
    acc_r    uuid         NOT NULL REFERENCES coa_0006(ref),
    d1_r     uuid         NOT NULL,
    ed1_t smallint, ed1_r uuid,
    ed2_t smallint, ed2_r uuid,
    ed3_t smallint, ed3_r uuid,
    ed_h     bigint       NOT NULL DEFAULT 0,
    r1       numeric(20,2) NOT NULL DEFAULT 0,
    CONSTRAINT pk_rg_b_0021 PRIMARY KEY (rec_t, rec_r, line_no)
);

8.6.4. Підсумки РБ

Підсумки будуються на кількох «рівнях субконто» — щоб запит без відбору за субконто не сканував найдетальнішу таблицю.

-- Рівень 0: залишки по рахунку + балансові вимірювання (без субконто)
CREATE TABLE rg_b_0020_tb0 (
    period   timestamp(0)  NOT NULL,
    splitter smallint      NOT NULL DEFAULT 0,
    acc_r    uuid          NOT NULL,
    d1_r     uuid          NOT NULL,               -- Організація (балансове)
    r1       numeric(24,2) NOT NULL,               -- Сума: >0 = дебетовий, <0 = кредитовий залишок
    r2       numeric(24,3) NOT NULL,               -- Кількість
    r3       numeric(24,2) NOT NULL,
    CONSTRAINT pk_rg_b_0020_tb0 PRIMARY KEY (period, acc_r, d1_r, splitter)
);

-- Рівень 1..3: залишки з розшифровкою по субконто (тільки для рахунків з ed_count >= k)
CREATE TABLE rg_b_0020_tb1 (
    period  timestamp(0) NOT NULL, splitter smallint NOT NULL DEFAULT 0,
    acc_r   uuid NOT NULL, d1_r uuid NOT NULL,
    ed1_t   smallint NOT NULL, ed1_r uuid NOT NULL,
    r1 numeric(24,2) NOT NULL, r2 numeric(24,3) NOT NULL, r3 numeric(24,2) NOT NULL,
    CONSTRAINT pk_rg_b_0020_tb1 PRIMARY KEY (period, acc_r, d1_r, ed1_t, ed1_r, splitter)
);
CREATE TABLE rg_b_0020_tb2 (
    period  timestamp(0) NOT NULL, splitter smallint NOT NULL DEFAULT 0,
    acc_r   uuid NOT NULL, d1_r uuid NOT NULL,
    ed1_t smallint NOT NULL, ed1_r uuid NOT NULL,
    ed2_t smallint NOT NULL, ed2_r uuid NOT NULL,
    r1 numeric(24,2) NOT NULL, r2 numeric(24,3) NOT NULL, r3 numeric(24,2) NOT NULL,
    CONSTRAINT pk_rg_b_0020_tb2 PRIMARY KEY (period, acc_r, d1_r, ed1_t, ed1_r, ed2_t, ed2_r, splitter)
);
-- rg_b_0020_tb3 — аналогічно, 3 субконто

-- Обороти (аналогічні рівні): tt0..tt3
CREATE TABLE rg_b_0020_tt0 (
    period   timestamp(0)  NOT NULL,
    splitter smallint      NOT NULL DEFAULT 0,
    acc_r    uuid          NOT NULL,
    d1_r     uuid          NOT NULL,
    r1_dt numeric(24,2) NOT NULL, r1_kt numeric(24,2) NOT NULL,
    r2_dt numeric(24,3) NOT NULL, r2_kt numeric(24,3) NOT NULL,
    r3_dt numeric(24,2) NOT NULL, r3_kt numeric(24,2) NOT NULL,
    CONSTRAINT pk_rg_b_0020_tt0 PRIMARY KEY (period, acc_r, d1_r, splitter)
);

-- Обороти між рахунками (кореспонденції)
CREATE TABLE rg_b_0020_tc (
    period    timestamp(0)  NOT NULL,
    splitter  smallint      NOT NULL DEFAULT 0,
    acc_dt_r  uuid          NOT NULL,
    acc_kt_r  uuid          NOT NULL,
    d1_r      uuid          NOT NULL,
    r1        numeric(24,2) NOT NULL,
    CONSTRAINT pk_rg_b_0020_tc PRIMARY KEY (period, acc_dt_r, acc_kt_r, d1_r, splitter)
);

Правило запису рівнів: для проводки з рахунком, що має ed_count = k, оновлюються таблиці tb0 … tbK (k+1 таблиць). Рахунки без субконто оновлюють лише tb0. Субконто з ознакою «Тільки обороти» не потрапляють у tb*, лише в tt*.

8.7. Регістр розрахунку

-- Нарахування: dims = Співробітник(d1, базове), Підрозділ(d2)
--              res  = Результат(r1), ВідпрацьованоДнів(r2), НормаДнів(r3)
--              attr = Графік(a1)
CREATE TABLE rg_c_0040 (
    rec_t        smallint      NOT NULL,
    rec_r        uuid          NOT NULL,
    line_no      int           NOT NULL,
    period       timestamp(0)  NOT NULL,      -- період реєстрації (початок місяця)
    active       boolean       NOT NULL DEFAULT true,
    reversal     boolean       NOT NULL DEFAULT false,   -- Сторно
    calc_type_r  uuid          NOT NULL,                 -- ВидРозрахунку → pct_0011(ref)
    act_begin    timestamp(0)  NOT NULL,      -- ПеріодДіїПочаток
    act_end      timestamp(0)  NOT NULL,      -- ПеріодДіїКінець
    base_begin   timestamp(0),                -- БазовийПеріодПочаток
    base_end     timestamp(0),                -- БазовийПеріодКінець
    d1_r         uuid          NOT NULL,
    d2_r         uuid          NOT NULL,
    r1           numeric(15,2) NOT NULL DEFAULT 0,
    r2           numeric(5,0)  NOT NULL DEFAULT 0,
    r3           numeric(5,0)  NOT NULL DEFAULT 0,
    a1_r         uuid,
    CONSTRAINT pk_rg_c_0040 PRIMARY KEY (rec_t, rec_r, line_no),
    CONSTRAINT chk_rg_c_0040_act CHECK (act_end >= act_begin)
);
CREATE INDEX ix_rg_c_0040_period ON rg_c_0040 (period, rec_t, rec_r, line_no);
CREATE INDEX ix_rg_c_0040_d1     ON rg_c_0040 (d1_r, period, calc_type_r) WHERE active;
CREATE INDEX ix_rg_c_0040_act    ON rg_c_0040 (d1_r, act_begin, act_end) WHERE active;
CREATE INDEX ix_rg_c_0040_base   ON rg_c_0040 (d1_r, calc_type_r, base_begin, base_end) WHERE active;

-- Фактичні періоди дії (після витіснення). Один запис → 0..N інтервалів.
CREATE TABLE rg_c_0040_ap (
    rec_t       smallint     NOT NULL,
    rec_r       uuid         NOT NULL,
    line_no     int          NOT NULL,
    interval_no smallint     NOT NULL,
    calc_type_r uuid         NOT NULL,
    d1_r        uuid         NOT NULL,        -- денормалізація базового вимірювання для швидкого JOIN
    fact_begin  timestamp(0) NOT NULL,
    fact_end    timestamp(0) NOT NULL,
    CONSTRAINT pk_rg_c_0040_ap PRIMARY KEY (rec_t, rec_r, line_no, interval_no),
    CONSTRAINT fk_rg_c_0040_ap FOREIGN KEY (rec_t, rec_r, line_no)
        REFERENCES rg_c_0040 (rec_t, rec_r, line_no) ON DELETE CASCADE
);
CREATE INDEX ix_rg_c_0040_ap_d1 ON rg_c_0040_ap (d1_r, fact_begin, fact_end);

-- Перерахунок «ПерерахунокЗаБазою»
CREATE TABLE rg_c_0040_rc1 (
    rec_t        smallint NOT NULL,     -- реєстратор запису, який треба перерахувати
    rec_r        uuid     NOT NULL,
    calc_type_r  uuid     NOT NULL,     -- вид розрахунку, що потребує перерахунку
    d1_r         uuid     NOT NULL,     -- вимірювання перерахунку «Співробітник»
    src_rec_t    smallint NOT NULL,     -- реєстратор, що спричинив перерахунок
    src_rec_r    uuid     NOT NULL,
    created_at   timestamp(0) NOT NULL DEFAULT now(),
    CONSTRAINT pk_rg_c_0040_rc1 PRIMARY KEY (rec_t, rec_r, calc_type_r, d1_r)
);
CREATE INDEX ix_rg_c_0040_rc1_src ON rg_c_0040_rc1 (src_rec_t, src_rec_r);

8.8. Зведення: які таблиці породжує кожен тип регістру

Оцінка: для РБ з МаксКількістьСубконто = 3 генерується 10 фізичних таблиць на один регістр.

9. ER-моделі

9.1. ER-модель метаданих (репозиторій)

9.2. ER-модель: регістр накопичення (вид «Залишки»)

9.3. ER-модель: регістр бухгалтерії

9.4. ER-модель: регістр розрахунку

9.5. Зведена концептуальна ER (усі типи)

Diagrams error (with dot command): Warning: flat edge between adjacent nodes one of which has a record shape - replace records with HTML-like labels Edge MDF -> MD Error: lost MD MDF edge

Легенда:

  • (PK*) — ключ для незалежного РВ; (PK**) — для підпорядкованого.
  • Пунктир «реєстратор» — логічний зв'язок без FK (поліморфне посилання, підтримується платформою).
  • Пунктир «генерує DDL» — метадані → фізична схема (не зв'язок даних).

10. Ключові алгоритми

10.1. Запис набору записів (режим «Заміщати»)

def write_record_set(reg: Register, rs: RecordSet, replace: bool = True) -> None:
    """Розділ 7.1, ФТ-01…ФТ-04. Уся операція — в одній транзакції."""
    check_rights(reg, Right.INSERT | Right.UPDATE)              # ФТ-37
    validate(reg, rs)                                           # реєстратор ∈ md_register_recorder;
                                                                # deny_empty; для РБ — Σ Дт = Σ Кт
    with conn.begin():                                          # ФТ-03: одна транзакція
        old = read_for_update(reg, rs.filter)                   # SELECT ... FOR UPDATE

        # АП-2: дельта рахується набором, один раз; тільки active-записи
        delta = aggregate(rs.active_records, sign=+1) + aggregate(old, sign=-1)

        if replace:                                             # ФТ-04
            conn.execute(delete(tbl).where(*rs.filter.clauses))  # 1 запит
        conn.execute(insert(tbl), rs.as_dicts())                 # 1 executemany (АП-1)

        update_totals(reg, delta)                               # -> merge_totals(), розділи 10.2, Е.3

        if reg.kind is RegisterKind.CALCULATION:
            keys = rs.affected_keys | affected_keys(old)
            recompute_actual_periods(reg, keys)                 # розділ 10.4
            register_recalculations(reg, [*rs.records, *old])   # розділ 10.5

10.2. Інкрементальне оновлення підсумків залишків

-- Δ (delta) — тимчасова таблиця: (period_month, d1_r, d2_r, d3_t, d3_r, d4_r, dr1, dr2)
-- де dr = Σ (CASE WHEN mvt = 0 THEN +значення ELSE -значення END)

-- Крок 1. Кумулятивні залишки: рух у місяці M впливає на підсумки всіх періодів > M
--         та на рядок MAXPERIOD.
INSERT INTO rg_a_0012_tb AS t (period, splitter, d1_r, d2_r, d3_t, d3_r, d4_r, r1, r2)
SELECT p.period, :splitter, d.d1_r, d.d2_r, d.d3_t, d.d3_r, d.d4_r, d.dr1, d.dr2
FROM   delta d
CROSS  JOIN LATERAL (
         SELECT period FROM totals_periods
         WHERE period >  d.period_month
           AND period <= (SELECT totals_actual_to FROM rg_a_0012_opt)
         UNION ALL SELECT TIMESTAMP '5999-11-01'      -- MAXPERIOD
       ) p
ORDER BY 1,3,4,5,6,7                                   -- детермінований порядок → без дедлоків
ON CONFLICT (period, d1_r, d2_r, d3_t, d3_r, d4_r, splitter)
DO UPDATE SET r1 = t.r1 + EXCLUDED.r1,
              r2 = t.r2 + EXCLUDED.r2;

-- Крок 2. Прибирання нульових рядків (регламентно, не в транзакції проведення)
DELETE FROM rg_a_0012_tb WHERE r1 = 0 AND r2 = 0 AND period <> TIMESTAMP '5999-11-01';

Шаблон:Note

10.3. Трансляція віртуальної таблиці «Залишки»

-- РегістрНакопичення.ТовариНаСкладах.Залишки(&Період, Склад = &Склад)
WITH boundary AS (
    SELECT COALESCE(MAX(period), TIMESTAMP '0001-01-01') AS p
    FROM   totals_periods
    WHERE  period <= :Період
      AND  period <= (SELECT totals_actual_to FROM rg_a_0012_opt)
),
base AS (                                    -- залишок на межу періоду з підсумків
    SELECT t.d1_r, t.d2_r, t.d3_t, t.d3_r, t.d4_r,
           SUM(t.r1) AS r1, SUM(t.r2) AS r2
    FROM   rg_a_0012_tb t, boundary b
    WHERE  t.period = b.p
      AND  t.d2_r  = :Склад                  -- push-down параметра ВТ
    GROUP BY 1,2,3,4,5
),
tail AS (                                    -- «живі» рухи після межі й до &Період
    SELECT m.d1_r, m.d2_r, m.d3_t, m.d3_r, m.d4_r,
           SUM(CASE WHEN m.mvt = 0 THEN m.r1 ELSE -m.r1 END) AS r1,
           SUM(CASE WHEN m.mvt = 0 THEN m.r2 ELSE -m.r2 END) AS r2
    FROM   rg_a_0012 m, boundary b
    WHERE  m.active
      AND  m.period >  b.p
      AND  m.period <= :Період
      AND  m.d2_r   =  :Склад
    GROUP BY 1,2,3,4,5
)
SELECT d1_r AS "Номенклатура", d2_r AS "Склад",
       d3_t, d3_r AS "Партія", d4_r AS "Характеристика",
       SUM(r1) AS "КількістьЗалишок",
       SUM(r2) AS "СумаЗалишок"
FROM  (SELECT * FROM base UNION ALL SELECT * FROM tail) u
GROUP BY 1,2,3,4,5
HAVING SUM(r1) <> 0 OR SUM(r2) <> 0;         -- нульові залишки не повертаються

Оптимізація для найчастішого випадку: якщо :Період ≥ ТА і в регістрі немає рухів у майбутньому — base читається з рядка MAXPERIOD одним index seek, tail = ∅.

10.4. Розрахунок фактичних періодів дії (витіснення)

def recompute_actual_periods(reg: Register, keys: set[DimKey]) -> None:
    """Розділ 7.5.1, ФТ-22…ФТ-24. Тут — оркестрація; сам алгоритм — Додаток Е.5."""
    tbl = schema.movements(reg)
    ap = schema.actual_periods(reg)                     # rg_c_<id>_ap

    # 1. Читання всіх записів зачеплених ключів — ОДИН запит (АП-1)
    records = conn.execute(
        select(tbl).where(tbl.c.active, dim_key_in(tbl, keys))
    ).all()

    # 2. Перерахунок інтервалів. Гарячий шлях: бюджет ≤ 3 c (АП-4),
    #    реалізація за Protocol-інтерфейсом (АП-3) — див. Е.5.
    #    Пріоритет: A витісняється B, якщо B ∈ ПВР[A].ВитісняючіВидиРозрахунку
    intervals = displacement_engine.compute(records, displaces=plan.displaces)

    # 3. Заміщення фактичних періодів — два запити на весь набір, не на запис
    conn.execute(delete(ap).where(record_key_in(ap, intervals.keys())))
    conn.execute(insert(ap), [
        {"rec_t": k.rec_t, "rec_r": k.rec_r, "line_no": k.line_no,
         "interval_no": n, "calc_type_r": k.calc_type, "d1_r": k.base_dim,
         "fact_begin": begin, "fact_end": end}
        for k, ivs in intervals.items()
        for n, (begin, end) in enumerate(ivs)            # 1 запис -> 0..N інтервалів
    ])

Складність: O(|Z|² ) на ключ. Обмеження: якщо |Z| > 500 для одного ключа — попередження в журналі та перехід на алгоритм замітання (sweep line) O(|Z| log |Z|).

10.5. Реєстрація перерахунків

def register_recalculations(reg: Register, changed: Sequence[CalcRecord]) -> None:
    """Розділ 7.5.3, ФТ-26. АП-2: пошук залежних виконує СКБД, а не цикл у Python."""
    q = schema.movements(reg).alias("q")
    b = schema.calc_plan_base(reg).alias("b")           # ТЧ «БазовіВидиРозрахунку» ПВР

    for recalc in reg.recalculations:
        rc = schema.recalc_table(reg, recalc)           # rg_c_<id>_rc<n>
        src = values_of(changed).alias("src")           # змінені записи як VALUES-таблиця

        # інтервальний предикат залежно від режиму базового періоду
        if reg.base_period == "ByActionPeriod":
            in_base = and_(src.c.act_begin <= q.c.base_end,
                           src.c.act_end >= q.c.base_begin)     # перетин періодів
        else:                                                    # ByRegistrationPeriod
            in_base = src.c.period.between(q.c.base_begin, q.c.base_end)

        dependent = (
            select(q.c.rec_t, q.c.rec_r, q.c.calc_type_r,
                   *[q.c[c] for c in recalc.dim_columns],
                   src.c.rec_t.label("src_rec_t"), src.c.rec_r.label("src_rec_r"))
            .select_from(
                src.join(b, b.c.base_type_r == src.c.calc_type_r)   # z — базовий для q
                   .join(q, q.c.calc_type_r == b.c.ref))
            .where(q.c.active, in_base,
                   *[q.c[c] == src.c[c] for c in recalc.dim_columns],
                   tuple_(q.c.rec_t, q.c.rec_r) != tuple_(src.c.rec_t, src.c.rec_r))
            .distinct()
        )

        stmt = pg_insert(rc).from_select(list(rc.columns.keys()), dependent)
        conn.execute(stmt.on_conflict_do_nothing())     # 1 запит на перерахунок (АП-1)

10.6. Зріз останніх

-- РегістрВідомостей.КурсиВалют.ЗрізОстанніх(&Дата, Валюта В (&Список))
SELECT DISTINCT ON (r.d1_r)
       r.d1_r AS "Валюта", r.period AS "Період", r.r1 AS "Курс", r.r2 AS "Кратність"
FROM   rg_i_0007 r
WHERE  r.period <= :Дата
  AND  r.d1_r = ANY (:Список)
ORDER  BY r.d1_r, r.period DESC;       -- використовує ix_rg_i_0007_d1 (d1_r, period DESC)
-- Для MS SQL: ROW_NUMBER() OVER (PARTITION BY d1_r ORDER BY period DESC) = 1
--             або OUTER APPLY (SELECT TOP 1 ...) для великих списків.

11. Нефункціональні вимоги

11.1. Продуктивність

Еталонний стенд: 16 vCPU, 128 ГБ RAM, NVMe, PostgreSQL 15, БД 1,5 ТБ.

Операція Обсяг Ціль (P95)
НФВ-01 Залишки на поточну дату, відбір за 1 вимірюванням 1 млрд рухів, 20 млн рядків підсумків ≤ 200 мс
НФВ-02 Залишки на довільну дату в межах ТА те саме ≤ 400 мс
НФВ-03 Обороти за 12 місяців із групуванням 1 млрд рухів ≤ 2 с
НФВ-04 Проведення документа зі 100 рядками рухів (3 регістри) ≤ 300 мс
НФВ-05 Оборотно-сальдова відомість по всіх рахунках за місяць 200 млн проводок ≤ 5 с
НФВ-06 Розрахунок ЗП: 10 000 співробітників × 8 видів розрахунку ≤ 10 хв
НФВ-07 Зріз останніх по 5 000 елементів вимірювання 50 млн записів РВ ≤ 500 мс
НФВ-08 Пропускна здатність проведення ≥ 50 док/с (10 паралельних сеансів)
НФВ-09 Перерахунок підсумків РН 1 млрд рухів ≤ 4 год (у вікні обслуговування)

Шаблон:Note

11.2. Масштабованість і обсяги

Параметр Значення
Макс. записів в одному регістрі 1010
Макс. кількість регістрів у конфігурації 2 000
Макс. одночасних сеансів проведення 200
Секціонування Обов'язкове для таблиць > 100 млн записів

11.3. Надійність і цілісність

  • НФВ-10. Рухи та реєстратор змінюються в одній транзакції БД; часткове проведення неможливе.
  • НФВ-11. Аварійне завершення сервера не залишає підсумки неузгодженими (гарантується транзакцією).
  • НФВ-12. Сервіс перевірки цілісності (ФТ-41) виконується регламентно; розбіжність підсумків = інцидент P1.

11.4. Супроводжуваність

  • НФВ-13. Реструктуризація регістру (додавання ресурсу/реквізиту) для таблиці ≤ 100 млн записів — ≤ 30 хв.
  • НФВ-14. Додавання вимірювання вимагає перебудови підсумків; система повинна попереджати про оцінку часу до початку операції.
  • НФВ-15. Усі DDL-операції — ідемпотентні та скриптовані (можливість застосувати вручну).

11.5. Сумісність

  • НФВ-16. PostgreSQL 14+ та MS SQL Server 2019+ — обидві платформи з однаковою функціональністю.
  • НФВ-17. Генератор DDL ізольований у шар «діалект СКБД».

12. Міграція існуючих структур K2 ERP

12.1. Етапи

Етап Результат
М1 Інвентаризація Реєстр наявних «регістроподібних» таблиць K2 з класифікацією за типом-мішенню (РВ/РН/РБ/РР).
М2 Мапінг Для кожної таблиці — метаопис цільового регістру + правила перетворення колонок.
М3 Створення регістрів Генерація метаданих і DDL у тестовому контурі.
М4 Перенесення даних ETL: історичні дані → рухи, з призначенням реєстратора (реальний документ або службовий «ВведенняЗалишків»).
М5 Побудова підсумків Повний перерахунок.
М6 Звірка Порівняння залишків/оборотів «стара система vs нова» на контрольних датах; допуск розбіжності = 0.
М7 Перемикання коду Заміна прямих SQL-звернень на віртуальні таблиці; період паралельної роботи ≥ 1 звітний період.
М8 Виведення з експлуатації Видалення старих таблиць після 2 закритих періодів.

12.2. Проблемні випадки

Випадок Рішення
Історичні записи без документа-джерела Службовий документ ВведенняЗалишків (один на період/розділ).
Записи з дублями за ключем (для незалежного РВ) Дедуплікація з протоколом; конфлікти — на ручний розбір.
Дробові періоди / некоректні дати (NULL, 1900-01-01) Нормалізація до MINPERIOD; протокол.
Прикладний код, що пише в таблиці напряму Заборона на рівні прав БД після М7; аудит звернень на етапі М3–М6.
Аналітика, відсутня в цільовій моделі Перенесення в реквізити (не у вимірювання) або відмова від міграції з обґрунтуванням.

13. Приймальні випробування

13.1. Функціональні тести (витяг)

ID Сценарій Очікуваний результат
ФТ-Т-01 Створити РВ періодичний незалежний, записати 2 записи з однаковим ключем Другий запис заміщує перший; у таблиці 1 рядок
ФТ-Т-02 Провести документ → 5 рухів РН 5 записів у rg_a_*; підсумки MAXPERIOD змінилися на суму дельти
ФТ-Т-03 Скасувати проведення 0 записів; підсумки повернулися до вихідних значень
ФТ-Т-04 Видалити документ-реєстратор Рухи видалені каскадно; перевірка цілісності — 0 помилок
ФТ-Т-05 Записати рух заднім числом (−6 міс.) Залишки на всі дати після руху коректні (звірка з повним перерахунком)
ФТ-Т-06 Установити Активність = Хиба Запис не впливає на віртуальні таблиці; підсумки скориговані
ФТ-Т-07 РБ: провести незбалансовану проводку (Дт ≠ Кт за балансовим ресурсом) Помилка проведення, транзакція відкочена
ФТ-Т-08 РБ: ОСВ по рахунку з 3 субконто Підсумки за рівнями 0–3 узгоджені між собою (згортка tb3 → tb0)
ФТ-Т-09 РР: сценарій «Оклад + Відпустка + Лікарняний» (розд. 7.5.1) Фактичні періоди дії відповідають еталону; «Оклад» дає 2 інтервали
ФТ-Т-10 РР: зміна базового запису У таблиці перерахунку з'явився запис для залежного документа
ФТ-Т-11 Паралельне проведення 2 документів за різними складами Обидва проходять без очікування (розділення підсумків увімкнено)
ФТ-Т-12 Паралельне проведення 2 документів за одним складом і одним товаром з контролем залишків Один чекає, другий проходить; від'ємного залишку немає
ФТ-Т-13 Перерахунок підсумків після хаотичних 106 операцій Підсумки збігаються з повним перерахунком «з нуля» до копійки
ФТ-Т-14 Реструктуризація: додати ресурс до РН із 10 млн записів Дані збережені, підсумки перебудовані, час ≤ НФВ-13
ФТ-Т-15 RLS: користувач з обмеженням по організації Віртуальні таблиці повертають лише дозволені дані

13.2. Навантажувальні тести

ID Тест Критерій
НТ-01 Генерація 1 млрд рухів РН, заміри НФВ-01…НФВ-03 Досягнення цілей
НТ-02 Профіль «90 % рухів поточним періодом, 10 % заднім числом» — порівняння двох стратегій підсумків (розд. 10.2) Обґрунтований вибір Рішення №3
НТ-03 Пікове проведення 50 док/с протягом 1 год Відсутність дедлоків; P95 ≤ НФВ-04
НТ-04 РБ: субконто-слоти vs дочірня таблиця, 200 млн проводок Обґрунтований вибір Рішення №2
НТ-05 Розрахунок ЗП на 10 000 співробітників НФВ-06
НТ-06 Деградація за 24 год безперервної роботи Приріст часу відгуку ≤ 15 %

14. Етапи робіт та оцінка

Етап Зміст Результат Оцінка, чол.-міс. Залежності
Е0 Обстеження платформи, верифікація припущень П1–П6, ескізний проєкт Затверджений архітектурний ескіз, уточнене ТЗ 1,5
Е1 Метамодель + репозиторій + генератор DDL + конструктор (базовий) Створення регістру будь-якого типу з генерацією схеми 3 Е0
Е2 Регістр відомостей: рантайм, набори записів, зрізи, форми РВ у продуктиві 2,5 Е1
Е3 Регістр накопичення: рухи, підсумки, ТА, віртуальні таблиці РН у продуктиві 5 Е2
Е4 Механізм проведення документів, блокування, оперативне/неоперативне проведення Транзакційне проведення 2,5 Е3
Е5 Розширення мови запитів (віртуальні таблиці, транслятор, push-down) Запити до ВТ 3,5 Е3
Е6 Регістр бухгалтерії + план рахунків + субконто + підсумки за рівнями РБ у продуктиві 6 Е4, Е5
Е7 Регістр розрахунку + ПВР + витіснення + база + графіки + перерахунки РР у продуктиві 6,5 Е4, Е5
Е8 Агрегати, розділення підсумків, порадник агрегатів Оптимізація 2,5 Е3
Е9 Права доступу, RLS, аудит, сервіси цілісності Безпека та адміністрування 2 Е6, Е7
Е10 Реструктуризація, міграція, інструменти звірки Інструменти міграції 2 Е6, Е7
Е11 Навантажувальне тестування, оптимізація, документація, профілювання Python + бенчмарки гарячих шляхів Приймальні випробування 4,5 усі
РАЗОМ 41,5

Шаблон:Note

Мінімальний життєздатний обсяг (MVP): Е0–Е5 (РВ + РН + проведення + запити) ≈ 18 чол.-міс. — покриває ~70 % прикладних сценаріїв K2 ERP.

14.1. Вплив вибору Python на оцінку

Етап Було (мова не визначена) Стало (Python) Δ Причина
Е1 (метамодель, DDL, конструктор) 4 3 −1 SQLAlchemy Core закриває генерацію DDL і діалекти СКБД; Pydantic v2 — валідацію DSL. Разом ~30 % етапу — готове.
Е5 (мова запитів, транслятор) 4 3,5 −0,5 lark (граматика) + SQLAlchemy Core expression language замість власного AST→SQL.
Е7 (регістр розрахунку) 6 6,5 +0,5 Ізоляція витіснення за інтерфейсом (АП-3) + резерв на нативну реалізацію.
Е10 (реструктуризація, міграція) 2,5 2 −0,5 Alembic покриває автогенерацію ALTER.
Е11 (навантаження, оптимізація) 3 4,5 +1,5 Профілювання Python, бенчмарки бюджету АП-4, ймовірні Cython/Rust-модулі, wheels у CI.
РАЗОМ 41,5 41,5 0 Економія на інфраструктурному коді компенсує витрати на оптимізацію.

Висновок: Python — нейтральний за трудомісткістю для цього проєкту. Він здешевлює те, чого тут багато (метадані, генерація схеми, абстракція діалектів, міграції), і дорожчає те, чого тут мало (гарячі CPU-цикли). Ризик зміщується з «не встигнемо написати» на «не вкладемося в бюджет часу виконання» — тому бенчмарки гарячих шляхів (АП-4) вводяться в CI з етапу Е3, а не наприкінці.

15. Ризики

ID Ризик Ймов. Вплив Пом'якшення
Р1 У K2 ERP немає власної мови запитів → віртуальні таблиці нікуди вбудовувати Сер. Крит. Верифікувати на Е0. План «Б»: віртуальні таблиці як параметризовані view/TVF + шар ORM.
Р2 Конкуренція за таблицю підсумків на піках проведення Вис. Вис. Розділення підсумків (сплітери), НТ-03, детермінований порядок MERGE.
Р3 Рухи «заднім числом» роблять оновлення підсумків O(N періодів) Вис. Сер. Рішення №3 (dirty months + регламентний перерахунок), НТ-02.
Р4 «Широка» схема субконто → реструктуризація при зміні ліміту Сер. Сер. Зафіксувати ліміт 5 із запасом; НТ-04; план «Б» — дочірня таблиця.
Р5 Механізм витіснення РР — найскладніший і найпомилковіший вузол Вис. Вис. Виділити в окремий модуль з еталонним набором з 50+ тест-кейсів; property-based тестування.
Р6 Обсяг Е6+Е7 недооцінено (досвід 1С: це роки розробки) Вис. Вис. MVP-підхід: спершу Е0–Е5, рішення про Е6/Е7 — після ретроспективи.
Р7 Різна поведінка PostgreSQL і MS SQL (плани, MERGE, ізоляція) Сер. Сер. Єдиний набір тестів на обох СКБД у CI від Е1.
Р8 Міграція історичних даних без реєстраторів Вис. Сер. Службовий документ «ВведенняЗалишків»; етап звірки М6 з нульовим допуском.
Р9 Патентні / ліцензійні претензії щодо копіювання механізмів Низ. Вис. Юридична експертиза до Е1: реалізація концепцій обліку (загальновідомих), а не коду/API; уникати дослівного копіювання найменувань API та документації.
Р10 Гарячі шляхи (витіснення, розрахунок ЗП) на чистому Python не вкладаються в бюджет АП-4 Сер. Сер. Ізоляція за Protocol-інтерфейсом (АП-3) + бенчмарк у CI від Е7; готовність до Cython/Rust; альтернатива для витіснення — daterange+GiST у PostgreSQL.
Р11 Динамічна типізація в ядрі метаданих → помилки типів спливають у рантаймі на продуктиві Вис. Сер. mypy --strict як обов'язковий gate у CI; Pydantic v2 на всіх межах; генерація .pyi-стабів для прикладного коду (ФТ-К-07).
Р12 GIL обмежує вертикальне масштабування сервера застосунків Сер. Низ. АП-5: asyncio для IO + мультипроцесний пул. Розрахунок у 4.4.2 показує запас: 50 док/с ≈ 50 % одного ядра.
Р13 Нативні розширення (Cython/Rust) ускладнюють збірку, кросплатформність і пошук інженерів Низ. Низ. Вводяться тільки за результатами бенчмарку; wheels під усі цільові платформи в CI; питання В13.
Р14 Виконання прикладного Python-коду в процесі сервера = виконання довільного коду з правами сервера (П8) Вис. Крит. Питання В12 до Е0. Варіанти: (а) довірена модель — конфігурацію пише лише вендор/партнер; (б) ізоляція процесами + seccomp/AppArmor; (в) обмежений DSL замість повного Python. У 1С вбудована мова існує в т.ч. з цієї причини.

Шаблон:Note

16. Додатки

Додаток А. Матриця відповідності «1С ↔ K2 ERP»

Об'єкт / поняття 1С Пропонований аналог K2 ERP Примітка
РегистрСведений Регістри.Відомостей.* Повний паритет
РегистрНакопления Регістри.Накопичення.* Повний паритет
РегистрБухгалтерии Регістри.Бухгалтерії.* Субконто зберігаються інакше (слоти)
РегистрРасчета Регістри.Розрахунку.* Повний паритет
Регистратор Реєстратор
ВидДвижения ВидРуху
Итоги Підсумки
Точка актуальности итогов ТА підсумків
Разделение итогов Розділення підсумків (splitter)
Агрегаты Агрегати
Субконто / ПланВидовХарактеристик Субконто / ПланВидівХарактеристик
ПланВидовРасчета ПланВидівРозрахунку
Перерасчет Перерахунок
ПериодДействия / Вытеснение ПеріодДії / Витіснення
Границя послідовності Не реалізується Механізм визнано застарілим; замість нього — перепроведення за списком «брудних» документів
РегистрыСведений з режимом «ПоПозицииРегистратора» ПоПозиціїРеєстратора Реалізується як (period, rec_t, rec_r, line_no) у ключі

Додаток Б. Альтернатива зберігання субконто (план «Б» до Рішення №2)

-- Дочірня таблиця «ключ-значення» замість слотів
CREATE TABLE rg_b_0020_ed (
    rec_t     smallint NOT NULL,
    rec_r     uuid     NOT NULL,
    line_no   int      NOT NULL,
    side      smallint NOT NULL CHECK (side IN (0,1)),   -- 0=Дт, 1=Кт
    ed_no     smallint NOT NULL,                          -- порядковий номер субконто рахунка
    ed_type_r uuid     NOT NULL,                          -- вид субконто
    val_t     smallint NOT NULL,
    val_r     uuid,
    val_n     numeric(38,10),
    val_s     varchar(150),
    val_d     timestamp(0),
    PRIMARY KEY (rec_t, rec_r, line_no, side, ed_no),
    FOREIGN KEY (rec_t, rec_r, line_no) REFERENCES rg_b_0020 (rec_t, rec_r, line_no) ON DELETE CASCADE
);
CREATE INDEX ix_rg_b_0020_ed_val ON rg_b_0020_ed (ed_type_r, val_t, val_r);
Критерій Слоти (Рішення №2) Дочірня таблиця (план «Б»)
Відбір за 1 субконто Index seek по колонці JOIN + семі-з'єднання
Відбір за 3 субконто 3 предикати в одному скані 3 JOIN
Зміна МаксКількістьСубконто ALTER TABLE (реструктуризація) Без змін схеми
Розмір рядка проводки +48 байт × 2 (Дт/Кт) Базовий
Складність генератора DDL Вища Нижча
Рекомендація Обрано за замовчуванням Резерв за результатами НТ-04

Додаток В. Перелік вимог (реєстр трасування)

ID Розділ Коротко Пріоритет Тест
ФТ-01…ФТ-07 7.1 Набори записів, проведення Must ФТ-Т-02…04
ФТ-08…ФТ-10 7.2 Активність Must ФТ-Т-06
ФТ-11…ФТ-13 7.3 Віртуальні таблиці Must ФТ-Т-08, НТ-01
ФТ-14…ФТ-18 7.4 Підсумки, ТА Must ФТ-Т-05, ФТ-Т-13
ФТ-19…ФТ-21 7.4.4 Агрегати Should НТ-01
ФТ-22…ФТ-30 7.5 Механіка РР Must (для Е7) ФТ-Т-09, ФТ-Т-10
ФТ-31…ФТ-36 7.7 Блокування Must ФТ-Т-11, ФТ-Т-12, НТ-03
ФТ-37…ФТ-39 7.8 Права, RLS Must ФТ-Т-15
ФТ-40…ФТ-42 7.9 Аудит, цілісність Should ФТ-Т-04
ФТ-К-01…06 6.8.4 Конструктор Should
НФВ-01…НФВ-09 11.1 Продуктивність Must НТ-01…НТ-06
НФВ-10…НФВ-12 11.3 Цілісність Must ФТ-Т-13
НФВ-13…НФВ-17 11.4–11.5 Супровід, сумісність Should ФТ-Т-14

Додаток Г. Відкриті питання

Питання Кому Термін
В1 Чи існує в K2 ERP мова запитів / транслятор у SQL? (визначає Р1 та обсяг Е5) Архітектор платформи До Е0
В2 Формат ідентифікації об'єктів: UUID чи сурогатний int? Архітектор платформи До Е0
В3 Чи потрібна мілісекундна дискретність періоду? Замовник До Е1
В4 Максимальна кількість субконто: 3 чи 5? Головний бухгалтер / методолог До Е6
В5 Чи потрібен механізм «Границя послідовності»? (пропонується відмова) Методолог До Е3
В6 Пріоритет типів регістрів: чи достатньо MVP (РВ + РН) для першого релізу? Замовник До Е0
В7 Правова оцінка ризику Р9 Юридична служба До Е1
В8 Цільова СКБД для першої черги: тільки PostgreSQL чи одразу обидві? Архітектор / ІТ-експлуатація До Е1
В9 Міжпланова база розрахунку: чи потрібна можливість брати базу з іншого регістру розрахунку (Утримання ← ОсновніНарахування)? Якщо так — ФТ-25 і md_register потребують розширення полем base_register_id (див. Д.7.2) Методолог ЗП / архітектор До Е7
В10 Чи прийнятна поведінка підпорядкованого РВ без контролю унікальності за (Період + Вимірювання)? (сумісність із 1С vs. захист від дублів — див. Д.3.2, антипатерн А-6) Методолог До Е2
В11 Конфігуратор: web (FastAPI + React) чи desktop (PySide6/Qt)? Макети розділу 6.8 намальовані як desktop Замовник / UX До Е1
В12 Ключове. Прикладний код — повноцінний Python чи обмежений DSL? Повний Python у процесі сервера = виконання довільного коду з правами сервера (ризик Р14) + неможливість статично перевірити конфігурацію партнера Архітектор + служба безпеки До Е0
В13 Чи допустимі нативні розширення (Cython/Rust) у складі постачання? Якщо ні — потрібен план «Б» для витіснення (SQL на daterange) ІТ-експлуатація До Е7
В14 Цільова версія: 3.12 чи 3.13? Чи розглядається free-threaded build (PEP 703) як шлях зняття Р12? Архітектор До Е1
В15 Мова ідентифікаторів прикладного API: кирилиця (rs.new(Номенклатура=…)) чи транслітерація/англійська? Впливає на ФТ-К-07 і читабельність Замовник / методолог До Е2

Додаток Д. Каталог прикладів регістрів

Додаток містить прикладні приклади регістрів усіх типів і підвидів. Для кожного наведено: декларацію (DSL), пояснення проєктних рішень, приклад даних у фізичних таблицях і типовий запит. Приклади призначені для: (а) перевірки повноти метамоделі на етапі Е0; (б) формування набору приймальних тестів; (в) навчання прикладних розробників.

Д.1. Як обрати тип регістру (дерево рішень)

Контрольні питання при проєктуванні будь-якого регістру:

  1. Чи можна відповісти на всі прикладні питання лише полями цього регістру, без з'єднання з документами? Якщо ні — бракує реквізиту (не вимірювання!).
  2. Яка кардинальність таблиці підсумків = ∏(кількість значень вимірювань) × кількість періодів? Якщо > 108 — переглянути склад вимірювань.
  3. Чи потрібно фільтрувати/групувати за цим полем у звітах? Так → вимірювання. Ні, лише показувати → реквізит.
  4. Чи підсумовується поле? Так → ресурс. Ні → реквізит або вимірювання.

Д.2. Зведений каталог

Регістр Тип Підвид / режим Вимірювання Ресурси Розділ
1 КурсиВалют РВ Періодичний (день), незалежний Валюта Курс, Кратність Д.3.1
2 ЦіниНоменклатури РВ Періодичний (день), підпорядкований Номенклатура, Характеристика, ТипЦін Ціна Д.3.2
3 ШтрихкодиНоменклатури РВ Неперіодичний, незалежний Штрихкод Номенклатура, Характеристика, Упаковка Д.3.3
4 ГрафікРоботи РВ Неперіодичний, незалежний ВидГрафіка, ДатаГрафіка Значення, Годин Д.3.4
5 КадровіДані РВ По позиції реєстратора, підпорядкований Співробітник Підрозділ, Посада, Ставка, ВидЗайнятості Д.3.5
6 НалаштуванняКористувачів РВ Неперіодичний, незалежний Користувач, Налаштування Значення (складений) Д.3.6
7 ТовариНаСкладах РН Залишки Номенклатура, Склад, Партія, Характеристика Кількість, Сума Д.4.1
8 ВзаєморозрахункиЗКонтрагентами РН Залишки Організація, Контрагент, Договір, Замовлення Сума, СумаВал Д.4.2
9 ТовариВРезерві РН Залишки Номенклатура, Склад, Замовлення, Характеристика Кількість Д.4.3
10 ГрошовіКоштиБезготівкові РН Залишки Організація, БанківськийРахунок Сума, СумаВал Д.4.4
11 Продажі РН Обороти (місяць) + агрегати Номенклатура, Контрагент, Договір, Менеджер, Склад Кількість, Сума, СумаБезПДВ, Собівартість Д.5.1
12 ВитратиЗаСтаттями РН Обороти (місяць) Організація, Підрозділ, СтаттяВитрат, Проєкт Сума Д.5.2
13 Госпрозрахунковий РБ З кореспонденцією Організація (бал.), Підрозділ (небал.) Сума (бал.), Кількість, ВалютнаСума Д.6.1
14 ПодатковийОблік РБ Без кореспонденції Організація, ВидРізниці Сума Д.6.2
15 Бюджетування РБ З кореспонденцією Сценарій, ЦФВ, Проєкт Сума Д.6.3
16 ОсновніНарахування РР Період дії + база за періодом дії Співробітник (баз.), Підрозділ Результат, ВідпрацьованоДнів, НормаДнів Д.7.1
17 Утримання РР Без періоду дії, база за періодом реєстрації Співробітник (баз.), Підрозділ Результат, БазаОподаткування Д.7.2

Д.3. Регістри відомостей

Д.3.1. КурсиВалют — періодичний, незалежний

Задача: зберігати історію курсів валют; отримувати курс на будь-яку дату.

register:
  name: КурсиВалют
  kind: Information
  synonym: "Курси валют"
  periodicity: WithinDay            # один курс на валюту на день
  writeMode: Independent            # курси вводяться вручну/завантажуються з НБУ
  dimensions:
    - { name: Валюта, type: "Довідник.Валюти", master: true, denyEmpty: true }
  resources:
    - { name: Курс,      type: "Число(15,4)", denyEmpty: true }
    - { name: Кратність, type: "Число(10,0)", default: 1 }

Проєктні рішення:

  • Independent — курс не породжується документом, він є зовнішнім фактом.
  • master: true для Валюти — при видаленні валюти курси видаляються; регістр видно у формі елемента довідника.
  • Періодичність WithinDay, а не WithinSecond — повторний запис курсу за той самий день замістить попередній, а не створить дубль.

Дані у таблиці rg_i_0007:

period d1_r (Валюта) r1 (Курс) r2 (Кратність)
2026-07-15 00:00:00 USD 41.8200 1
2026-07-15 00:00:00 EUR 45.6100 1
2026-07-16 00:00:00 USD 41.8500 1
2026-07-17 00:00:00 USD 41.9000 1
2026-07-17 00:00:00 EUR 45.7300 1

Запит (курс на дату документа):

ВИБРАТИ Курси.Курс, Курси.Кратність
З РегістрВідомостей.КурсиВалют.ЗрізОстанніх(&ДатаДокумента, Валюта = &Валюта) ЯК Курси
# або через менеджер
курс = Registers.information("КурсиВалют").get_last(doc.Дата, {"Валюта": doc.Валюта})
сума_грн: Decimal = doc.СумаВалюти * курс.Курс / курс.Кратність

Д.3.2. ЦіниНоменклатури — періодичний, підпорядкований реєстратору

Задача: зберігати історію цін; ціна встановлюється наказом (документом), який можна скасувати.

register:
  name: ЦіниНоменклатури
  kind: Information
  synonym: "Ціни номенклатури"
  periodicity: WithinDay
  writeMode: SubordinateToRecorder   # ← ключова відмінність від КурсиВалют
  dimensions:
    - { name: Номенклатура,   type: "Довідник.Номенклатура", master: true, index: true }
    - { name: Характеристика, type: "Довідник.ХарактеристикиНоменклатури", nullable: true }
    - { name: ТипЦін,         type: "Довідник.ТипиЦін", master: true, denyEmpty: true }
  resources:
    - { name: Ціна, type: "Число(15,2)", denyEmpty: true }
  attributes:
    - { name: Валюта,   type: "Довідник.Валюти" }
    - { name: Одиниця,  type: "Довідник.ОдиниціВиміру" }
  recorders:
    - Документ.ВстановленняЦінНоменклатури

Проєктні рішення:

  • SubordinateToRecorder — потрібна юридична прив'язка ціни до наказу, можливість скасувати наказ і «відкотити» ціни одним рухом.
  • Валюта і Одиницяреквізити, а не вимірювання: за ними не фільтрують і не будують зрізи, вони визначаються типом цін. Помилка новачка — зробити Валюту вимірюванням, що подвоїть кількість записів і зламає зріз останніх.
  • Характеристика дозволяє порожнє значення — для номенклатури без характеристик.

Дані у таблиці rg_i_0010:

rec_t rec_r line_no period active d1_r (Номенкл.) d2_r (Характ.) d3_r (ТипЦін) r1 (Ціна) a1_r (Валюта)
15 a3f…01 1 2026-07-01 true Кава Lavazza 1кг (порожня) Роздрібна 480.00 UAH
15 a3f…01 2 2026-07-01 true Кава Lavazza 1кг (порожня) Оптова 410.00 UAH
15 a3f…01 3 2026-07-01 true Чай Ahmad 100г (порожня) Роздрібна 95.00 UAH
15 b7c…04 1 2026-07-15 true Кава Lavazza 1кг (порожня) Роздрібна 495.00 UAH

Шаблон:Note

Д.3.3. ШтрихкодиНоменклатури — неперіодичний, незалежний

Задача: за штрихкодом знайти номенклатуру. Класичний приклад «вимірювання = те, за чим шукаємо; ресурс = те, що знаходимо».

register:
  name: ШтрихкодиНоменклатури
  kind: Information
  periodicity: Nonperiodical
  writeMode: Independent
  dimensions:
    - { name: Штрихкод, type: "Рядок(30)", denyEmpty: true }     # ← ключ пошуку
  resources:
    - { name: Номенклатура,   type: "Довідник.Номенклатура", denyEmpty: true }
    - { name: Характеристика, type: "Довідник.ХарактеристикиНоменклатури", nullable: true }
    - { name: Упаковка,       type: "Довідник.УпаковкиНоменклатури", nullable: true }
CREATE TABLE rg_i_0011 (
    d1_s  varchar(30) NOT NULL,      -- Штрихкод
    r1_r  uuid        NOT NULL,      -- Номенклатура
    r2_r  uuid        NOT NULL,      -- Характеристика
    r3_r  uuid        NOT NULL,      -- Упаковка
    CONSTRAINT pk_rg_i_0011 PRIMARY KEY (d1_s)     -- ← унікальність штрихкоду «безкоштовно»
);
CREATE INDEX ix_rg_i_0011_r1 ON rg_i_0011 (r1_r);  -- зворотний пошук: штрихкоди товару

Проєктне рішення: PK за вимірюванням автоматично гарантує, що один штрихкод не може вказувати на два товари. Якби Номенклатуру зробили вимірюванням, а Штрихкод — ресурсом, ця гарантія зникла б, і довелося б писати перевірку в прикладному коді.

Д.3.4. ГрафікРоботи — неперіодичний, незалежний (використовується як графік для РР)

register:
  name: ГрафікРоботи
  kind: Information
  periodicity: Nonperiodical
  writeMode: Independent
  dimensions:
    - { name: ВидГрафіка,  type: "Довідник.ГрафікиРоботи", master: true, denyEmpty: true }
    - { name: ДатаГрафіка, type: "Дата", denyEmpty: true }        # ← вимірювання типу Дата, не Період!
  resources:
    - { name: Значення, type: "Число(1,0)" }      # 1 = робочий день, 0 = вихідний
    - { name: Годин,    type: "Число(4,2)" }      # тривалість зміни

Проєктне рішення (важливе): ДатаГрафіка — це вимірювання типу Дата, а не стандартний реквізит Період. Регістр неперіодичний. Причина: графік описує календар (кожна дата — окремий факт), а не історію зміни значення. Якби використали періодичність, зріз останніх повертав би «останній робочий день», що безглуздо. Механізм РР (ДаніГрафіка) очікує саме таку структуру.

Дані у таблиці rg_i_0015:

d1_r (ВидГрафіка) d2_d (ДатаГрафіка) r1 (Значення) r2 (Годин)
П'ятиденка 2026-07-01 1 8.00
П'ятиденка 2026-07-04 0 0.00
П'ятиденка 2026-07-05 0 0.00
П'ятиденка 2026-07-06 1 8.00
Змінний 2/2 2026-07-01 1 12.00
Змінний 2/2 2026-07-03 0 0.00

Д.3.5. КадровіДані — по позиції реєстратора

Задача: зберігати актуальні кадрові дані співробітника з історією; підтримати кілька наказів однією датою в правильному порядку.

register:
  name: КадровіДані
  kind: Information
  periodicity: ByRecorderPosition   # ← упорядкування за позицією документа, не лише за датою
  writeMode: SubordinateToRecorder
  dimensions:
    - { name: Співробітник, type: "Довідник.Співробітники", master: true, denyEmpty: true }
  resources:
    - { name: Підрозділ,    type: "Довідник.Підрозділи" }
    - { name: Посада,       type: "Довідник.Посади" }
    - { name: Ставка,       type: "Число(5,2)" }
    - { name: ВидЗайнятості, type: "Перерахування.ВидиЗайнятості" }
    - { name: ГрафікРоботи, type: "Довідник.ГрафікиРоботи" }
  recorders:
    - Документ.ПрийомНаРоботу
    - Документ.КадровеПереміщення
    - Документ.Звільнення

Проєктне рішення: ByRecorderPosition — коли два накази оформлені однією датою (переміщення + зміна ставки), звичайна періодичність «у межах дня» дала б недетермінований результат зрізу. Позиція реєстратора додає до ключа (rec_t, rec_r, line_no) і впорядковує за моментом часу документа.

Фізична схема:

CREATE TABLE rg_i_0016 (
    period   timestamp(0) NOT NULL,
    rec_t    smallint     NOT NULL,
    rec_r    uuid         NOT NULL,
    line_no  int          NOT NULL,
    active   boolean      NOT NULL DEFAULT true,
    d1_r     uuid         NOT NULL,        -- Співробітник
    r1_r     uuid NOT NULL, r2_r uuid NOT NULL,
    r3       numeric(5,2) NOT NULL,
    r4       smallint     NOT NULL,        -- Перерахування зберігається як порядковий номер
    r5_r     uuid NOT NULL,
    CONSTRAINT pk_rg_i_0016 PRIMARY KEY (period, rec_t, rec_r, line_no)  -- ← Період у ключі ПЕРШИЙ
);
CREATE INDEX ix_rg_i_0016_d1 ON rg_i_0016 (d1_r, period DESC, rec_t, rec_r, line_no DESC)
    WHERE active;

Д.3.6. НалаштуванняКористувачів — складений тип ресурсу

register:
  name: НалаштуванняКористувачів
  kind: Information
  periodicity: Nonperiodical
  writeMode: Independent
  dimensions:
    - { name: Користувач,   type: "Довідник.Користувачі", master: true }
    - { name: Налаштування, type: "ПланВидівХарактеристик.НалаштуванняКористувачів", master: true }
  resources:
    - name: Значення
      type: ["Рядок(500)", "Число(15,2)", "Дата", "Булево",
             "Довідник.Організації", "Довідник.Склади"]     # ← складений тип
CREATE TABLE rg_i_0018 (
    d1_r  uuid           NOT NULL,
    d2_r  uuid           NOT NULL,
    r1_t  smallint       NOT NULL,      -- код типу значення
    r1_b  boolean,
    r1_n  numeric(15,2),
    r1_s  varchar(500),
    r1_d  timestamp(0),
    r1_r  uuid,
    CONSTRAINT pk_rg_i_0018 PRIMARY KEY (d1_r, d2_r)
);
d1_r d2_r r1_t r1_b r1_n r1_s r1_d r1_r
Іваненко ОсновнаОрганізація 101 ТОВ «Альфа»
Іваненко ОсновнийСклад 104 Центральний
Іваненко ПоказуватиПідказки 2 true
Петренко КількістьРядківСписку 3 50

Д.4. Регістри накопичення, вид «Залишки»

Д.4.1. ТовариНаСкладах

Декларація — розділ 6.7, фізична схема — розділ 8.5, ER — розділ 9.2. Тут — дані.

Рухи (rg_a_0012) після проведення трьох документів:

rec_t rec_r line_no period mvt d1_r (Номенкл.) d2_r (Склад) r1 (К-сть) r2 (Сума)
2 (ПрихНакл) p1 1 2026-06-10 0 Прихід Кава Lavazza Центральний 100.000 35 000.00
2 (ПрихНакл) p1 2 2026-06-10 0 Прихід Чай Ahmad Центральний 200.000 12 000.00
3 (ВидНакл) v1 1 2026-07-05 1 Витрата Кава Lavazza Центральний 30.000 10 500.00
4 (Переміщ) m1 1 2026-07-12 1 Витрата Кава Lavazza Центральний 20.000 7 000.00
4 (Переміщ) m1 2 2026-07-12 0 Прихід Кава Lavazza Роздрібний 20.000 7 000.00

Підсумки залишків (rg_a_0012_tb), ТА = 2026-07-31:

period d1_r d2_r r1 (К-сть ±) r2 (Сума ±) Коментар
2026-07-01 Кава Lavazza Центральний 100.000 35 000.00 залишок на початок липня
2026-07-01 Чай Ahmad Центральний 200.000 12 000.00
2026-08-01 Кава Lavazza Центральний 50.000 17 500.00 100 − 30 − 20
2026-08-01 Кава Lavazza Роздрібний 20.000 7 000.00
2026-08-01 Чай Ahmad Центральний 200.000 12 000.00
5999-11-01 Кава Lavazza Центральний 50.000 17 500.00 MAXPERIOD — поточний залишок
5999-11-01 Кава Lavazza Роздрібний 20.000 7 000.00 MAXPERIOD
5999-11-01 Чай Ahmad Центральний 200.000 12 000.00 MAXPERIOD

(Вимірювання Партія і Характеристика для стислості опущені.)

Д.4.2. ВзаєморозрахункиЗКонтрагентами

Задача: облік дебіторської/кредиторської заборгованості в розрізі договорів і замовлень.

register:
  name: ВзаєморозрахункиЗКонтрагентами
  kind: Accumulation
  accumulationKind: Balance
  periodicity: WithinSecond
  enableBalanceTotals: true
  enableTurnoverTotals: true
  partitioning: { mode: RangeByPeriod, step: month }
  dimensions:
    - { name: Організація, type: "Довідник.Організації", denyEmpty: true, index: true }
    - { name: Контрагент,  type: "Довідник.Контрагенти", master: true, index: true }
    - { name: Договір,     type: "Довідник.ДоговориКонтрагентів", master: true, index: true }
    - { name: Замовлення,  type: ["Документ.ЗамовленняПокупця", "Документ.ЗамовленняПостачальнику"],
        nullable: true }
  resources:
    - { name: Сума,    type: "Число(15,2)" }     # у валюті регламентованого обліку
    - { name: СумаВал, type: "Число(15,2)" }     # у валюті договору
  attributes:
    - { name: Коментар, type: "Рядок(200)" }
  recorders:
    - Документ.ВидатковаНакладна
    - Документ.ПрихіднаНакладна
    - Документ.ПлатіжнеДоручення
    - Документ.КасовийОрдер
    - Документ.КоригуванняБоргу

Угода про знак (обов'язково фіксується в методології!):

Вид руху Сенс Приклад документа
Прихід Борг контрагента перед нами зростає (дебіторка +) Видаткова накладна
Витрата Борг контрагента перед нами зменшується (оплата) Платіжне доручення вхідне

Кредиторська заборгованість = від'ємний залишок. Альтернатива — два окремих ресурси (СумаДт/СумаКт) або два регістри; обраний варіант — один знаковий ресурс (простіше, менший обсяг підсумків).

Приклад даних:

Документ period mvt Контрагент Договір r1 (Сума) Залишок після
ВидатковаНакладна №12 2026-07-05 Прихід ТОВ «Бета» Основний 12 000.00 +12 000 (винні нам)
ПлатіжнеДоручення №88 2026-07-09 Витрата ТОВ «Бета» Основний 8 000.00 +4 000
ПлатіжнеДоручення №91 2026-07-16 Витрата ТОВ «Бета» Основний 6 000.00 −2 000 (передоплата)

Запит (акт звірки):

ВИБРАТИ
    Дані.Контрагент, Дані.Договір,
    Дані.СумаПочатковийЗалишок, Дані.СумаПрихід,
    Дані.СумаВитрата,           Дані.СумаКінцевийЗалишок
З РегістрНакопичення.ВзаєморозрахункиЗКонтрагентами.ЗалишкиІОбороти(
       &Початок, &Кінець, Авто, Рух,
       Організація = &Організація І Контрагент = &Контрагент
   ) ЯК Дані

Д.4.3. ТовариВРезерві — регістр «супутник»

register:
  name: ТовариВРезерві
  kind: Accumulation
  accumulationKind: Balance
  periodicity: WithinSecond
  dimensions:
    - { name: Номенклатура,   type: "Довідник.Номенклатура", master: true, index: true }
    - { name: Характеристика, type: "Довідник.ХарактеристикиНоменклатури", nullable: true }
    - { name: Склад,          type: "Довідник.Склади", master: true, index: true }
    - { name: Замовлення,     type: "Документ.ЗамовленняПокупця", master: true, denyEmpty: true }
  resources:
    - { name: Кількість, type: "Число(15,3)" }
  recorders:
    - Документ.ЗамовленняПокупця
    - Документ.ВидатковаНакладна
    - Документ.ЗакриттяЗамовлень

Патерн «доступний залишок» — з'єднання двох регістрів:

ВИБРАТИ
    ЄСТЬNULL(Зал.Номенклатура, Рез.Номенклатура) ЯК Номенклатура,
    ЄСТЬNULL(Зал.Склад, Рез.Склад) ЯК Склад,
    ЄСТЬNULL(Зал.КількістьЗалишок, 0) ЯК ВСкладі,
    ЄСТЬNULL(Рез.КількістьЗалишок, 0) ЯК ВРезерві,
    ЄСТЬNULL(Зал.КількістьЗалишок, 0) - ЄСТЬNULL(Рез.КількістьЗалишок, 0) ЯК Доступно
З РегістрНакопичення.ТовариНаСкладах.Залишки(&Період, Склад = &Склад) ЯК Зал
ПОВНЕ З'ЄДНАННЯ РегістрНакопичення.ТовариВРезерві.Залишки(&Період, Склад = &Склад) ЯК Рез
    ЗА Зал.Номенклатура = Рез.Номенклатура І Зал.Склад = Рез.Склад

Шаблон:Note

Д.4.4. ГрошовіКоштиБезготівкові

register:
  name: ГрошовіКоштиБезготівкові
  kind: Accumulation
  accumulationKind: Balance
  periodicity: WithinDay              # достатньо дня: банківська виписка — денна
  dimensions:
    - { name: Організація,       type: "Довідник.Організації", denyEmpty: true }
    - { name: БанківськийРахунок, type: "Довідник.БанківськіРахунки", master: true, denyEmpty: true }
  resources:
    - { name: Сума,    type: "Число(15,2)" }
    - { name: СумаВал, type: "Число(15,2)" }
  recorders: [Документ.ПлатіжнеДоручення, Документ.БанківськаВиписка, Документ.ПереміщенняКоштів]

Проєктне рішення: Валюта — не вимірювання, тому що вона однозначно визначається банківським рахунком (функціональна залежність). Її додавання як вимірювання створило б надлишковість і ризик неузгоджених даних. Валюта отримується з'єднанням із довідником.

Д.5. Регістри накопичення, вид «Обороти»

Д.5.1. Продажі — з агрегатами

register:
  name: Продажі
  kind: Accumulation
  accumulationKind: Turnover          # ← залишків не буває: «залишок продажів» безглуздий
  periodicity: WithinSecond
  turnoverTotalsPeriod: Month
  aggregatesMode: Aggregates
  partitioning: { mode: RangeByPeriod, step: month }
  dimensions:
    - { name: Номенклатура,   type: "Довідник.Номенклатура", index: true }
    - { name: Характеристика, type: "Довідник.ХарактеристикиНоменклатури", nullable: true }
    - { name: Контрагент,     type: "Довідник.Контрагенти", index: true }
    - { name: Договір,        type: "Довідник.ДоговориКонтрагентів" }
    - { name: Менеджер,       type: "Довідник.Користувачі", index: true }
    - { name: Склад,          type: "Довідник.Склади" }
  resources:
    - { name: Кількість,   type: "Число(15,3)" }
    - { name: Сума,        type: "Число(15,2)" }
    - { name: СумаБезПДВ,  type: "Число(15,2)" }
    - { name: Собівартість, type: "Число(15,2)" }
  recorders: [Документ.ВидатковаНакладна, Документ.ПовернненняВідПокупця, Документ.ЗвітПроРоздрібніПродажі]

  aggregates:
    - name: ПродажіПоМісяцяхІТоварах
      periodicity: Month
      dimensions: [Номенклатура, Склад]
      realtime: true
    - name: ПродажіПоМенеджерах
      periodicity: Day
      dimensions: [Менеджер]
      realtime: false                 # оновлюється регламентно
    - name: ПродажіПоКонтрагентах
      periodicity: Quarter
      dimensions: [Контрагент, Договір]
      realtime: false

Проєктні рішення:

  • Turnover, а не Balance: немає ВидРуху, немає таблиці залишків → удвічі менше підсумків і немає кумулятивних оновлень. Повернення відображається від'ємною сумою, а не «витратою».
  • 6 вимірювань → таблиця підсумків надто широка для швидких звітів → компенсуємо агрегатами.

Ефект агрегатів:

Звіт Без агрегатів (rg_a_0031_tt) З агрегатом Виграш
Продажі по товарах за рік скан 8 млн рядків підсумків ag1: 40 тис. рядків ×200
Рейтинг менеджерів за квартал скан 8 млн рядків ag2: 1,8 тис. рядків ×4000
Продажі по контрагенту за 3 роки скан 8 млн рядків ag3: 60 тис. рядків ×130
Продажі по товару + контрагенту агрегат не покриває → підсумки
CREATE TABLE rg_a_0031_ag1 (          -- Місяць × (Номенклатура, Склад)
    period timestamp(0) NOT NULL, d1_r uuid NOT NULL, d6_r uuid NOT NULL,
    r1 numeric(20,3) NOT NULL, r2 numeric(20,2) NOT NULL,
    r3 numeric(20,2) NOT NULL, r4 numeric(20,2) NOT NULL,
    CONSTRAINT pk_rg_a_0031_ag1 PRIMARY KEY (period, d1_r, d6_r)
);
CREATE TABLE rg_a_0031_ag2 (          -- День × (Менеджер)
    period timestamp(0) NOT NULL, d5_r uuid NOT NULL,
    r1 numeric(20,3) NOT NULL, r2 numeric(20,2) NOT NULL,
    r3 numeric(20,2) NOT NULL, r4 numeric(20,2) NOT NULL,
    CONSTRAINT pk_rg_a_0031_ag2 PRIMARY KEY (period, d5_r)
);

Д.5.2. ВитратиЗаСтаттями

register:
  name: ВитратиЗаСтаттями
  kind: Accumulation
  accumulationKind: Turnover
  periodicity: WithinDay
  turnoverTotalsPeriod: Month
  dimensions:
    - { name: Організація,    type: "Довідник.Організації", denyEmpty: true }
    - { name: Підрозділ,      type: "Довідник.Підрозділи", index: true }
    - { name: СтаттяВитрат,   type: "Довідник.СтаттіВитрат", master: true, denyEmpty: true, index: true }
    - { name: Проєкт,         type: "Довідник.Проєкти", nullable: true }
  resources:
    - { name: Сума, type: "Число(15,2)" }
  recorders: [Документ.АвансовийЗвіт, Документ.НадходженняПослуг, Документ.НарахуванняЗарплати, Документ.Амортизація]

Приклад даних (rg_a_0032_tt, підсумки оборотів за місяць):

period Підрозділ СтаттяВитрат Проєкт r1 (Сума)
2026-07-01 Відділ продажів Оренда (порожній) 45 000.00
2026-07-01 Відділ продажів ЗП і нарахування (порожній) 320 000.00
2026-07-01 Відділ продажів Реклама Запуск «Альфа» 78 000.00
2026-07-01 ІТ-відділ ЗП і нарахування (порожній) 410 000.00
2026-07-01 ІТ-відділ Ліцензії ПЗ (порожній) 62 000.00

Д.6. Регістри бухгалтерії

Д.6.1. Госпрозрахунковий — з кореспонденцією

Декларація — розділ 6.7, схема — 8.6, ER — 9.3. Тут — приклад проводок.

Господарська операція: реалізація товару на 12 000 грн (у т.ч. ПДВ 2 000), собівартість 7 000 грн.

Проводки (rg_b_0020, реєстратор = ВидатковаНакладна №12):

line acc_dt ed_dt1 ed_dt2 acc_kt ed_kt1 ed_kt2 r1 (Сума) r2_dt r2_kt
1 361 Розрах. з покупцями ТОВ «Бета» Договір №5 702 Дохід від реалізації Кава Lavazza 12 000.00 30.000
2 702 Дохід від реалізації Кава Lavazza 641 Розрах. за податками ПДВ 2 000.00
3 902 Собівартість реалізації Кава Lavazza 281 Товари на складі Кава Lavazza Центральний 7 000.00 30.000 30.000

Контроль: Σ(Сума) по Дт = Σ(Сума) по Кт у межах реєстратора → 12 000 + 2 000 + 7 000 = 21 000 з обох боків. ✔

Підсумки, рівень 0 (rg_b_0020_tb0), на 5999-11-01:

acc_r d1_r (Організація) r1 (Сума ±) Інтерпретація
281 ТОВ «Альфа» +28 000.00 дебетовий залишок (товари є)
361 ТОВ «Альфа» +12 000.00 дебетовий (дебіторка)
641 ТОВ «Альфа» −2 000.00 кредитовий (винні бюджету)
702 ТОВ «Альфа» −10 000.00 кредитовий (дохід)
902 ТОВ «Альфа» +7 000.00 дебетовий (собівартість)

Підсумки, рівень 2 (rg_b_0020_tb2) — для рахунку 281 (2 субконто: Номенклатура, Склад):

acc_r ed1_r (Номенклатура) ed2_r (Склад) r1 (Сума) r2 (Кількість)
281 Кава Lavazza Центральний 17 500.00 50.000
281 Кава Lavazza Роздрібний 7 000.00 20.000
281 Чай Ahmad Центральний 12 000.00 200.000

Згортка tb2tb0: 17 500 + 7 000 + 12 000 = 36 500(різниця з таблицею вище — інші операції; у тесті ФТ-Т-08 перевіряється точна рівність).

Запит (ОСВ по рахунку):

ВИБРАТИ
    Дані.Субконто1 ЯК Номенклатура, Дані.Субконто2 ЯК Склад,
    Дані.СумаПочатковийЗалишокДт, Дані.КількістьПочатковийЗалишокДт,
    Дані.СумаОборотДт, Дані.СумаОборотКт,
    Дані.СумаКінцевийЗалишокДт, Дані.КількістьКінцевийЗалишокДт
З РегістрБухгалтерії.Госпрозрахунковий.ЗалишкиІОбороти(
       &Початок, &Кінець, Місяць,
       Рахунок = &Рахунок281,
       , Організація = &Організація,
       , 
   ) ЯК Дані

Д.6.2. ПодатковийОблік — без кореспонденції

Задача: податковий облік із розділенням на постійні/тимчасові різниці, де подвійний запис не потрібен (кожен факт реєструється окремо по Дт або по Кт).

register:
  name: ПодатковийОблік
  kind: Accounting
  chartOfAccounts: ПланРахунків.Податковий
  correspondence: false               # ← уніграфічний облік
  periodicity: WithinSecond
  dimensions:
    - { name: Організація, type: "Довідник.Організації", balance: true, denyEmpty: true }
    - { name: ВидРізниці,  type: "Перерахування.ВидиРізниць", balance: true }   # БУ|ПУ|ПР|ТР
  resources:
    - { name: Сума, type: "Число(15,2)", balance: true }
  attributes:
    - { name: Зміст, type: "Рядок(255)" }
  recorders: [Документ.БухгалтерськаОперація, Документ.ВидатковаНакладна, Документ.НадходженняПослуг]

Проводки (rg_b_0021):

line acc_mvt acc_r ed1 (Номенкл.) d1 (Організація) d2 (ВидРізниці) r1 (Сума)
1 0 Дебет 902.НУ Кава Lavazza ТОВ «Альфа» ПУ податковий 7 000.00
2 1 Кредит 281.НУ Кава Lavazza ТОВ «Альфа» ПУ 7 000.00
3 0 Дебет 902.НУ Кава Lavazza ТОВ «Альфа» ПР постійна різниця 350.00
4 1 Кредит 281.НУ Кава Lavazza ТОВ «Альфа» ПР 350.00

Відмінності від Д.6.1:

З кореспонденцією Без кореспонденції
Один факт = 1 запис (Дт+Кт) 2 записи (окремо Дт, окремо Кт)
Поля рахунків acc_dt_r, acc_kt_r acc_r + acc_mvt
Субконто 2 набори (Дт, Кт) 1 набір
Кількість записів N 2N
Таблиця _tc (кореспонденції) Є Немає — «шахова відомість» неможлива
Контроль Дт=Кт У межах реєстратора Опційно (може бути незбалансовано за задумом)
Коли обирати Класичний облік, потрібен аналіз кореспонденцій Облік без потреби в кореспонденціях, забалансовий, статистичний

Д.6.3. Бюджетування

register:
  name: Бюджетування
  kind: Accounting
  chartOfAccounts: ПланРахунків.Бюджетування
  correspondence: true
  periodicity: WithinMonth            # бюджет — помісячний, до секунди не потрібно
  dimensions:
    - { name: Сценарій, type: "Довідник.СценаріїБюджетування", balance: true, denyEmpty: true }
    - { name: ЦФВ,      type: "Довідник.ЦентриФінансовоїВідповідальності", balance: true }
    - { name: Проєкт,   type: "Довідник.Проєкти", balance: true, nullable: true }
  resources:
    - { name: Сума, type: "Число(15,2)", balance: true }
  recorders: [Документ.БюджетнаОперація, Документ.ВведенняБюджету]

Проєктне рішення: Сценарій — вимірювання, а не окремий регістр на кожен сценарій (План/Факт/Прогноз). Це дозволяє план-фактний аналіз одним запитом. Усі три вимірювання балансові — бюджетна проводка не змінює сценарій/ЦФВ між дебетом і кредитом.

Д.7. Регістри розрахунку

Д.7.1. ОсновніНарахування — період дії + витіснення

План видів розрахунку ОсновніНарахування:

Вид розрахунку Період дії Витісняючі Базові Залежність від бази
Оклад Так Відпустка, Лікарняний Не залежить
Відпустка Так Лікарняний Оклад, Премія За періодом дії
Лікарняний Так Оклад За періодом дії
Премія Ні Оклад За періодом реєстрації

Декларація регістру — розділ 6.7.

Записи (rg_c_0040), період реєстрації = липень 2026:

line calc_type period act_begin act_end base_begin base_end d1 (Співробітник) r1 (Результат)
1 Оклад 2026-07-01 2026-07-01 2026-07-31 Іваненко 18 000.00
2 Відпустка 2026-07-01 2026-07-10 2026-07-17 2026-01-01 2026-06-30 Іваненко 6 420.00
3 Лікарняний 2026-07-01 2026-07-14 2026-07-22 2026-01-01 2026-06-30 Іваненко 5 180.00
4 Оклад 2026-07-01 2026-07-01 2026-07-31 Петренко 22 000.00

Фактичні періоди дії (rg_c_0040_ap) — результат витіснення:

rec/line interval_no calc_type d1 fact_begin fact_end Коментар
n1 / 1 0 Оклад Іваненко 2026-07-01 2026-07-09 до відпустки
n1 / 1 1 Оклад Іваненко 2026-07-23 2026-07-31 після лікарняного
n1 / 2 0 Відпустка Іваненко 2026-07-10 2026-07-13 витіснена лікарняним з 14-го
n1 / 3 0 Лікарняний Іваненко 2026-07-14 2026-07-22 не витісняється нічим
n1 / 4 0 Оклад Петренко 2026-07-01 2026-07-31 витіснення відсутнє

Один запис «Оклад» Іваненка → два інтервали — ілюстрація зв'язку 1:N у розділі 9.4.

Запит (дані графіка для розрахунку окладу):

ВИБРАТИ
    Дані.Співробітник, Дані.НомерРядка,
    Дані.ЗначенняФактичногоПеріодуДії ЯК ВідпрацьованоДнів,
    Дані.ЗначенняПеріодуДії           ЯК НормаДнів
З РегістрРозрахунку.ОсновніНарахування.ДаніГрафіка(
       ВидРозрахунку = ЗНАЧЕННЯ(ПланВидівРозрахунку.ОсновніНарахування.Оклад)
       І Реєстратор = &Реєстратор
   ) ЯК Дані

Результат: Іваненко — ВідпрацьованоДнів = 13 (робочі дні у 01–09 та 23–31), НормаДнів = 23 → Оклад = 18 000 × 13 / 23 = 10 173,91.

Запит (база для відпустки):

ВИБРАТИ База.Співробітник, База.НомерРядка, База.РезультатБаза ЯК Заробіток
З РегістрРозрахунку.ОсновніНарахування.БазаРозрахунку(
       , /* вимірювання бази */
       , /* вимірювання основного регістру */
       Співробітник,                          /* розрізи */
       ВидРозрахунку = ЗНАЧЕННЯ(ПланВидівРозрахунку.ОсновніНарахування.Відпустка)
   ) ЯК База

Д.7.2. Утримання — без періоду дії, база за періодом реєстрації

План видів розрахунку Утримання:

Вид розрахунку Період дії Базові види розрахунку Залежність
ПДФО Ні (усі з ПВР ОсновніНарахування) За періодом реєстрації
Військовий збір Ні (усі з ПВР ОсновніНарахування) За періодом реєстрації
Аліменти Ні Оклад, Премія, Відпустка За періодом реєстрації
Профспілковий внесок Ні Оклад За періодом реєстрації
register:
  name: Утримання
  kind: Calculation
  calculationTypesPlan: ПланВидівРозрахунку.Утримання
  periodicity: Month
  actionPeriod: false                 # ← утримання не має періоду дії
  basePeriod: ByRegistrationPeriod    # ← база береться за періодом реєстрації
  # schedule: не задається — графік не потрібен
  dimensions:
    - { name: Співробітник, type: "Довідник.Співробітники", baseDimension: true, master: true, denyEmpty: true }
    - { name: Підрозділ,    type: "Довідник.Підрозділи" }
  resources:
    - { name: Результат,        type: "Число(15,2)" }
    - { name: БазаОподаткування, type: "Число(15,2)" }
  recalculations:
    - name: ПерерахунокУтримань
      dimensions:
        - { name: Співробітник, registerDimension: Співробітник }
  recorders: [Документ.НарахуванняЗарплати]

Фізична схема (спрощена — немає полів періоду дії):

CREATE TABLE rg_c_0041 (
    rec_t       smallint     NOT NULL,
    rec_r       uuid         NOT NULL,
    line_no     int          NOT NULL,
    period      timestamp(0) NOT NULL,     -- період реєстрації
    active      boolean      NOT NULL DEFAULT true,
    reversal    boolean      NOT NULL DEFAULT false,
    calc_type_r uuid         NOT NULL,
    base_begin  timestamp(0) NOT NULL,     -- ← є, бо basePeriod ≠ None
    base_end    timestamp(0) NOT NULL,
    -- act_begin / act_end ВІДСУТНІ (actionPeriod: false)
    d1_r        uuid         NOT NULL,
    d2_r        uuid         NOT NULL,
    r1          numeric(15,2) NOT NULL DEFAULT 0,
    r2          numeric(15,2) NOT NULL DEFAULT 0,
    CONSTRAINT pk_rg_c_0041 PRIMARY KEY (rec_t, rec_r, line_no)
);
-- Таблиця rg_c_0041_ap НЕ створюється (немає періоду дії → немає витіснення)
CREATE TABLE rg_c_0041_rc1 (            -- ПерерахунокУтримань
    rec_t smallint NOT NULL, rec_r uuid NOT NULL,
    calc_type_r uuid NOT NULL, d1_r uuid NOT NULL,
    src_rec_t smallint NOT NULL, src_rec_r uuid NOT NULL,
    created_at timestamp(0) NOT NULL DEFAULT now(),
    CONSTRAINT pk_rg_c_0041_rc1 PRIMARY KEY (rec_t, rec_r, calc_type_r, d1_r)
);

Шаблон:Note

Приклад даних:

line calc_type period base_begin base_end Співробітник r2 (База) r1 (Результат)
1 ПДФО 2026-07-01 2026-07-01 2026-07-31 Іваненко 21 773.91 3 919.30
2 Військовий збір 2026-07-01 2026-07-01 2026-07-31 Іваненко 21 773.91 326.61
3 Аліменти 2026-07-01 2026-07-01 2026-07-31 Іваненко 17 528.00 4 382.00

Д.8. Наскрізний приклад: проведення видаткової накладної

Документ: Видаткова накладна №12 від 05.07.2026, ТОВ «Бета», 30 кг кави за 400 грн (12 000 грн з ПДВ), собівартість 7 000 грн.

class ВидатковаНакладна(Document):
    """Проведення по 5 регістрах в одній транзакції. Бюджет Python: <= 15 мс (АП-4)."""

    def on_posting(self, ctx: PostingContext) -> None:
        # --- 0. Блокування перед контролем залишків ---
        with DataLock() as dl:
            li = dl.add("РегістрНакопичення.ТовариНаСкладах.Залишки")
            li.mode = DataLockMode.EXCLUSIVE
            li.data_source = self.Товари.unload("Номенклатура", "Характеристика")
            li.use_from_data_source("Номенклатура", "Номенклатура")
            li.use_from_data_source("Характеристика", "Характеристика")
            li.set_value("Склад", self.Склад)

            self._movements_goods()       # 1. ТовариНаСкладах
            self._movements_ar()          # 2. Взаєморозрахунки
            self._movements_sales()       # 3. Продажі
            self._movements_reserve()     # 4. ТовариВРезерві
            self._movements_accounting()  # 5. Госпрозрахунковий

            self.movements.ТовариНаСкладах.write_now()
            self._control_negative_balances(ctx)

    # --- 1. РН ТовариНаСкладах: списання ---
    def _movements_goods(self) -> None:
        rs = self.movements.ТовариНаСкладах
        rs.write = True
        rs.clear()
        rs.extend(
            rs.new(Період=self.Дата,
                   ВидРуху=AccumulationMovementType.EXPENSE,
                   Номенклатура=r.Номенклатура, Характеристика=r.Характеристика,
                   Склад=self.Склад, Партія=r.Партія,
                   Кількість=r.Кількість, Сума=r.Собівартість)
            for r in self.Товари
        )

    # --- 2. РН Взаєморозрахунки: дебіторка зростає ---
    def _movements_ar(self) -> None:
        rs = self.movements.ВзаєморозрахункиЗКонтрагентами
        rs.write = True
        rs.clear()
        rs.append(rs.new(
            Період=self.Дата,
            ВидРуху=AccumulationMovementType.RECEIPT,     # Прихід = борг зростає
            Організація=self.Організація, Контрагент=self.Контрагент,
            Договір=self.Договір, Замовлення=self.ЗамовленняПокупця,
            Сума=self.СумаДокумента, СумаВал=self.СумаДокументаВал,
        ))

    # --- 3. РН Продажі: оборот (ВидРуху відсутній!) ---
    def _movements_sales(self) -> None:
        rs = self.movements.Продажі
        rs.write = True
        rs.clear()
        rs.extend(
            rs.new(Період=self.Дата,
                   Номенклатура=r.Номенклатура, Контрагент=self.Контрагент,
                   Договір=self.Договір, Менеджер=self.Менеджер, Склад=self.Склад,
                   Кількість=r.Кількість, Сума=r.Сума,
                   СумаБезПДВ=r.СумаБезПДВ, Собівартість=r.Собівартість)
            for r in self.Товари
        )

    # --- 4. РН ТовариВРезерві: зняття резерву ---
    def _movements_reserve(self) -> None:
        if self.ЗамовленняПокупця.is_empty():
            return
        rs = self.movements.ТовариВРезерві
        rs.write = True
        rs.clear()
        rs.extend(
            rs.new(Період=self.Дата, ВидРуху=AccumulationMovementType.EXPENSE,
                   Номенклатура=r.Номенклатура, Характеристика=r.Характеристика,
                   Склад=self.Склад, Замовлення=self.ЗамовленняПокупця,
                   Кількість=r.Кількість)
            for r in self.Товари
        )

    # --- 5. РБ Госпрозрахунковий: 3 проводки на рядок ---
    def _movements_accounting(self) -> None:
        rs = self.movements.Госпрозрахунковий
        rs.write = True
        rs.clear()
        for r in self.Товари:
            e1 = rs.add_accounting_entry(               # Дт 361 / Кт 702
                Період=self.Дата, Організація=self.Організація,
                РахунокДт=Accounts["361"], РахунокКт=Accounts["702"],
                Сума=r.Сума, КількістьКт=r.Кількість)
            e1.subconto_dt[ВидиСубконто.Контрагенти] = self.Контрагент
            e1.subconto_dt[ВидиСубконто.Договори] = self.Договір
            e1.subconto_kt[ВидиСубконто.Номенклатура] = r.Номенклатура

            e2 = rs.add_accounting_entry(               # Дт 702 / Кт 641 (ПДВ)
                Період=self.Дата, Організація=self.Організація,
                РахунокДт=Accounts["702"], РахунокКт=Accounts["641"],
                Сума=r.СумаПДВ)
            e2.subconto_dt[ВидиСубконто.Номенклатура] = r.Номенклатура
            e2.subconto_kt[ВидиСубконто.ПодаткиІЗбори] = Taxes.ПДВ

            e3 = rs.add_accounting_entry(               # Дт 902 / Кт 281
                Період=self.Дата, Організація=self.Організація,
                РахунокДт=Accounts["902"], РахунокКт=Accounts["281"],
                Сума=r.Собівартість,
                КількістьДт=r.Кількість, КількістьКт=r.Кількість)
            e3.subconto_dt[ВидиСубконто.Номенклатура] = r.Номенклатура
            e3.subconto_kt[ВидиСубконто.Номенклатура] = r.Номенклатура
            e3.subconto_kt[ВидиСубконто.Склади] = self.Склад

Що відбувається у БД (одна транзакція):

Крок Таблиця Операція Рядків
1 doc_0003 UPDATE posted = true 1
2 rg_a_0012 (ТовариНаСкладах) DELETE + INSERT 1
3 rg_a_0012_tb MERGE (MAXPERIOD + періоди > 07.2026) 2
4 rg_a_0013 (Взаєморозрахунки) DELETE + INSERT 1
5 rg_a_0013_tb MERGE 2
6 rg_a_0031 (Продажі) DELETE + INSERT 1
7 rg_a_0031_tt MERGE (тільки період 2026-07) 1
8 rg_a_0031_ag1 MERGE (realtime-агрегат) 1
9 rg_a_0014 (Резерв) DELETE + INSERT 1
10 rg_b_0020 (проводки) DELETE + INSERT 3
11 rg_b_0020_tb0 MERGE 10
12 rg_b_0020_tb1/tb2 MERGE 8
13 rg_b_0020_tt0..tt2 MERGE 12
14 rg_b_0020_tc MERGE 3
Разом ≈ 47 рядків, 1 COMMIT

Шаблон:Note

Д.9. Наскрізний приклад: розрахунок зарплати

КРОК 1. Документ «Нарахування зарплати» за липень 2026, Іваненко.
        Записуємо рухи в РР ОсновніНарахування (Результат = 0).

КРОК 2. Платформа автоматично будує фактичні періоди дії (rg_c_0040_ap):
        Оклад      → [01.07–09.07], [23.07–31.07]
        Відпустка  → [10.07–13.07]
        Лікарняний → [14.07–22.07]

КРОК 3. Розрахунок «первинних» видів (без бази) — Оклад.
        Запит до ДаніГрафіка:
          НормаДнів            = 23 (робочих днів у липні за П'ятиденкою)
          ВідпрацьованоДнів    = 13 (робочі дні у фактичних періодах дії)
        Результат = 18 000 × 13 / 23 = 10 173,91
        → UPDATE rg_c_0040 SET r1 = 10173.91, r2 = 13, r3 = 23 WHERE line_no = 1

КРОК 4. Розрахунок видів із базою — Відпустка, Лікарняний.
        Запит до БазаРозрахунку (базовий період 01.01–30.06, база = Оклад+Премія):
          Заробіток за 6 міс. = 115 560,00
          Календарних днів    = 181
          Середньоденний      = 638,45
        Відпустка: 638,45 × 4 дні (10–13.07) = 2 553,80
        Лікарняний: 638,45 × 9 днів × 100 % = 5 746,05
        → UPDATE rg_c_0040 SET r1 = ... WHERE line_no IN (2, 3)

КРОК 5. Записуємо рухи в РР Утримання (базовий період = період реєстрації, липень).
        База ПДФО = 10 173,91 + 2 553,80 + 5 746,05 = 18 473,76
        ПДФО = 18 473,76 × 18 % = 3 325,28
        ВЗ   = 18 473,76 × 1,5 % =   277,11

КРОК 6. Проведення документа → рухи в:
        РН ВитратиЗаСтаттями (Обороти):  Стаття «ЗП і нарахування» = 18 473,76
        РБ Госпрозрахунковий:            Дт 92 / Кт 661 = 18 473,76
                                         Дт 661 / Кт 641.ПДФО = 3 325,28
                                         Дт 661 / Кт 642.ВЗ =     277,11

КРОК 7. Через тиждень бухгалтер змінює Оклад Іваненка за червень (заднім числом).
        → Платформа реєструє в rg_c_0040_rc1 (ПерерахунокЗаБазою):
             (Реєстратор = НарахуванняЗП липня, ВидРозрахунку = Відпустка,  Співробітник = Іваненко)
             (Реєстратор = НарахуванняЗП липня, ВидРозрахунку = Лікарняний, Співробітник = Іваненко)
        → Звіт «Документи до перерахунку» показує липневий документ.
        → Бухгалтер перепроводить → база перераховується → записи перерахунку очищаються.

Д.10. Антипатерни проєктування регістрів

ID Антипатерн Чому погано Як правильно
А-1 Дата як вимірювання у РН (замість стандартного Період) Таблиця підсумків множиться на кількість дат і зростає нескінченно; віртуальні таблиці Залишки/Обороти ігнорують це вимірювання і дають безглузді результати Використовувати стандартний реквізит Період. Виняток — календарі (Д.3.4), де це РВ, а не РН
А-2 РН «Залишки» там, де потрібна лише історія значення Надлишкові таблиці підсумків, кумулятивні оновлення, вид руху без сенсу РВ періодичний
А-3 РН «Залишки» там, де залишок безглуздий (Продажі, Витрати) Удвічі більший обсяг підсумків, кумулятивні MERGE на всі майбутні періоди РН «Обороти» + агрегати (Д.5.1)
А-4 Реквізит документа продубльовано у вимірювання «щоб фільтрувати у звіті» Кардинальність підсумків × N; типовий випадок — Менеджер/Валюта/Коментар Реквізит регістру + з'єднання з документом у звіті; або окремий регістр «Обороти»
А-5 > 8 вимірювань в одному РН Таблиця підсумків стає більшою за таблицю рухів; проведення сповільнюється в рази Розділити на регістри за призначенням; частину аналітики — в «Обороти» з агрегатами
А-6 Незалежний РВ «у межах секунди» як журнал подій Два записи в ту саму секунду → другий мовчки замістить перший; втрата даних Підпорядкований РВ (реєстратор у ключі) або додати вимірювання-дискримінатор
А-7 Ресурс типу Рядок/Посилання у РН Підсумовування неможливе; підсумки не побудуються Реквізит (не агрегується) або вимірювання (якщо це розріз)
А-8 Прямий UPDATE таблиці підсумків із прикладного коду Розсинхронізація підсумків і рухів; діагностика — тижні Тільки через набори записів. Заборона на рівні прав БД (розділ 12.2)
А-9 Один «універсальний» регістр із вимірюванням ВидОбліку (БУ/ПУ/УО) замість трьох Втричі більша таблиця; конкуренція за блокування між незалежними обліками; неможливо окремо перерахувати підсумки Окремі регістри. Виняток — РБ ПодатковийОблік (Д.6.2), де ВидРізниці — це справді розріз одного обліку
А-10 Функціонально залежне поле як вимірювання (Валюта при БанківськомуРахунку) Надлишковість; ризик неузгоджених даних; зайвий розмір ключа підсумків З'єднання з довідником у запиті (Д.4.4)
А-11 РБ там, де достатньо РН Замість 1 таблиці — 10; ~70 % вартості проведення (Д.8); складність субконто без потреби РН. РБ — лише коли потрібні план рахунків, подвійний запис і кореспонденції
А-12 Вимірювання з nullable без потреби Порожнє посилання — окреме значення ключа; підсумки «розмазуються» на два рядки замість одного denyEmpty: true скрізь, де порожнє значення не має сенсу
А-13 Контроль залишків до запису рухів Класична гонитва: два сеанси проходять контроль і обидва списують у мінус Спершу DataLockWriteNow() → потім контроль (Д.8, ФТ-Т-12)
А-14 periodicity: WithinSecond «про всяк випадок» скрізь Для РВ — неможливість замістити запис за день; для РН — марна точність, більші індекси Обирати мінімально достатню: день для цін/курсів/виписок, секунда — для складських рухів
А-15
(Python)
Запис рухів у циклі: for r in Товари: rs.add(...).write() Порушення АП-1: 100 round-trip'ів замість 1; проведення 300 мс → ~10 с. Невидиме на тестових даних, катастрофічне на продуктиві. Найчастіша помилка при ORM-мисленні Наповнити набір повністю (rs.extend(генератор)), записати один раз. Детектор — Додаток Е.7
А-16
(Python)
Агрегація дельт підсумків у Python (defaultdict + цикл) замість MERGE Порушення АП-2: дані ганяються в застосунок і назад; на 105 рядків — десятки секунд INSERT … ON CONFLICT DO UPDATE одним запитом (Е.3)

Д.11. Чек-лист рев'ю нового регістру

□ Тип регістру обґрунтований за деревом Д.1
□ Кожне вимірювання використовується у фільтрі АБО групуванні звітів
□ Кожен ресурс має сенс при підсумовуванні
□ Немає полів з антипатернів А-1, А-4, А-7, А-10, А-12
□ Оцінка кардинальності підсумків ≤ 10^8 (калькулятор конструктора, ФТ-К-03)
□ Для вимірювань, де порожнє значення безглузде, стоїть denyEmpty
□ Ведучі (master) вимірювання визначені — інакше «висячі» записи при видаленні об'єктів
□ Склад реєстраторів повний; зайвих типів документів немає
□ Угода про знак / вид руху зафіксована в методологічній документації
□ Періодичність мінімально достатня (А-14)
□ Для РН «Обороти» з >4 вимірюваннями визначено ≥1 агрегат
□ Для РБ: балансові/небалансові вимірювання та ресурси розставлені свідомо
□ Для РР: витісняючі та базові види розрахунку в ПВР заповнені; перерахунки визначені
□ Партиціонування ввімкнене, якщо очікується >100 млн записів
□ Написано тест на проведення + скасування + перерахунок підсумків

Додаток Е. Ескізи реалізації ядра на Python

Додаток демонструє, що метамодель розділу 6 і фізична модель розділу 8 реалізовні обраним стеком (розділ 4.3), і фіксує ключові технічні рішення для етапів Е1, Е3, Е5, Е7, Е11. Код — ілюстративний ескіз, а не остаточний API.

Е.1. Маніфест регістру як Pydantic-модель (етап Е1)

YAML-декларації розділу 6.7 і Додатка Д валідуються цією моделлю. Це безпосередньо реалізує ФТ-К-01 (валідація в реальному часі) і частину контролю антипатернів Д.10.

from __future__ import annotations

import warnings
from enum import StrEnum
from typing import Annotated, Literal, Self

from pydantic import BaseModel, ConfigDict, Field, model_validator

IDENT = r"^[^\W\d]\w{0,79}$"          # Unicode-ідентифікатор (кирилиця дозволена), <= 80 симв.


class RegisterKind(StrEnum):
    INFORMATION = "Information"
    ACCUMULATION = "Accumulation"
    ACCOUNTING = "Accounting"
    CALCULATION = "Calculation"


class Periodicity(StrEnum):
    NONPERIODICAL = "Nonperiodical"
    WITHIN_SECOND = "WithinSecond"
    WITHIN_DAY = "WithinDay"
    WITHIN_MONTH = "WithinMonth"
    WITHIN_QUARTER = "WithinQuarter"
    WITHIN_YEAR = "WithinYear"
    BY_RECORDER_POSITION = "ByRecorderPosition"


class WriteMode(StrEnum):
    INDEPENDENT = "Independent"
    SUBORDINATE = "SubordinateToRecorder"


class FieldRole(StrEnum):
    DIMENSION = "Dimension"
    RESOURCE = "Resource"
    ATTRIBUTE = "Attribute"


class RegisterField(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    name: Annotated[str, Field(pattern=IDENT)]
    role: FieldRole
    type: str | list[str]                 # "Довідник.Номенклатура" | ["Документ.А", "Документ.Б"]
    synonym: str | None = None
    index: bool = False
    master: bool = False                  # «Ведуче»
    deny_empty: bool = False
    nullable: bool = False
    use_in_totals: bool = True
    # РБ
    balance: bool = False
    accounting_flag: str | None = None
    # РР
    base_dimension: bool = False

    @property
    def is_composite(self) -> bool:
        return isinstance(self.type, list) and len(self.type) > 1


class Register(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: Annotated[str, Field(pattern=IDENT)]
    kind: RegisterKind
    synonym: str | None = None
    periodicity: Periodicity = Periodicity.NONPERIODICAL
    write_mode: WriteMode = WriteMode.SUBORDINATE
    dimensions: list[RegisterField] = []
    resources: list[RegisterField] = []
    attributes: list[RegisterField] = []
    recorders: list[str] = []
    # РН
    accumulation_kind: Literal["Balance", "Turnover"] | None = None
    enable_balance_totals: bool = True
    enable_turnover_totals: bool = False
    turnover_totals_period: Literal["Day", "Month"] | None = None
    # РБ
    chart_of_accounts: str | None = None
    correspondence: bool = True
    max_extdim_count: Annotated[int, Field(ge=0, le=5)] = 3
    # РР
    calculation_types_plan: str | None = None
    action_period: bool = False
    base_period: Literal["None", "ByActionPeriod", "ByRegistrationPeriod"] = "None"

    # ---------- крос-полева валідація (розділи 6.2–6.6) ----------
    @model_validator(mode="after")
    def _validate(self) -> Self:
        if self.write_mode is WriteMode.INDEPENDENT and self.kind is not RegisterKind.INFORMATION:
            raise ValueError("Незалежний режим запису дозволено лише для регістру відомостей")

        if self.kind is RegisterKind.ACCUMULATION:
            if self.accumulation_kind is None:
                raise ValueError("Для регістру накопичення обов'язковий accumulation_kind")
            if self.periodicity is Periodicity.NONPERIODICAL:
                raise ValueError("Регістр накопичення не може бути неперіодичним")
            if self.accumulation_kind == "Turnover" and self.enable_balance_totals:
                raise ValueError("Підсумки залишків несумісні з видом «Обороти»")

        if self.kind is RegisterKind.ACCOUNTING and not self.chart_of_accounts:
            raise ValueError("Для регістру бухгалтерії обов'язковий chart_of_accounts")

        if self.kind is RegisterKind.CALCULATION:
            if not self.calculation_types_plan:
                raise ValueError("Для регістру розрахунку обов'язковий calculation_types_plan")
            if not any(f.base_dimension for f in self.dimensions) and self.base_period != "None":
                raise ValueError("Задано base_period, але жодне вимірювання не позначене base_dimension")

        # ресурс має бути підсумовуваним (антипатерн А-7)
        if self.kind is not RegisterKind.INFORMATION:
            for r in self.resources:
                if not _is_numeric(r.type):
                    raise ValueError(f"Ресурс «{r.name}»: тип {r.type} не підсумовується (А-7). "
                                     f"Використайте роль Attribute або Dimension")

        # обмеження розділу 6.6
        if len(self.dimensions) > 20:
            raise ValueError(f"Вимірювань {len(self.dimensions)} > 20 (розділ 6.6)")

        # попередження (ФТ-К-01: warnings, а не помилки)
        if len(self.dimensions) > 8:
            warnings.warn(f{self.name}»: {len(self.dimensions)} вимірювань — "
                          f"ризик розростання таблиці підсумків (антипатерн А-5)")
        for d in self.dimensions:
            if d.type == "Дата" and self.kind is RegisterKind.ACCUMULATION:
                warnings.warn(f{self.name}.{d.name}»: Дата як вимірювання РН — антипатерн А-1")
        return self

Що це дає безкоштовно: валідацію YAML, JSON Schema для web-конструктора (Register.model_json_schema()), типізовані об'єкти для генератора DDL, детальні повідомлення про помилки з шляхом до поля.

Е.2. Генератор DDL на SQLAlchemy Core (етап Е1)

from collections.abc import Callable, Sequence

from sqlalchemy import (Boolean, Column, DateTime, Index, Integer, MetaData, Numeric,
                        PrimaryKeyConstraint, SmallInteger, String, Table, Uuid, text)
from sqlalchemy.types import TypeEngine

PRIMITIVE: dict[str, Callable[[RegisterField], TypeEngine]] = {
    "Число": lambda f: Numeric(f.precision, f.scale),
    "Рядок": lambda f: String(f.length),
    "Дата": lambda f: DateTime(timezone=False),
    "Булево": lambda f: Boolean(),
    "УНІ": lambda f: Uuid(),
}


def columns_for_field(f: RegisterField, prefix: str) -> list[Column]:
    """Одне поле метаданих -> 1..6 фізичних колонок (розділ 8.2)."""
    if f.is_composite:                                   # складений тип: розділ 8.2.2
        return [
            Column(f"{prefix}_t", SmallInteger, nullable=False),
            Column(f"{prefix}_b", Boolean),
            Column(f"{prefix}_n", Numeric(38, 10)),
            Column(f"{prefix}_s", String(f.max_string_len)),
            Column(f"{prefix}_d", DateTime),
            Column(f"{prefix}_r", Uuid),
        ]
    if f.is_reference:                                   # просте посилання
        return [Column(f"{prefix}_r", Uuid, nullable=f.nullable)]
    base = f.base_type
    return [Column(prefix, PRIMITIVE[base](f), nullable=f.nullable)]


def build_movement_table(reg: Register, md: MetaData) -> Table:
    """Метаопис -> Table. Розділи 8.4–8.7."""
    cols: list[Column] = []

    if reg.write_mode is WriteMode.SUBORDINATE:
        cols += [
            Column("rec_t", SmallInteger, nullable=False),
            Column("rec_r", Uuid, nullable=False),
            Column("line_no", Integer, nullable=False),
            Column("active", Boolean, nullable=False, server_default=text("true")),
        ]
    if reg.periodicity is not Periodicity.NONPERIODICAL:
        cols.append(Column("period", DateTime, nullable=False))
    if reg.kind is RegisterKind.ACCUMULATION and reg.accumulation_kind == "Balance":
        cols.append(Column("mvt", SmallInteger, nullable=False))   # ВидРуху
    if reg.kind is RegisterKind.CALCULATION:
        cols += [Column("calc_type_r", Uuid, nullable=False),
                 Column("reversal", Boolean, nullable=False, server_default=text("false"))]
        if reg.action_period:
            cols += [Column("act_begin", DateTime, nullable=False),
                     Column("act_end", DateTime, nullable=False)]
        if reg.base_period != "None":
            cols += [Column("base_begin", DateTime), Column("base_end", DateTime)]

    for f in reg.all_fields:                              # dimensions + resources + attributes
        cols += columns_for_field(f, reg.column_prefix(f))   # 'd3', 'r1', 'a2'

    t = Table(reg.physical_table, md, *cols,
              PrimaryKeyConstraint(*primary_key_columns(reg),
                                   name=f"pk_{reg.physical_table}"),
              postgresql_partition_by=partition_clause(reg))

    for f in reg.dimensions:
        if f.index:
            Index(f"ix_{reg.physical_table}_{reg.column_prefix(f)}",
                  *index_columns(reg, f),
                  postgresql_where=t.c.active if "active" in t.c else None)
    return t

НФВ-16 / НФВ-17 (дві СКБД, ізольований шар діалекту) — у три рядки:

from sqlalchemy.dialects import mssql, postgresql
from sqlalchemy.schema import CreateTable

md = MetaData()
t = build_movement_table(reg_goods, md)

print(CreateTable(t).compile(dialect=postgresql.dialect()))   # -> CREATE TABLE rg_a_0012 (...)
print(CreateTable(t).compile(dialect=mssql.dialect()))        # -> той самий об'єкт у T-SQL

Шаблон:Note

Реалізація ФТ-К-02 («Показати SQL» у конструкторі) — той самий виклик CreateTable(t).compile(...), виведений у UI.

Е.3. Інкрементальне оновлення підсумків (етап Е3)

Реалізація алгоритму 10.2 з дотриманням АП-1 (один запит на весь набір) і ФТ-34 (детермінований порядок проти дедлоків).

from operator import itemgetter
from collections.abc import Sequence

from sqlalchemy import Connection, Table
from sqlalchemy.dialects.postgresql import insert as pg_insert


def merge_totals(conn: Connection, totals: Table, rows: list[dict],
                 key: Sequence[str], res: Sequence[str]) -> None:
    """АП-1 + АП-2: одна операція на весь набір дельт; складання виконує СКБД."""
    if not rows:
        return

    rows.sort(key=itemgetter(*key))          # ФТ-34: детермінований порядок -> без дедлоків

    stmt = pg_insert(totals).values(rows)
    stmt = stmt.on_conflict_do_update(
        index_elements=list(key),
        set_={r: totals.c[r] + stmt.excluded[r] for r in res},   # r1 = t.r1 + EXCLUDED.r1
    )
    conn.execute(stmt)                       # 1 round-trip незалежно від len(rows)

Для MS SQL — той самий інтерфейс, інша реалізація (MERGE ... WHEN MATCHED THEN UPDATE) за діалектним диспетчером. Ізоляція — на рівні функції, прикладний код не знає про діалект.

Чому НЕ ORM: ORM-варіант (session.merge(obj) у циклі) дав би len(rows) round-trip'ів — пряме порушення АП-1 і зростання НФВ-04 на два порядки. Саме тому ORM заборонений у розділі 4.3.

Е.4. Транслятор віртуальних таблиць (етап Е5)

from functools import lru_cache

from lark import Lark, Transformer
from sqlalchemy import Select, func, literal, select, union_all

GRAMMAR = r"""
    ?query        : "ВИБРАТИ"i select_list "З"i source [where] [group_by] [order_by]
    source        : virtual_table | plain_table
    virtual_table : REGISTER_PATH "." VT_NAME "(" [vt_args] ")" ["ЯК"i alias]
    VT_NAME       : "Залишки"i | "Обороти"i | "ЗалишкиІОбороти"i
                  | "ЗрізОстанніх"i | "ЗрізПерших"i | "ОборотиДтКт"i
                  | "ДаніГрафіка"i | "ФактичнийПеріодДії"i | "БазаРозрахунку"i
    vt_args       : (expr | )  ("," (expr | ))*        // позиційні, можуть бути порожні
    ...
"""


class QueryCompiler:
    def __init__(self, schema: SchemaRegistry) -> None:
        self._parser = Lark(GRAMMAR, parser="lalr", start="query")
        self._schema = schema

    @lru_cache(maxsize=2048)                     # АП-4: кеш скомпільованих запитів
    def compile(self, text: str, param_signature: tuple[str, ...]) -> Select:
        tree = self._parser.parse(text)
        return _ToSQLAlchemy(self._schema).transform(tree)

    def balances(self, reg: Register, period, conditions: list) -> Select:
        """Реалізація алгоритму 10.3 в термінах SQLAlchemy Core."""
        tb = self._schema.totals_balance(reg)
        mv = self._schema.movements(reg)
        dims = [tb.c[c] for c in self._schema.dimension_columns(reg)]

        boundary = (select(func.coalesce(func.max(tb.c.period), MIN_PERIOD))
                    .where(tb.c.period <= period)
                    .scalar_subquery())

        base = (select(*dims, *[func.sum(tb.c[r]).label(r) for r in reg.resource_columns])
                .where(tb.c.period == boundary, *conditions)      # ФТ-13: push-down
                .group_by(*dims))

        signed = lambda c: case((mv.c.mvt == 0, mv.c[c]), else_=-mv.c[c])
        tail = (select(*[mv.c[d.name] for d in dims],
                       *[func.sum(signed(r)).label(r) for r in reg.resource_columns])
                .where(mv.c.active, mv.c.period > boundary, mv.c.period <= period, *conditions)
                .group_by(*[mv.c[d.name] for d in dims]))

        u = union_all(base, tail).subquery()
        return (select(*[u.c[d.name] for d in dims],
                       *[func.sum(u.c[r]).label(f"{r}Залишок") for r in reg.resource_columns])
                .group_by(*[u.c[d.name] for d in dims])
                .having(or_(*[func.sum(u.c[r]) != 0 for r in reg.resource_columns])))

Ризик Р1 (немає мови запитів) на Python пом'якшується: навіть якщо повноцінна мова запитів не буде реалізована в Е5, QueryCompiler.balances() залишається придатним як програмний API — прикладний код викликає його напряму й отримує Select, який можна далі з'єднувати засобами SQLAlchemy Core. Це план «Б» для Р1, недоступний у мовах без подібної бібліотеки.

Е.5. Витіснення: гарячий шлях за інтерфейсом (етапи Е7, Е11)

Реалізація принципу АП-3 на найризикованішому вузлі (Р5 + Р10).

from collections import defaultdict
from collections.abc import Callable, Sequence
from datetime import date, timedelta
from typing import Protocol
from uuid import UUID

Interval = tuple[date, date]
ONE_DAY = timedelta(days=1)


class DisplacementEngine(Protocol):
    """АП-3. Бюджет (АП-4): <= 3 c на 10 тис. ключів × ~10 записів."""

    def compute(self,
                records: Sequence[CalcRecord],
                displaces: Callable[[UUID, UUID], bool]) -> dict[RecordKey, list[Interval]]:
        """Повертає фактичні періоди дії для кожного запису (розділ 10.4)."""
        ...


def subtract(intervals: list[Interval], cut: Interval) -> list[Interval]:
    """Різниця множин інтервалів. Найгарячіша функція платформи."""
    out: list[Interval] = []
    cb, ce = cut
    for b, e in intervals:
        if ce < b or cb > e:                 # не перетинаються
            out.append((b, e))
            continue
        if b < cb:
            out.append((b, cb - ONE_DAY))    # «хвіст» зліва
        if e > ce:
            out.append((ce + ONE_DAY, e))    # «хвіст» справа
    return out


class PyDisplacementEngine:
    """Еталонна реалізація. Заміна на Cython/Rust не змінює виклик (АП-3)."""

    def compute(self, records, displaces):
        by_key: dict[DimKey, list[CalcRecord]] = defaultdict(list)
        for r in records:
            if r.active:
                by_key[r.dim_key].append(r)

        result: dict[RecordKey, list[Interval]] = {}
        for Z in by_key.values():
            if len(Z) > 500:                 # розділ 10.4: перехід на sweep-line
                result |= SweepLineEngine().compute(Z, displaces)
                continue
            Z.sort(key=lambda r: (r.priority, r.period, r.line_no))
            for z in Z:
                intervals = [(z.act_begin, z.act_end)]
                for w in Z:
                    if w is z or not displaces(w.calc_type, z.calc_type):
                        continue
                    if not _registration_periods_intersect(w, z):
                        continue
                    intervals = subtract(intervals, (w.act_begin, w.act_end))
                    if not intervals:
                        break                # повністю витіснений
                result[z.key] = intervals
        return result

Property-based тести (мітигація Р5 — найпомилковішого вузла):

from hypothesis import given
from hypothesis import strategies as st

dates = st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))
intervals = st.lists(st.tuples(dates, dates).map(lambda t: tuple(sorted(t))), max_size=20)


@given(ivs=intervals, cut=st.tuples(dates, dates).map(lambda t: tuple(sorted(t))))
def test_subtract_invariants(ivs: list[Interval], cut: Interval) -> None:
    out = subtract(ivs, cut)
    assert all(b <= e for b, e in out), "некоректні межі"
    assert not any(_overlaps(i, cut) for i in out), "витіснений інтервал залишився"
    # збереження міри: те, що прибрали, дорівнює перетину з cut
    assert _measure(out) + _measure(_intersect_all(ivs, cut)) == _measure(ivs)

Бенчмарк як блокер приймання (АП-4):

@pytest.mark.benchmark(group="displacement")
def test_displacement_budget(benchmark, records_10k_keys) -> None:
    """Вводиться в CI з етапу Е7. Перевищення бюджету -> червоний білд."""
    benchmark(PyDisplacementEngine().compute, records_10k_keys, displaces)
    assert benchmark.stats.stats.mean < 3.0, (
        f"Порушено бюджет АП-4: {benchmark.stats.stats.mean:.2f} c > 3.0 c. "
        f"Розгляньте sweep-line, Cython або перенесення у SQL (daterange + GiST)."
    )

Е.6. Генерація .pyi-стабів (ФТ-К-07, етап Е1)

Проблема: поля регістру доступні динамічно (rs.new(Номенклатура=…)), тому mypy не бачить одруків і помилок типів — це і є ризик Р11.

Рішення: конструктор генерує стаби з метаопису при кожній зміні конфігурації.

# k2/generated/registers/ТовариНаСкладах.pyi   -- АВТОЗГЕНЕРОВАНО. Не редагувати.
from datetime import datetime
from decimal import Decimal

from k2.catalogs import Номенклатура, Склади, ХарактеристикиНоменклатури
from k2.documents import ПрихіднаНакладна, ВведенняЗалишків
from k2.registers import AccumulationMovementType, Record, RecordSet


class ТовариНаСкладахRecord(Record):
    Період: datetime
    ВидРуху: AccumulationMovementType
    Номенклатура: Номенклатура
    Склад: Склади
    Характеристика: ХарактеристикиНоменклатури | None
    Партія: ПрихіднаНакладна | ВведенняЗалишків | None      # складений тип
    Кількість: Decimal
    Сума: Decimal
    Коментар: str


class ТовариНаСкладахRecordSet(RecordSet[ТовариНаСкладахRecord]):
    def new(self, *, Період: datetime, ВидРуху: AccumulationMovementType,
            Номенклатура: Номенклатура, Склад: Склади,
            Характеристика: ХарактеристикиНоменклатури | None = None,
            Партія: ПрихіднаНакладна | ВведенняЗалишків | None = None,
            Кількість: Decimal = ..., Сума: Decimal = ...,
            Коментар: str = ...) -> ТовариНаСкладахRecord: ...

Що це ловить у CI ще до запуску:

Помилка прикладного коду Без стабів Зі стабами
rs.new(Номенклатур=x) — одрук у назві поля Виняток у рантаймі при проведенні на продуктиві mypy: unexpected keyword argument
m.Кількість = "багато" InvalidOperation при записі в БД mypy: incompatible type str, expected Decimal
m.Склад = номенклатура — переплутані посилання FK-помилка або, гірше, тихо неправильні дані mypy: incompatible type
Видалили вимірювання з метаданих, код лишився Виняток при проведенні Червоний білд одразу після зміни конфігурації

Автодоповнення в IDE — приємний побічний ефект, який частково компенсує відсутність повноцінного конфігуратора рівня 1С на ранніх етапах.

Е.7. Автоматичний детектор порушень АП-1 (етап Е3, CI)

Принцип АП-1 марний без механічної перевірки — розробник порушить його ненавмисно.

# tests/architecture/test_ap1_no_row_by_row.py

@pytest.mark.parametrize("scenario", POSTING_SCENARIOS)
def test_roundtrips_do_not_scale_with_rows(db_counter, scenario) -> None:
    """АП-1: кількість звернень до СКБД НЕ залежить від кількості рядків документа."""
    with db_counter() as c10:
        scenario.build(rows=10).post()
    with db_counter() as c100:
        scenario.build(rows=100).post()

    assert c100.roundtrips <= c10.roundtrips + 2, (
        f"{scenario.name}: звернень до БД зросло {c10.roundtrips} -> {c100.roundtrips} "
        f"при зростанні рядків у 10 разів. Порушення АП-1 — див. розділ 4.4."
    )


class RowByRowGuard:
    """Runtime-запобіжник для dev/staging: виняток при >N однакових запитів у транзакції."""

    def __init__(self, threshold: int = 20) -> None:
        self._counts: Counter[str] = Counter()
        self._threshold = threshold

    def before_execute(self, conn, clauseelement, *args) -> None:
        fingerprint = _statement_fingerprint(clauseelement)   # SQL без літералів
        self._counts[fingerprint] += 1
        if self._counts[fingerprint] == self._threshold:
            raise ArchitectureViolation(
                f"Один і той самий запит виконано {self._threshold} разів у межах транзакції.\n"
                f"Ймовірне порушення АП-1 («жодного рядка за раз»).\n{fingerprint}"
            )

Це — найдешевша страховка проєкту. Порушення АП-1 не видно на тестових даних (10 рядків — усе швидко) і катастрофічне на продуктиві (100 рядків × 5 регістрів). Детектор вводиться з етапу Е3, а не за результатами НТ-03.

Е.8. Структура репозиторію ядра

k2-registers/
├── src/k2/
│   ├── metadata/          # Е.1: Pydantic-моделі, репозиторій, валідація
│   │   ├── models.py      #   Register, RegisterField, ...
│   │   ├── repository.py  #   md_* таблиці
│   │   └── validators.py  #   ФТ-К-01, антипатерни Д.10
│   ├── schema/            # Е.2: генерація DDL
│   │   ├── builder.py     #   метаопис -> SQLAlchemy Table
│   │   ├── dialects/      #   НФВ-17: postgres.py, mssql.py
│   │   └── restructure.py #   Е10: Alembic-інтеграція
│   ├── runtime/           # набори записів, проведення, блокування
│   │   ├── recordset.py
│   │   ├── posting.py     #   розділ 7.1
│   │   ├── totals.py      #   Е.3: merge_totals
│   │   └── locks.py       #   ФТ-32
│   ├── query/             # Е.4: lark-граматика + компілятор
│   │   ├── grammar.lark
│   │   ├── compiler.py
│   │   └── virtual/       #   balances.py, turnovers.py, slices.py, calc_base.py
│   ├── calculation/       # Е.5: витіснення (гарячий шлях)
│   │   ├── protocols.py   #   DisplacementEngine
│   │   ├── engine_py.py   #   еталонна реалізація
│   │   └── engine_native/ #   [резерв] Cython/Rust — тільки за результатом бенчмарку
│   ├── codegen/           # Е.6: генерація .pyi-стабів
│   └── diagnostics/       # Е.7: RowByRowGuard, перевірка цілісності (ФТ-41)
├── tests/
│   ├── unit/
│   ├── property/          # hypothesis: інтервальна арифметика (Р5)
│   ├── architecture/      # Е.7: детектор АП-1
│   ├── benchmarks/        # АП-4: бюджети гарячих шляхів (gate у CI)
│   └── integration/       # testcontainers: PostgreSQL + MS SQL (Р7)
└── pyproject.toml         # uv, ruff, mypy --strict

Кінець документа. Версія 1.2. Підлягає узгодженню з архітектором платформи K2 ERP до затвердження.