Empire ERP. Занимательная бухгалтерия: главная книга, счета, баланс

В данной статье мы осуществим попытку проникновения в самое сердце «кровавого энтерпрайза» — в бухгалтерию. Вначале мы проведем исследование главной книги, счетов и баланса, выявим присущие им свойства и алгоритмы. Используем Python и технологию Test Driven Development. Здесь мы займемся прототипированием, поэтому вместо базы данных будем использовать базовые контейнеры: списки, словари и кортежи. Проект разрабатывается в соответствии с требованиями к проекту Empire ERP.


Условие задачи

Космос… Планета Эмпирея… Одно государство на всю планету. Население работает 2 часа в 2 недели, через 2 года на пенсию. План счетов состоит из 12 позиций. Счета 1-4 — активные, 5-8 — активно-пассивные, 9-12 — пассивные. Предприятие Horns & Hooves. Все транзакции выполняются в одном отчетном периоде, в начале периода остатки отсутствуют.

Настройка проекта

Клонируем проект с гитхаба:

git clone https://github.com/nomhoi/empire-erp.git

Разработку ведем на Python 3.7.4. Настраиваем виртуальное окружение, активируем его и устанавливаем pytest.

pip install pytest
1. Главная книга

Переходим в папку reaserch/day1/step1.

accounting.py:

DEBIT = 0
CREDIT = 1
AMOUNT = 2

class GeneralLedger(list):
def __str__(self):
res = ‘nGeneral ledger’
for e in self:
res += ‘n {:2} {:2} {:8.2f}’.format(e[DEBIT], e[CREDIT], e[AMOUNT])
res += «n———————-»
return res

test_accounting.py:

import pytest
from accounting import *
from decimal import *

@pytest.fixture
def ledger():
return GeneralLedger()

@pytest.mark.parametrize(‘entries’, [
[(1, 12, 100.00),
(1, 11, 100.00)]
])
def test_ledger(ledger, entries):
for entry in entries:
ledger.append((entry[DEBIT], entry[CREDIT], Decimal(entry[AMOUNT])))
assert len(ledger) == 2
assert ledger[0][DEBIT] == 1
assert ledger[0][CREDIT] == 12
assert ledger[0][AMOUNT] == Decimal(100.00)
assert ledger[1][DEBIT] == 1
assert ledger[1][CREDIT] == 11
assert ledger[1][AMOUNT] == Decimal(100.00)
print(ledger)

Главная книга, как видим, представлена в виде списка записей. Каждая запись оформлена в виде кортежа. Для записи проводки пока используем только номера счетов по дебету и кредиту и сумму проводки. Даты, описания и прочая информация пока не нужны, мы их добавим позже.

В тестовом файле создали фиксатор ledger и параметризованый тест test_ledger. В параметр теста entries передаем сразу весь список проводок. Для проверки выполняем в терминале команду pytest -s -v. Тест должен пройти, и мы увидим в терминале весь список транзакций сохраненных в главной книге:

General ledger
1 12 100.00
1 11 100.00
2. Счета

Теперь добавим в проект поддержку счетов. Переходим в папку day1/step2.

accounting.py:

class GeneralLedger(list):
def __init__(self, accounts=None):
self.accounts = accounts

def append(self, entry):
if self.accounts is not None:
self.accounts.append_entry(entry)
super().append(entry)

В классе GeneralLedger перегрузили метод append. При добавлении проводки в книгу добавляем ее сразу и в счета.

accounting.py:

class Account:
def __init__(self, id, begin=Decimal(0.00)):
self.id = id
self.begin = begin
self.end = begin
self.entries = []

def append(self, id, amount):
self.entries.append((id, amount))
self.end += amount

class Accounts(dict):
def __init__(self):
self.range = range(1, 13)
for i in self.range:
self[i] = Account(i)

def append_entry(self, entry):
self[entry[DEBIT]].append(entry[CREDIT], Decimal(entry[AMOUNT]))
self[entry[CREDIT]].append(entry[DEBIT], Decimal(-entry[AMOUNT]))

Класс Accounts выполнен в виде словаря. В ключах номер счета, в значениях содержимое счета, т.е. экземпляр класса Account, который в свою очередь содержит поля начального и конечного сальдо и список транзакций имеющих отношение к этому счету. Заметим, что в этом списке суммы проводок по дебету и кредиту хранятся в одном поле, сумма по дебету положительна, сумма по кредиту отрицательна.

test_accounting.py:

@pytest.fixture
def accounts():
return Accounts()

@pytest.fixture
def ledger(accounts):
return GeneralLedger(accounts)

В тестовом файле добавили фиксатор accounts и поправили фиксатор ledger.

test_accounting.py:

