Skip to content

Архитектура: ОДН / дельта ОДПУ−ИПУ, регистр дома, годовая корректировка

Целевая схема для учёта положительной и отрицательной разницы между общедомовым прибором и суммой индивидуальных потреблений (и нормативов), в терминах ПП РФ №354 (ОДН / КР на СОИ).

Размещение данных: billing-service (оперативный учёт периодов и распределения к ЛС) + чтение справочников и показаний из core-service.


1. Принципы

ПринципСодержание
Дом — отдельный контур объёмаПеренос «минуса» ОДН и учёт «плюса» до распределения — не в разрезе ЛС, а в регистре по дому и ресурсу (линии ОДН).
ЛС — только неотрицательные начисления ОДН в квитанцииПри отрицательной сырой дельте жителям за месяц не выставляется отрицательная строка ОДН; дельта уходит в перенос (carry).
Идемпотентность периодаОдна запись «снимка месяца» на пару (дом, услуга ОДН, период); перерасчёт периода перезаписывает снимок и перевыставляет Charge (черновик → применение).
Связь с доменом corehouse_id и house_service_id (общедомовая услуга с is_common_service = true, связь с индивидуальной через related_individual_service_id) уже есть в core; billing хранит ссылки по ID.

2. Целевая схема таблиц (PostgreSQL)

2.1 Регистр переноса объёма — house_resource_carry_balances

Один текущий остаток объёма (в единицах учёта ресурса: м³, Гкал, кВт·ч) на пару дом + линия ОДН. Остаток знаковый: отрицательный = «дом «должен» жителям по объёму» после прошлых периодов; положительный = «есть нераспределённый плюс» после взаимозачёта.

sql
-- Регистр переноса дельты по дому (объём, не рубли)
CREATE TABLE house_resource_carry_balances (
    id                      BIGSERIAL PRIMARY KEY,
    house_id                BIGINT NOT NULL,              -- FK → core.houses
    house_service_id        BIGINT NOT NULL,              -- FK → core.house_services (ОДН-линия)
    resource_unit_code      VARCHAR(32) NOT NULL,         -- м3, gcal, kwh — денормализация для контроля
    carry_volume            NUMERIC(20, 6) NOT NULL
        DEFAULT 0,                                        -- знаковый остаток к зачёту в след. месяцах
    last_reconciled_period  VARCHAR(7),                   -- YYYY-MM последнего полного расчёта
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE (house_id, house_service_id)
);

CREATE INDEX idx_house_resource_carry_house ON house_resource_carry_balances (house_id);

Семантика carry_volume после расчёта месяца:

  • К существующему carry_volume прибавляется сырая дельта месяца (после нормативного ограничения «плюса», см. ниже), минус то, что фактически распределено на ЛС за месяц.
  • Упрощённо: carry_volume_new = carry_volume_old + volume_available_for_distribution - distributed_sum
    где volume_available_for_distribution уже учитывает правило «отрицательная сырая дельта → к переносу, жителям 0».

Альтернатива: хранить только carry_volume и вести полную историю в house_odn_period_facts (рекомендуется).


2.2 Факты расчёта за период — house_odn_period_facts

Журнал по месяцу: входы (ОДПУ, ΣИПУ, нормативы), промежуточные величины, сколько ушло жителям, сколько в перенос, сверхнорматив УК.

