python

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

  • понедельник, 14 октября 2019 г. в 00:29:00
https://habr.com/ru/post/471304/
  • Python
  • Анализ и проектирование систем
  • TDD
  • ERP-системы


В данной статье мы осуществим попытку проникновения в самое сердце "кровавого энтерпрайза" — в бухгалтерию. Вначале мы проведем исследование главной книги, счетов и баланса, выявим присущие им свойства и алгоритмы. Используем Python и технологию Test Driven Development. Здесь мы займемся прототипированием, поэтому вместо базы данных будем использовать базовые контейнеры: списки, словари и кортежи. Проект разрабатывается в соответствии с требованиями к проекту Empire ERP: https://github.com/nomhoi/empire-erp/blob/master/requirements.md.


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


Космос… Планета Эмпирея… Одно государство на всю планету. Население работает 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.