@pytest.mark.parametrize(‘entries’, [
[(1, 12, 100.00),
(1, 11, 100.00)]
])
def test_accounts(accounts, ledger, entries):
for entry in entries:
ledger.append((entry[DEBIT], entry[CREDIT], Decimal(entry[AMOUNT])))
assert len(ledger) == 2
assert ledger[0][DEBIT] == 1
assert ledger[0][CREDIT] == 12
assert ledger[0][AMOUNT] == Decimal(100.00)
assert len(accounts) == 12
assert accounts[1].end == Decimal(200.00)
assert accounts[11].end == Decimal(-100.00)
assert accounts[12].end == Decimal(-100.00)
print(ledger)
print(accounts)

Добавили новый тест test_accounts.

Запускаем тест и наблюдаем вывод:

General ledger
1 12 100.00
1 11 100.00
———————-

Account 1
beg: 0.00 0.00
12: 100.00 0.00
11: 100.00 0.00
end: 200.00 0.00
———————-
Account 11
beg: 0.00 0.00
1: 0.00 100.00
end: 0.00 100.00
———————-
Account 12
beg: 0.00 0.00
1: 0.00 100.00
end: 0.00 100.00
———————-

В классах Account и Acconts методы __str__ тоже перегружены, можно посмотреть в исходниках проекта. Суммы проводок и остатков для лучшей наглядности представлены в двух столбцах: по дебету и кредиту.

3. Счета: проверка проводок

Вспоминаем о таком правиле:

Остаток на активном счету может быть только по дебету.
Остаток на пассивном счету может быть только по кредиту.
Остаток на активно-пассивном счету может быть и по дебету и по кредиту.

То есть в экземпляре класса Account значение end (конечное сальдо) на активных счетах не может быть отрицательным, а на пассивных счетах не может быть положительным.

Переходим в папку day1/step3.

accounting.py:

class BalanceException(Exception):
pass

Добавили исключение BalanceException.

class Account:

def is_active(self):
return True if self.id < 5 else False

def is_passive(self):
return True if self.id > 8 else False

В класс Account добавили проверку, к какому типу относится счет: к активному или пассивному.

class Accounts(dict):

def check_balance(self, entry):
if self[entry[CREDIT]].end — Decimal(entry[AMOUNT]) < 0 and self[entry[CREDIT]].is_active():
raise BalanceException(‘BalanceException’)
if self[entry[DEBIT]].end + Decimal(entry[AMOUNT]) > 0 and self[entry[DEBIT]].is_passive():
raise BalanceException(‘BalanceException’)

В класс Accounts.py добавили проверку, если в результате добавления новой проводки на активном счету образуется отрицательное значение по дебету, то поднимется исключение, и то же самое, если на пассивном счету получится отрицательное значение по кредиту.

class GeneralLedger(list):

def append(self, entry):
if self.accounts is not None:
self.accounts.check_balance(entry)
self.accounts.append_entry(entry)

super().append(entry)

В классе GeneralLedger перед добавлением проводки в счета выполняем проверку. Если поднимается исключение, то проводка не попадает ни в счета, ни в главную книгу.

test_accounting.py:

@pytest.mark.parametrize(‘entries, exception’, [
([(12, 1, 100.00)], BalanceException(‘BalanceException’)),
([(12, 6, 100.00)], BalanceException(‘BalanceException’)),
([(12, 11, 100.00)], BalanceException(‘BalanceException’)),

([(6, 2, 100.00)], BalanceException(‘BalanceException’)),
#([(6, 7, 100.00)], BalanceException(‘BalanceException’)),
#([(6, 12, 100.00)], BalanceException(‘BalanceException’)),

([(1, 2, 100.00)], BalanceException(‘BalanceException’)),
#([(1, 6, 100.00)], BalanceException(‘BalanceException’)),
#([(1, 12, 100.00)], BalanceException(‘BalanceException’)),
])
def test_accounts_balance(accounts, ledger, entries, exception):
for entry in entries:
try:
ledger.append((entry[DEBIT], entry[CREDIT], Decimal(entry[AMOUNT])))
except BalanceException as inst:
assert isinstance(inst, type(exception))
assert inst.args == exception.args
else:
pytest.fail(«Expected error but found none»)

assert len(ledger) == 0
assert len(accounts) == 12

В тестовый модуль добавили тест test_accounts_balance. В списке проводок сначала перечислили все возможные комбинации проводок и закомментировали все проводки, которые не поднимают исключение. Запускаем тест и убеждаемся, что оставшиеся 5 вариантов проводок поднимают исключение BalanceException.

4. Баланс

Переходим в папку day1/step4.

accounting.py:

class Balance(list):
def __init__(self, accounts):
self.accounts = accounts
self.suma = Decimal(0.00)
self.sump = Decimal(0.00)

def create(self):
self.suma = Decimal(0.00)
self.sump = Decimal(0.00)
for i in self.accounts.range:
active = self.accounts[i].end if self.accounts[i].end >= 0 else Decimal(0.00)
passive = -self.accounts[i].end if self.accounts[i].end < 0 else Decimal(0.00)
self.append((active, passive))
self.suma += active
self.sump += passive

При создании баланса просто собираем остатки со всех счетов в одну таблицу.

test_accounting.py:

@pytest.fixture
def balance(accounts):
return Balance(accounts)

Создали фиксатор balance.

@pytest.mark.parametrize(‘entries’, [
[
( 1, 12, 200.00), # increase active and passive
],[
( 1, 12, 200.00), # increase active and passive
(12, 1, 100.00), # decrease passive and decrease active
],[
( 1, 12, 300.00), # increase active and passive
(12, 1, 100.00), # decrease passive and decrease active
( 2, 1, 100.00), # increase active and decrease active
],[
( 1, 12, 300.00), # increase active and passive
(12, 1, 100.00), # decrease passive and decrease active
( 2, 1, 100.00), # increase active and decrease active
(12, 11, 100.00), # decrease passive and increase passive
]
])
def test_balance(accounts, ledger, balance, entries):
for entry in entries:
ledger.append(entry)
balance.create()
print(accounts)
print(balance)

Создали тест test_balance. В списках параметров перечислили все возможные типы проводок: увеличивающие актив и пассив, уменьшающие актив и пассив, увеличивающие актив и уменьшающие актив, увеличивающие пассив и уменьшающие пассив. Оформили 4 варианта проводок, чтобы можно было пошагово посмотреть вывод. Для последнего варианта вывод видим такой:

General ledger
1 12 300.00
12 1 100.00
2 1 100.00
12 11 100.00
———————-

Account 1
beg: 0.00 0.00
12: 300.00 0.00
12: 0.00 100.00
2: 0.00 100.00
end: 100.00 0.00
———————-
Account 2
beg: 0.00 0.00
1: 100.00 0.00
end: 100.00 0.00
———————-
Account 11
beg: 0.00 0.00
12: 0.00 100.00
end: 0.00 100.00
———————-
Account 12
beg: 0.00 0.00
1: 0.00 300.00
1: 100.00 0.00
11: 100.00 0.00
end: 0.00 100.00
———————-

Balance
1 : 100.00 0.00
2 : 100.00 0.00
3 : 0.00 0.00
4 : 0.00 0.00
5 : 0.00 0.00
6 : 0.00 0.00
7 : 0.00 0.00
8 : 0.00 0.00
9 : 0.00 0.00
10 : 0.00 0.00
11 : 0.00 100.00
12 : 0.00 100.00
———————-
sum: 200.00 200.00
======================
5. Сторно

Теперь проверим как выполняется сторно.

@pytest.mark.parametrize(‘entries’, [
[
( 1, 12, 100.00),
( 1, 12,-100.00),
]
])
def test_storno(accounts, ledger, balance, entries):
for entry in entries:
ledger.append(entry)
balance.create()
print(ledger)
print(accounts)
print(balance)

Вывод получили такой:

General ledger
1 12 100.00
1 12 -100.00
———————-

Account 1
beg: 0.00 0.00
12: 100.00 0.00
12: 0.00 100.00
end: 0.00 0.00
———————-
Account 12
beg: 0.00 0.00
1: 0.00 100.00
1: 100.00 0.00
end: 0.00 0.00
———————-

Balance
1 : 0.00 0.00
2 : 0.00 0.00
3 : 0.00 0.00
4 : 0.00 0.00
5 : 0.00 0.00
6 : 0.00 0.00
7 : 0.00 0.00
8 : 0.00 0.00
9 : 0.00 0.00
10 : 0.00 0.00
11 : 0.00 0.00
12 : 0.00 0.00
———————-
sum: 0.00 0.00
======================

Вроде все верно.

А если мы используем такой набор проводок, то тест пройдет:

( 1, 12, 100.00),
(12, 1, 100.00),
( 1, 12,-100.00),

А если такой набор, поменяем последние 2 строки местами, то получим исключение:

( 1, 12, 100.00),
( 1, 12,-100.00),
(12, 1, 100.00),

Таким образом, чтобы отловить такую ошибку сторно нужно размещать сразу после исправляемой транзакции.

Заключение

В следующих статьях продолжим исследование бухгалтерского учета и будем рассматривать все аспекты разработки системы в соответствии со списком требований к Empire ERP.

Оставить комментарий