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

Month Closing — закриття місяця

Статус: 🏗️ Scaffolded (каркас + 2 реальні кроки з 6, 2026-04-23)

Вихідна точка — пункт аудиту audit-concepts-2026-04-22: «закриття місяця — не галочка, а процес». Замість монолітного wizard'а реалізовано послідовний набір ідемпотентних кроків, кожен з яких — окрема облікова операція. Два кроки вже живі (amortization, posting-lock), чотири — заглушки з чітким контрактом під подальше наповнення.


Призначення

Закриття місяця у бухобліку — не одна дія, а впорядкована послідовність операцій, після якої формується достовірний баланс і P&L:

  1. Заморозити первинні документи (щоб ніхто ретроспективно не вносив зміни у закритий період).
  2. Переоцінити валютні залишки за курсом на кінець періоду (нереалізовані курсові різниці).
  3. Нарахувати амортизацію всіх активних основних засобів.
  4. Закрити витратні рахунки виробництва/послуг на собівартість реалізації.
  5. Закрити рахунки доходів і витрат у нерозподілений прибуток.
  6. Встановити posting_lock_date — технічний «замок» від подальших правок.

Кожен крок має свої передумови та може запускатися повторно без ризику дублів. Якщо на середині щось падає — процес зупиняється, оператор виправляє причину і натискає «Retry» на конкретному кроці, або «Run all» знову.


Ідемпотентність

Це головний інваріант процесу. Повторний виклик run-step будь-якого кроку повинен давати той самий результат, що й перший. Реалізації:

Крок Механізм idempotency
lock_source_documents (stub)
fx_revaluation (stub)
depreciation Unique constraint (asset, period_year, period_month) у DepreciationEntry — дубль не створюється.
cost_closing (stub)
pnl_closing (stub)
set_posting_lock_date posting_lock_date рухається лише вперед — повторний виклик не відкочує замок.

На рівні оркестратора (month_closing_service.run_step) додатково: якщо step_results[key].status == 'done' — крок не викликається взагалі, повертається збережений результат. Це захищає stub'и (де внутрішньої idempotency немає) і економить цикли.


Моделі

MonthClosing (TenantAwareModel)

Один запис на (tenant, year, month) — забезпечено UniqueConstraint.

Поле Тип Опис
year int Рік періоду
month int (1..12) Місяць періоду
status enum draftin_progressclosed; після reset_step закритого → reopened
step_results JSON Словник {step_key: {status, message, timestamp, user_id, postings_count?, amount?}}
started_at datetime Час першого run-step
closed_at datetime Час завершення останнього кроку
closed_by FK User Хто закрив період

Приклад step_results:

{
  "depreciation": {
    "status": "done",
    "message": "Depreciation processed: 15 entries.",
    "timestamp": "2026-04-30T23:55:12.410Z",
    "user_id": 42,
    "postings_count": 15,
    "amount": 12345.67
  },
  "set_posting_lock_date": {
    "status": "done",
    "message": "Posting lock date set to 2026-04-30.",
    "timestamp": "2026-04-30T23:55:14.892Z",
    "user_id": 42
  }
}


Сервіс

essentials/services/month_closing_service.py

Метадані кроків: STEPS

Впорядкований список:

STEPS = [
    {'key': 'lock_source_documents', 'name': 'Lock source documents', ...},
    {'key': 'fx_revaluation',        'name': 'FX revaluation',       ...},
    {'key': 'depreciation',          'name': 'Depreciation',          ...},
    {'key': 'cost_closing',          'name': 'Cost closing',          ...},
    {'key': 'pnl_closing',           'name': 'P&L closing',           ...},
    {'key': 'set_posting_lock_date', 'name': 'Set posting lock date', ...},
]

Порядок важливий: lock_source_documents має йти до всього що створює проводки (інакше хтось може «підкласти» документ у вже рахований період), а set_posting_lock_dateостаннім (інакше амортизація/переоцінка впадуть на posting_lock_date перевірці).

Контракт кроку

def _step_<name>(closing: MonthClosing, *, user=None) -> dict:
    """Повертає результат, що мерджиться в closing.step_results[key]."""
    return {
        'status': 'done' | 'error',
        'message': str,
        'timestamp': iso8601,
        'user_id': int | None,  # підставляється оркестратором
        # опціональні step-специфічні поля:
        'postings_count': int,
        'amount': float,
    }