sql
CREATE TABLE house_odn_period_facts (
    id                          BIGSERIAL PRIMARY KEY,
    house_id                    BIGINT NOT NULL,
    house_service_id            BIGINT NOT NULL,          -- ОДН house_service
    billing_period              VARCHAR(7) NOT NULL,      -- YYYY-MM

    -- Входные объёмы за период (уже в единицах ресурса после методики расчёта месяца)
    volume_odpu                 NUMERIC(20, 6) NOT NULL,
    volume_sum_ipu              NUMERIC(20, 6) NOT NULL DEFAULT 0,
    volume_sum_normative        NUMERIC(20, 6) NOT NULL DEFAULT 0,

    -- Сырая дельта: V_odpu - Σ V_ipu - Σ V_norm
    raw_delta_volume            NUMERIC(20, 6) NOT NULL,

    -- После правил ПП 354 для «плюса»: потолок по нормативу ОДН (если применимо)
    normative_cap_volume        NUMERIC(20, 6),           -- NULL = не применялось
    volume_after_normative_cap  NUMERIC(20, 6) NOT NULL,  -- сколько максимум можно отнести на жителей «плюсом»
    uk_absorption_volume        NUMERIC(20, 6) NOT NULL DEFAULT 0, -- сверхнорматив / убыток УК (объём)

    -- Взаимозачёт с регистром на начало месяца
    carry_opening_volume        NUMERIC(20, 6) NOT NULL DEFAULT 0,
    volume_net_before_distribution NUMERIC(20, 6) NOT NULL, -- что реально распределяем пропорционально S

    -- Распределение
    distributed_volume          NUMERIC(20, 6) NOT NULL DEFAULT 0, -- Σ по ЛС (должно совпасть с суммой детализации)
    carry_closing_volume        NUMERIC(20, 6) NOT NULL,   -- остаток регистра после месяца (= новый carry)

    -- Управление и аудит
    calculation_job_id          BIGINT,                   -- FK → billing calculation_jobs
    protocol_oss_full_fact_flag BOOLEAN NOT NULL DEFAULT FALSE,   -- «100% факта» по ОСС / ТСЖ (упрощённо)
    comment                     TEXT,
    created_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at                  TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE (house_id, house_service_id, billing_period)
);

CREATE INDEX idx_house_odn_period_house_period ON house_odn_period_facts (house_id, billing_period);

Заполнение volume_after_normative_cap и uk_absorption_volume (положительная сыря дельта):

  • Если действует ограничение «не более норматива ОДН» и нет флага полного факта:
    volume_after_normative_cap = min(raw_delta_volume, normative_cap_volume)
    uk_absorption_volume = max(0, raw_delta_volume - normative_cap_volume) (объём на УК).
  • Если протокол ОСС / ТСЖ: volume_after_normative_cap = raw_delta_volume, uk_absorption_volume = 0.

Отрицательная сырая дельта:

  • volume_after_normative_cap = 0 (на жителей в этом месяце не распределяем «минус» как начисление).
  • Весь raw_delta_volume (отрицательный) участвует в переносе: увеличивает «долг дома перед жителями по объёму» в carry_closing_volume (через формулу ниже).

Взаимозачёт с переносом (псевдокод):

text
opening = house_resource_carry_balances.carry_volume  // на начало месяца
capped_plus = volume_after_normative_cap             // >= 0

// Объём, который пойдёт в распределение по площади (не может быть отрицательным в квитанции)
to_distribute = max(0, capped_plus + opening)      // «плюс» съедает накопленный «минус»

distributed = Σ по ЛС долей (Si/S_total) * to_distribute

carry_closing = opening + raw_delta_effective - distributed
// где raw_delta_effective для учёта: для отрицательной сырой дельты без распределения:
//   фактически carry_closing = opening + raw_delta_volume (если capped_plus=0 и distributed=0)
// Унифицированно: carry_closing = opening + (capped_plus - distributed) + (raw_delta_volume - capped_plus - uk_absorption_volume)
// Рекомендуется в коде разбить на понятные шаги и покрыть тестами LS10 и положительный ОДН.

Точную формулу фиксируйте в сервисе расчёта одним модульным блоком + golden tests.


2.3 Детализация по ЛС — house_odn_period_account_allocations

Сколько объёма ОДН получил каждый ЛС за период (для сверки с Charge и актами).

sql
CREATE TABLE house_odn_period_account_allocations (
    id                      BIGSERIAL PRIMARY KEY,
    house_odn_period_fact_id BIGINT NOT NULL
        REFERENCES house_odn_period_facts(id) ON DELETE CASCADE,
    personal_account_id     BIGINT NOT NULL,
    share_area_m2           NUMERIC(18, 4) NOT NULL,
    allocated_volume        NUMERIC(20, 6) NOT NULL,
    amount_rub              NUMERIC(18, 2),               -- опционально, если дублируете из Charge
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE (house_odn_period_fact_id, personal_account_id)
);

CREATE INDEX idx_odn_alloc_pa ON house_odn_period_account_allocations (personal_account_id);

2.4 Годовая корректировка ОДН — house_odn_annual_adjustments

Разовая операция за год (п. 44 / методика региона): сводка 12 месяцев, доначисление или возврат.

