Month Closing — закриття місяця¶
Статус: 🏗️ Scaffolded (каркас + 2 реальні кроки з 6, 2026-04-23)
Вихідна точка — пункт аудиту
audit-concepts-2026-04-22: «закриття місяця — не галочка, а процес». Замість монолітного wizard'а реалізовано послідовний набір ідемпотентних кроків, кожен з яких — окрема облікова операція. Два кроки вже живі (amortization, posting-lock), чотири — заглушки з чітким контрактом під подальше наповнення.
Призначення¶
Закриття місяця у бухобліку — не одна дія, а впорядкована послідовність операцій, після якої формується достовірний баланс і P&L:
- Заморозити первинні документи (щоб ніхто ретроспективно не вносив зміни у закритий період).
- Переоцінити валютні залишки за курсом на кінець періоду (нереалізовані курсові різниці).
- Нарахувати амортизацію всіх активних основних засобів.
- Закрити витратні рахунки виробництва/послуг на собівартість реалізації.
- Закрити рахунки доходів і витрат у нерозподілений прибуток.
- Встановити
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 | draft → in_progress → closed; після 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:
Нічого не роблять, але повертають 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. Стоп на першій помилці. Коли всі done — status='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 напряму |
Частий сценарій — окрема точка входу |
Як додати новий крок¶
- Додати опис у
STEPS(ключ + name/name_ua + description/description_ua). - Написати
_step_<key>(closing, *, user=None) → dictуmonth_closing_service.py. - Зареєструвати у мапі
_STEP_IMPL. - Перезапустити backend — ні frontend, ні міграції чіпати не треба. Серіалізатор автоматично згенерує пункт у
steps[], wizard UI відрендерить.
Ключовий інваріант для нового кроку:
- Ідемпотентність — повторний виклик повертає той самий результат, не створює дублів. Зазвичай реалізується unique constraint на рівні домена (як у DepreciationEntry) або перевіркою «уже існує» на початку.
- Атомарність — крок має бути обгорнутий у @transaction.atomic або викликати вже атомарні сервіси (як run_monthly_depreciation).
- Репортабельність — повертати postings_count / amount / короткий опис, щоб оператор бачив результат у UI.
Як замінити заглушку реальним сервісом¶
Приклад для fx_revaluation:
- Написати окремий сервіс
essentials/services/fx_revaluation_service.pyз функцієюrevalue_fx_accounts(tenant, year, month) → {posting_group, amount, accounts_count}(idempotent). - У
month_closing_service.pyзамінити тіло_step_fx_revaluation: - Перезапустити 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_all→status=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: afterstatus=closed, reset one step →status=reopened, step inpending.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 Setup —
posting_lock_dateу налаштуваннях - Journal Entry — механізм проводок, через який створюватимуться
cost_closingіpnl_closing - Audit Concepts 2026-04-22 — вихідне зауваження, що привело до цього процесу