Помилки ловить оркестратор (except Exception), записує status=error з текстом. Крок не має кидати — якщо кидає, ловить оркестратор, але краще явно повернути _err(...).

Реалізовані кроки

depreciation

def _step_depreciation(closing, *, user=None):
    entries = run_monthly_depreciation(closing.tenant, closing.year, closing.month)
    total = float(sum((e.amount for e in entries), start=0))
    return _ok(
        f'Depreciation processed: {len(entries)} entries.',
        postings_count=len(entries),
        amount=total,
    )

Викликає ту саму функцію, що й standalone пункт «Нарахування амортизації» у меню Процеси → Фінанси та періоди. Детальніше про саму амортизацію — fixed-assets.

set_posting_lock_date

Оновлює EssentialsModuleSettings.posting_lock_date на останній день періоду. Критичне правило — лише вперед:

if settings.posting_lock_date is None or settings.posting_lock_date < lock_date:
    settings.posting_lock_date = lock_date
    settings.save(...)

Якщо посилити замок (наприклад, зараз 2026-04-30, закриваємо березень) — не відкочує до березня, а лишає квітневий. Це захищає від випадкового «розморожування» уже закритих періодів через run-all старого місяця.

Stub-кроки (заглушки)

lock_source_documents, fx_revaluation, cost_closing, pnl_closing — зараз no-op:

def _step_fx_revaluation(closing, *, user=None):
    return _ok('Stub: no-op (feature pending).')

Нічого не роблять, але повертають status: done щоб wizard міг продовжити. Замінювати на реальну реалізацію потрібно заміною тільки цієї функції у _STEP_IMPL — жодних змін у вьюсеті, серіалізаторі, frontend не треба.

Публічні функції

Функція Призначення
get_or_create_period(tenant, year, month, *, user=None) → MonthClosing Повертає існуючий період або створює новий. Використовується вьюсетом open_period action.
run_step(closing, step_key, *, user=None) → dict Запускає один крок. Ідемпотентний: якщо done — повертає збережений результат. Інакше викликає _STEP_IMPL[step_key], зберігає у step_results.
run_all(closing, *, user=None) → dict Проходить STEPS, викликає run_step для кожного не-done. Стоп на першій помилці. Коли всі donestatus='closed', closed_at, closed_by.
reset_step(closing, step_key, *, user=None) Видаляє результат кроку — вимагається для примусового повторного запуску done-кроку. Якщо період був closed — переводить у reopened.

API

Базовий URL: /api/v1/essentials/month-closings/

Метод URL Body Опис
GET / Список періодів
GET /{id}/ Деталь одного періоду з steps[]
POST /open-period/ {year, month} Idempotent getter — створити / повернути період. Frontend відкриває візард звідси.
POST /{id}/run-step/ {step_key} Виконати один крок
POST /{id}/reset-step/ {step_key} Скинути done-крок (для повторного запуску)
POST /{id}/run-all/ Виконати всі не-done кроки по черзі; стоп на помилці
DELETE /{id}/ Видалити запис періоду (не «скасувати закриття» — просто прибрати слід у таблиці)

Response від run-step:

{
  "step_result": { "status": "done", "message": "...", "postings_count": 15, "amount": 12345.67, ... },
  "closing": { /* оновлений MonthClosing з усіма steps[] */ }
}

Response від run-all:

{
  "result": { "status": "done" | "error", "executed": ["depreciation", ...], "stopped_at": "fx_revaluation", "message": "..." },
  "closing": { ... }
}

Серіалізатор повертає обчислюване поле steps — плоский список з актуальним статусом кожного кроку (merge метаданих STEPS + step_results). Саме це поле рендерить frontend.


Frontend

Process page

components/Essentials/MonthClosing/MonthClosingPage.tsx

  • Селектор року + місяця → кнопка «Відкрити період» → POST open-period/.
  • Badge стану (draft / in_progress / closed / reopened).
  • Кнопка «Запустити все» → POST run-all/.
  • Вертикальний Mantine Stepper, по одному запису на крок:
  • Іконка статусу (Clock / PlayerPlay / Check / AlertCircle).
  • Badge з postings_count та amount (для кроку амортизації).
  • Текст повідомлення від сервера + timestamp.
  • Кнопка «Виконати» / «Повторити» (error) + Refresh для reset done-кроку.
  • Green-картка «Період закрито» знизу, коли status === 'closed'.