sql
CREATE TABLE house_odn_annual_adjustments (
    id                      BIGSERIAL PRIMARY KEY,
    house_id                BIGINT NOT NULL,
    house_service_id        BIGINT NOT NULL,
    calendar_year           INT NOT NULL,                 -- например 2025 за периоды 2025-01 … 2025-12
    total_volume_delta_year NUMERIC(20, 6) NOT NULL,    -- итог по фактам/регистру (методика)
    adjustment_volume       NUMERIC(20, 6) NOT NULL,    -- что разово доначислить (+) или сторнировать (-)
    status                  VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT | APPLIED
    applied_at              TIMESTAMPTZ,
    billing_cycle_id        BIGINT,                     -- связь с проводками при применении
    comment                 TEXT,
    created_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE (house_id, house_service_id, calendar_year)
);

Детализация по ЛС годовой корректировки — отдельная таблица или строки Charge с типом annual_odn_adjustment.


3. Рекомендуемая архитектура модулей (billing-service)

┌─────────────────────────────────────────────────────────────────┐
│                    Calculation pipeline (месяц)                    │
└─────────────────────────────────────────────────────────────────┘

  ├─ 1. Load core: house, house_services (индивид. + ОДН), meters, readings

  ├─ 2. OdnVolumeEngine (дом × ОДН-услуга)
  │      • Считает volume_odpu, volume_sum_ipu, volume_sum_normative
  │      • raw_delta_volume
  │      • normative_cap, OSS flag → volume_after_normative_cap, uk_absorption_volume
  │      • FOR UPDATE house_resource_carry_balances
  │      • to_distribute, distributed по Si/S_total
  │      • UPSERT house_odn_period_facts + allocations
  │      • UPDATE carry_volume = carry_closing

  ├─ 3. TariffEngine → amount на ЛС из allocated_volume (или один шаг с п.2)

  ├─ 4. Persist Charge (DRAFT) по house_service_id ОДН + personal_account_id

  └─ 5. Apply billing cycle → Ledger (без отрицательных ОДН-строк; корректировки — отдельными типами проводок)
  • Идempotency: пересчёт месяца удаляет/перезаписывает house_odn_period_facts + allocations и связанные Charge этого job/периода.
  • Core не хранит перенос объёма — только счётчики и услуги; billing — источник истины по регистру и фактам ОДН.

4. Связь с существующими сущностями Nimbus

Уже естьРоль
HouseService (ОДН + related_individual_service_id)Идентификация линии ОДН и связи с индивидуальной услугой
UtilityService.CalculationFormulaCode + FormulaFlags (OdnCalculationRule, …)Правила норматива / метода; читает OdnVolumeEngine
Meter + показанияВходы volume_odpu, volume_sum_ipu
ChargeИтог в рублях по ЛС за период
house_resource_carry_balances (новая)Перенос знаковой дельты между месяцами

5. Расширения (по мере зрелости)

  • Отдельное поле на houses или house_services: odn_full_fact_oss_protocol_ref (ссылка на файл/номер протокола).
  • Версионирование норматива ОДН по региону и дате — справочник в core или billing.
  • Отчёт «сверка ОДН по дому за год» из house_odn_period_facts + annual_adjustments.

6. Миграция и внедрение

  1. Добавить таблицы в billing-service через GORM AutoMigrate + SQL-миграция для индексов/комментариев.
  2. Реализовать OdnVolumeEngine и тесты (в т.ч. сценарий LS10 — отрицательная дельта, распределение 0, рост carry_volume).
  3. Подключить к processCalculation после базового контура Charge.

7. Реализация в коде (текущее состояние)

КомпонентРасположение
Модели GORMbilling-service/internal/models/house_resource_carry_balance.go, house_odn_period_fact.go, house_odn_period_account_allocation.go, house_odn_annual_adjustment.go
Автомиграцияbilling-service/internal/database/migrate.go
SQL-миграция (PostgreSQL)billing-service/migrations/006_house_odn_tables.sql
Расчёт дельты, норматива, переноса, долей по площадиbilling-service/internal/odn/calc.go (ComputePeriod), юнит-тесты calc_test.go
Сохранение снимка периода + обновление carrybilling-service/internal/odn/persist.go (SavePeriodResult, GetCarryVolume)

Формула переноса в коде: CarryClosing = CarryOpening + RawDelta − Distributed − UKAbsorption (см. ComputePeriod).

Дальше: загрузка объёмов ОДПУ/ИПУ из core, вызов GetCarryVolumeComputePeriodSavePeriodResult из конвейера расчёта, затем формирование Charge по allocated_volume и тарифу. В processCalculation оставлен комментарий-ссылка на пакет internal/odn.


См. также