0036. Модель движения товара: журнал событий по звеньям + агрегация реестра партиций
Копия ADR из репозитория:
backend/docs/adr/0036-inventory-movement-model.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted (2026-07-09 — реализация ADR-0031, вариант B)
- Дата: 2026-07-09
- Связанные задачи: OP-130 (модель движения); соседи — OP-128 (проекция остатка), OP-129 (синк WB), OP-131 (учёт операций), OP-132 (API)
- Связанные ADR: ADR-0031 (метод учёта остатков — журнал операций + сверка), ADR-0008 (партиционирование fact-таблиц), ADR-0030/OP-91 (декларативный реестр партиций)
Контекст и проблема
ADR-0031 закрепил учёт остатков журналом операций + проекцией. OP-130 поставляет саму модель движения. Два вопроса требовали решения на уровне реализации:
- Форма записи движения по цепочке ТЗ 5.11 (поставщик → склад селлера → … → клиент).
- Как зарегистрировать помесячно-партиционируемую fact-таблицу журнала в менеджере
партиций (OP-91), если декларативный реестр
PARTITIONED_TABLESуже единолично держитRnpRegistryModule.
Решение
1. Журнал «событие-на-звено» со знаковой дельтой
inventory_movements — append-only fact-таблица; строка = один знаковый эффект (delta)
на ячейку остатка (кабинет, nmId, склад, статус). Перемещение между звеньями = пара
строк (−N источник / +N приёмник, один type=transfer). Остаток — проекция
Σ delta GROUP BY (кабинет, nmId, склад, статус); идемпотентно пересобираема (ADR-0031).
Звено цепочки — существующий enum stock_status (единый словарь мест с проекцией OP-128).
Рассмотрена альтернатива «строка-переход» (from_*/to_* в одной строке): отвергнута —
усложняет пересборку проекции (каждая строка бьёт две ячейки) и хуже ложится на колонки
«количество, дельта» из ADR-0031.
Расширяемость под «иные статусы» (ТЗ): MovementType.Other + свободное note для
неклассифицированных операций без миграции; словари расширяются ALTER TYPE ... ADD VALUE.
Партиционирование: PARTITION BY RANGE (occurred_at) помесячно (ADR-0008), составной PK
(id, occurred_at) — Postgres требует ключ партиции в PK. Прошлые партиции менеджер не
создаёт (как RNP daily-mart); маппинг исторических дат в актуальное окно — за OP-131.
2. Единый владелец реестра партиций — композиционный агрегатор
PartitionManagerService читает PARTITIONED_TABLES одиночным @Inject с коэрсией
single→array (multi-провайдеры в этой сборке NestJS 11 не агрегируются между модулями).
Значит токен должен иметь единственного провайдера, иначе второй глобальный провайдер
вытеснит первый.
Введён @Global PartitionRegistryModule (корень композиции) — единственный провайдер
токена, агрегирующий плоские const-массивы владельцев (RNP_PARTITIONED_TABLES,
INVENTORY_PARTITIONED_TABLES). RnpRegistryModule перестал привязывать токен (оставил
только const). Реализует задокументированный контракт «модули-владельцы регистрируют спеки»
без коллизии.
Последствия
- Положительные: пересборка проекции = тривиальная агрегация журнала; расширяемость под «иные статусы»; один владелец реестра партиций — новые fact-модули добавляют свой массив в агрегатор без правок соседних доменов и без риска потери спеков.
- Trade-offs: запись движения с
occurred_atвне окна партиций упадётno partition found— ответственность слоя учёта (OP-131) маппить исторические даты; ретенция журнала 24 мес (как РНП) — при необходимости длиннее решается точечно.