Мова UI: ua/en, вся термінологія з i18n-паттерну Item/FixedAsset (немає окремих t() ключів, hardcoded conditional).

Конфіг

frontend/erp/src/config/essentials.ts, пункт monthClosing у essentialsprocesses → financeperiod:

{
  code: "monthClosing",
  name: "Month Closing",
  name_ua: "Закриття місяця",
  type: ItemType.PROCESS,
  icon: "CheckCircleOutlined",
  ...
}

Рендеринг через ProcessPanel.tsx — case 'monthClosing' → lazy-load MonthClosingPage.

Розташування у меню: Essentials → Процеси → Фінанси та періоди → Закриття місяця.


Workflow

 ┌──────────────────────────────────────────────────────────────────┐
 │ 1. Оператор відкриває період (year, month) → MonthClosing draft  │
 └──────────────────────────────────────────────────────────────────┘
 ┌──────────────────────────────────────────────────────────────────┐
 │ 2. Натискає «Запустити все» (або крок за кроком)                  │
 │                                                                   │
 │    lock_source_documents   → stub (no-op)                         │
 │    fx_revaluation          → stub (no-op)                         │
 │    depreciation            → run_monthly_depreciation(...)        │
 │      ├─ PostingGroup Дт 6810 / Кт asset_account per active asset  │
 │      └─ DepreciationEntry + accumulated_depreciation ++           │
 │    cost_closing            → stub (no-op)                         │
 │    pnl_closing             → stub (no-op)                         │
 │    set_posting_lock_date   → Settings.posting_lock_date ← EOM     │
 └──────────────────────────────────────────────────────────────────┘
                  ┌────────────┴────────────┐
                  ▼                         ▼
 ┌───────────────────────┐     ┌────────────────────────────────────┐
 │ Усі done              │     │ Падіння на кроці X                  │
 │ → status=closed       │     │ → status лишається in_progress      │
 │ → closed_at, closed_by│     │ → step_results[X].status=error      │
 │ → period замкнуто     │     │ → оператор читає message, виправляє │
 │   через lock_date     │     │   і натискає «Повторити» на X       │
 └───────────────────────┘     └────────────────────────────────────┘

Перевідкриття періоду

Якщо після закриття виявилися помилки (рідкісний, але можливий сценарій): 1. Адмін натискає Refresh (reset) на одному з done-кроків — статус стає reopened. 2. Правиться проблема (нова проводка / правка активу). 3. Run step на перевідкритому кроці → далі зазвичай set_posting_lock_date залишається той самий (він лише вперед), або повторно провести амортизацію.


Обмеження MVP

Що відсутнє Поточний стан Trigger для реалізації
lock_source_documents stub (no-op) Є posting_lock_date, але немає явного flag'а на документах + UI-alert при спробі змінити
fx_revaluation stub (no-op) Реальна переоцінка валютних залишків з ExchangeRate на кінець періоду + проводка Дт 945 / Кт account (або reverse)
cost_closing stub (no-op) Закриття 23 (виробництво) / 9х (операційні витрати) на 901 (собівартість реалізації)
pnl_closing stub (no-op) 70/71/73/74/75 → 791 (фінрезультат); 90/92/93/94/97/98 → 791; 791 → 441 (нерозподілений прибуток)
Auto-scheduling (Celery) Ручний запуск через UI Production deploy + task-queue
Approval workflow (бухгалтер → головбух) Немає ролей Запит корпоративного клієнта
Звіт «історія закриттів» з diff'ом Немає Запит аудиту періодів
Multi-currency revaluation Не торкалось Паралельно з fx_revaluation
Partial close (тільки amortization без posting-lock) Можна досягти через run_step напряму Частий сценарій — окрема точка входу

Як додати новий крок

  1. Додати опис у STEPS (ключ + name/name_ua + description/description_ua).
  2. Написати _step_<key>(closing, *, user=None) → dict у month_closing_service.py.
  3. Зареєструвати у мапі _STEP_IMPL.
  4. Перезапустити backend — ні frontend, ні міграції чіпати не треба. Серіалізатор автоматично згенерує пункт у steps[], wizard UI відрендерить.

Ключовий інваріант для нового кроку: - Ідемпотентність — повторний виклик повертає той самий результат, не створює дублів. Зазвичай реалізується unique constraint на рівні домена (як у DepreciationEntry) або перевіркою «уже існує» на початку. - Атомарність — крок має бути обгорнутий у @transaction.atomic або викликати вже атомарні сервіси (як run_monthly_depreciation). - Репортабельність — повертати postings_count / amount / короткий опис, щоб оператор бачив результат у UI.


Як замінити заглушку реальним сервісом

Приклад для fx_revaluation:

  1. Написати окремий сервіс essentials/services/fx_revaluation_service.py з функцією revalue_fx_accounts(tenant, year, month) → {posting_group, amount, accounts_count} (idempotent).
  2. У month_closing_service.py замінити тіло _step_fx_revaluation:
    def _step_fx_revaluation(closing, *, user=None):
        result = revalue_fx_accounts(closing.tenant, closing.year, closing.month)
        return _ok(
            f'Revalued {result.accounts_count} FX accounts.',
            postings_count=1,
            amount=float(result.amount),
        )
    
  3. Перезапустити backend. UI одразу починає показувати «Revalued X FX accounts» замість «Stub: no-op».

Покриття тестами

Поки відсутнє — сервіс і вьюсет покриваються наступним кроком. Мінімальний план:

  • test_run_step_idempotency: двічі run_step(closing, 'depreciation', ...)DepreciationEntry.count() == 1 × N_assets, другий виклик повертає stored result.
  • test_run_all_happy_path: створити 2 активи, викликати run_allstatus=closed, closed_at встановлено, posting_lock_date == EOM.
  • test_run_all_stops_on_error: змусити _step_depreciation кинути → run_all повертає {status: error, stopped_at: depreciation}, status=in_progress, наступні stub'и не викликано.
  • test_reset_step_reopens: after status=closed, reset one step → status=reopened, step in pending.
  • test_posting_lock_only_forward: після закриття квітня 2026-04-30, спроба run_all для березня → posting_lock_date лишається 2026-04-30.

🔮 Deferred / Ideas

Auto-run по крону

Мотивація: бухгалтер часто забуває запустити амортизацію останнього числа місяця. Чому відкладено: потребує Celery beat у production, error-notifications через Slack/email. Trigger: production deploy із job scheduler.

Progress bar під час run-all

Мотивація: якщо тенант має 500 активів + 20 валютних рахунків + купу закриттів — run-all може тривати хвилини. Чому відкладено: поточна реалізація синхронна; для прогресу треба WebSocket або polling. Trigger: клієнт з великим обсягом даних скаржиться на «зависання».

Diff «що зміниться» перед запуском

Мотивація: перед закриттям показати оператору — «буде створено X проводок на Y грн». Чому відкладено: вимагає dry-run режиму у кожному сервісі (fx / cost / pnl). Trigger: запит корпоративного клієнта з чотирма очима.

Approval workflow

Мотивація: закриття ↔ підтвердження головбухом перед посиленням posting_lock_date. Чому відкладено: MVP — один користувач; ролі ще не гранульовані. Trigger: клієнт з > 2 бухгалтерів.

Відкат кроку з компенсуючими проводками

Мотивація: reset_step зараз лише чистить step_results, але фактичні проводки залишаються у PostingEntry. Для реального відкату треба зробити reverse-group. Чому відкладено: складно — відкат амортизації безпечний (unique constraint дозволить re-run), але відкат fx_revaluation потребує явної протилежної проводки. Trigger: реалізація не-idempotent кроків (які не можна просто повторити).


Пов'язане

  • Fixed Assets — амортизація як крок depreciation
  • Currency — джерело курсів для майбутнього fx_revaluation
  • Accounting Setupposting_lock_date у налаштуваннях
  • Journal Entry — механізм проводок, через який створюватимуться cost_closing і pnl_closing
  • Audit Concepts 2026-04-22 — вихідне зауваження, що привело до цього процесу