From d079c74ecfc1bb76ee588ddf446e2e3f033f100a Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Mon, 10 Aug 2026 14:25:43 +0200 Subject: [PATCH] feat(organizations): generate complete test statements --- src/organizations/test_companies.py | 306 +++++++++++++++--- .../test_test_companies_commands.py | 147 ++++++++- 2 files changed, 407 insertions(+), 46 deletions(-) diff --git a/src/organizations/test_companies.py b/src/organizations/test_companies.py index fbd8de7..9175625 100644 --- a/src/organizations/test_companies.py +++ b/src/organizations/test_companies.py @@ -37,6 +37,76 @@ TEST_FINANCIAL_HISTORY_YEARS = 4 TEST_COMPANY_NAMESPACE = UUID("59b36ae9-bcf8-4b7c-b77a-77578f485a01") TEST_RECORD_PREFIX = "mostovik-test-company" +TEST_BALANCE_LINE_NAMES = { + "1110": "Нематериальные активы", + "1120": "Результаты исследований и разработок", + "1130": "Нематериальные поисковые активы", + "1140": "Материальные поисковые активы", + "1150": "Основные средства", + "1160": "Доходные вложения в материальные ценности", + "1170": "Финансовые вложения", + "1180": "Отложенные налоговые активы", + "1190": "Прочие внеоборотные активы", + "1100": "Итого по разделу I", + "1210": "Запасы", + "1220": "НДС по приобретенным ценностям", + "1230": "Дебиторская задолженность", + "1240": "Финансовые вложения (за исключением денежных эквивалентов)", + "1250": "Денежные средства и денежные эквиваленты", + "1260": "Прочие оборотные активы", + "1200": "Итого по разделу II", + "1600": "БАЛАНС (актив)", + "1310": "Уставный капитал", + "1320": "Собственные акции, выкупленные у акционеров", + "1340": "Переоценка внеоборотных активов", + "1350": "Добавочный капитал (без переоценки)", + "1360": "Резервный капитал", + "1370": "Нераспределенная прибыль (непокрытый убыток)", + "1300": "Итого по разделу III", + "1410": "Заемные средства", + "1420": "Отложенные налоговые обязательства", + "1430": "Оценочные обязательства", + "1450": "Прочие обязательства", + "1400": "Итого по разделу IV", + "1510": "Заемные средства", + "1520": "Кредиторская задолженность", + "1530": "Доходы будущих периодов", + "1540": "Оценочные обязательства", + "1550": "Прочие обязательства", + "1500": "Итого по разделу V", + "1700": "БАЛАНС (пассив)", +} + +TEST_PROFIT_LOSS_LINE_NAMES = { + "2110": "Выручка", + "2120": "Себестоимость продаж", + "2100": "Валовая прибыль (убыток)", + "2210": "Коммерческие расходы", + "2220": "Управленческие расходы", + "2200": "Прибыль (убыток) от продаж", + "2310": "Доходы от участия в других организациях", + "2320": "Проценты к получению", + "2330": "Проценты к уплате", + "2340": "Прочие доходы", + "2350": "Прочие расходы", + "2300": "Прибыль (убыток) до налогообложения", + "2410": "Налог на прибыль", + "2411": "Текущий налог на прибыль", + "2412": "Отложенный налог на прибыль", + "2460": "Прочее", + "2400": "Чистая прибыль (убыток)", + "2510": "Результат от переоценки внеоборотных активов", + "2520": "Результат от прочих операций", + "2530": "Налог на прибыль от операций вне чистой прибыли", + "2500": "Совокупный финансовый результат периода", + "2900": "Базовая прибыль (убыток) на акцию", + "2910": "Разводненная прибыль (убыток) на акцию", +} + +TEST_FINANCIAL_LINES_PER_YEAR = len(TEST_BALANCE_LINE_NAMES) + len( + TEST_PROFIT_LOSS_LINE_NAMES +) + @dataclass(frozen=True) class TestCompanyDatasetResult: @@ -452,7 +522,9 @@ class TestCompanyDatasetService: "file_hash": sha256( f"financial:{organization.ogrn}".encode() ).hexdigest(), - "lines_count": 4, + "lines_count": ( + TEST_FINANCIAL_LINES_PER_YEAR * TEST_FINANCIAL_HISTORY_YEARS + ), }, }, ParserLoadLog.Source.PROCUREMENTS: cls._procurement_values( @@ -670,57 +742,215 @@ class TestCompanyDatasetService: } @staticmethod - def _refresh_financial_lines(*, record, legacy_report, index: int) -> None: + def _test_amount( + *, + base: int, + index: int, + year: int, + per_company: int, + annual_growth: int, + ) -> int: + """Return a stable amount for the same company and calendar year.""" + return base + index * per_company + (year - 2020) * annual_growth + + @classmethod + def _balance_values(cls, *, index: int, year: int) -> dict[str, int]: + """Build a balanced form 0710001 in thousands of rubles.""" + + def amount(base: int, per_company: int, annual_growth: int) -> int: + return cls._test_amount( + base=base, + index=index, + year=year, + per_company=per_company, + annual_growth=annual_growth, + ) + + values = { + "1110": amount(350, 7, 25), + "1120": amount(220, 5, 18), + "1130": amount(80, 2, 6), + "1140": amount(70, 2, 5), + "1150": amount(4_800, 60, 240), + "1160": amount(300, 8, 22), + "1170": amount(900, 18, 70), + "1180": amount(180, 4, 12), + "1190": amount(140, 3, 10), + "1210": amount(3_400, 45, 190), + "1220": amount(240, 5, 12), + "1230": amount(3_800, 55, 220), + "1240": amount(1_100, 20, 90), + "1250": amount(1_500, 30, 120), + "1260": amount(260, 6, 16), + "1310": amount(3_500, 30, 100), + "1320": -amount(120, 2, 5), + "1340": amount(320, 4, 15), + "1350": amount(280, 3, 12), + "1360": amount(240, 3, 10), + "1410": amount(2_300, 32, 110), + "1420": amount(220, 4, 12), + "1430": amount(180, 3, 9), + "1450": amount(240, 4, 11), + "1510": amount(1_600, 25, 80), + "1520": amount(3_200, 40, 150), + "1530": amount(120, 2, 6), + "1540": amount(250, 4, 12), + "1550": amount(320, 5, 15), + } + + values["1100"] = sum( + values[code] + for code in ( + "1110", + "1120", + "1130", + "1140", + "1150", + "1160", + "1170", + "1180", + "1190", + ) + ) + values["1200"] = sum( + values[code] for code in ("1210", "1220", "1230", "1240", "1250", "1260") + ) + values["1600"] = values["1100"] + values["1200"] + values["1400"] = sum(values[code] for code in ("1410", "1420", "1430", "1450")) + values["1500"] = sum( + values[code] for code in ("1510", "1520", "1530", "1540", "1550") + ) + equity_without_retained = sum( + values[code] for code in ("1310", "1320", "1340", "1350", "1360") + ) + values["1370"] = ( + values["1600"] - equity_without_retained - values["1400"] - values["1500"] + ) + values["1300"] = equity_without_retained + values["1370"] + values["1700"] = values["1300"] + values["1400"] + values["1500"] + return values + + @classmethod + def _profit_loss_values(cls, *, index: int, year: int) -> dict[str, int]: + """Build a form 0710002 whose calculated rows reconcile.""" + + def amount(base: int, per_company: int, annual_growth: int) -> int: + return cls._test_amount( + base=base, + index=index, + year=year, + per_company=per_company, + annual_growth=annual_growth, + ) + + revenue = amount(32_000, 800, 2_400) + cost_of_sales = revenue * 61 // 100 + gross_profit = revenue - cost_of_sales + commercial_expenses = revenue * 5 // 100 + management_expenses = revenue * 8 // 100 + sales_profit = gross_profit - commercial_expenses - management_expenses + participation_income = amount(180, 6, 14) + interest_income = amount(220, 5, 16) + interest_expense = amount(310, 7, 20) + other_income = amount(760, 15, 45) + other_expense = amount(620, 12, 38) + profit_before_tax = ( + sales_profit + + participation_income + + interest_income + - interest_expense + + other_income + - other_expense + ) + current_tax = profit_before_tax * 19 // 100 + deferred_tax = profit_before_tax // 100 + income_tax = current_tax + deferred_tax + other_tax_effects = amount(25, 1, 2) + net_profit = profit_before_tax - income_tax - other_tax_effects + revaluation_result = amount(90, 3, 7) + other_operations_result = amount(55, 2, 5) + other_operations_tax = amount(18, 1, 1) + comprehensive_result = ( + net_profit + + revaluation_result + + other_operations_result + - other_operations_tax + ) + basic_eps = max(1, net_profit // 1_000) + diluted_eps = max(1, basic_eps - 1) + + return { + "2110": revenue, + "2120": cost_of_sales, + "2100": gross_profit, + "2210": commercial_expenses, + "2220": management_expenses, + "2200": sales_profit, + "2310": participation_income, + "2320": interest_income, + "2330": interest_expense, + "2340": other_income, + "2350": other_expense, + "2300": profit_before_tax, + "2410": income_tax, + "2411": current_tax, + "2412": deferred_tax, + "2460": other_tax_effects, + "2400": net_profit, + "2510": revaluation_result, + "2520": other_operations_result, + "2530": other_operations_tax, + "2500": comprehensive_result, + "2900": basic_eps, + "2910": diluted_eps, + } + + @classmethod + def _financial_line_definitions(cls, *, index: int, year: int): + forms = ( + ( + "1", + TEST_BALANCE_LINE_NAMES, + cls._balance_values, + ), + ( + "2", + TEST_PROFIT_LOSS_LINE_NAMES, + cls._profit_loss_values, + ), + ) + for form_code, names, value_factory in forms: + period_start_values = value_factory(index=index, year=year - 1) + period_end_values = value_factory(index=index, year=year) + for line_code, line_name in names.items(): + yield ( + form_code, + line_code, + line_name, + period_start_values[line_code], + period_end_values[line_code], + ) + + @classmethod + def _refresh_financial_lines(cls, *, record, legacy_report, index: int) -> None: current_year = timezone.localdate().year report_years = range( current_year - TEST_FINANCIAL_HISTORY_YEARS + 1, current_year + 1, ) - expected = ( - ( - "1", - "1600", - "Баланс (актив)", - 10_000_000 + index * 100_000, - 300_000 + index * 2_000, - ), - ( - "1", - "1300", - "Капитал и резервы", - 4_000_000 + index * 50_000, - 120_000 + index * 1_000, - ), - ( - "2", - "2110", - "Выручка", - 20_000_000 + index * 200_000, - 750_000 + index * 5_000, - ), - ( - "2", - "2400", - "Чистая прибыль", - 2_000_000 + index * 25_000, - 80_000 + index * 500, - ), - ) expected_keys = set() for report_year in report_years: - years_before_current = current_year - report_year for ( form_code, line_code, line_name, - current_period_end, - annual_change, - ) in expected: - period_end = current_period_end - annual_change * years_before_current + period_start, + period_end, + ) in cls._financial_line_definitions(index=index, year=report_year): expected_keys.add((form_code, line_code, report_year)) defaults = { "line_name": line_name, - "period_start": period_end - annual_change, + "period_start": period_start, "period_end": period_end, } OrganizationSourceFinancialLine.objects.update_or_create( diff --git a/tests/apps/organizations/test_test_companies_commands.py b/tests/apps/organizations/test_test_companies_commands.py index 3cd31f2..18900e7 100644 --- a/tests/apps/organizations/test_test_companies_commands.py +++ b/tests/apps/organizations/test_test_companies_commands.py @@ -25,7 +25,10 @@ from organizations.models import ( SourceGroup, ) from organizations.test_companies import ( + TEST_BALANCE_LINE_NAMES, TEST_FINANCIAL_HISTORY_YEARS, + TEST_FINANCIAL_LINES_PER_YEAR, + TEST_PROFIT_LOSS_LINE_NAMES, TEST_RECORD_PREFIX, ) @@ -92,7 +95,7 @@ class TestCompaniesCommandsTest(TestCase): OrganizationSourceFinancialLine.objects.filter( source_record__extension__organization__in=companies ).count(), - 20 * 4 * TEST_FINANCIAL_HISTORY_YEARS, + 20 * TEST_FINANCIAL_LINES_PER_YEAR * TEST_FINANCIAL_HISTORY_YEARS, ) current_year = timezone.localdate().year expected_financial_years = set( @@ -124,7 +127,7 @@ class TestCompaniesCommandsTest(TestCase): ) self.assertEqual( FinancialReportLine.objects.count(), - 20 * 4 * TEST_FINANCIAL_HISTORY_YEARS, + 20 * TEST_FINANCIAL_LINES_PER_YEAR * TEST_FINANCIAL_HISTORY_YEARS, ) self.assertEqual( set(FinancialReportLine.objects.values_list("year", flat=True)), @@ -250,14 +253,10 @@ class TestCompaniesCommandsTest(TestCase): self.assertEqual(len(reports), 20) self.assertEqual( sum(len(report["lines"]) for report in reports), - 20 * 4 * TEST_FINANCIAL_HISTORY_YEARS, + 20 * TEST_FINANCIAL_LINES_PER_YEAR * TEST_FINANCIAL_HISTORY_YEARS, ) self.assertEqual( - { - line["year"] - for report in reports - for line in report["lines"] - }, + {line["year"] for report in reports for line in report["lines"]}, set( range( timezone.localdate().year - TEST_FINANCIAL_HISTORY_YEARS + 1, @@ -266,6 +265,138 @@ class TestCompaniesCommandsTest(TestCase): ), ) + def test_financial_statements_are_complete_balanced_and_periodic(self): + call_command("create_test_companies", stdout=StringIO()) + + company = Organization.objects.get(name="Тестовая компания 1") + record = OrganizationSourceRecord.objects.get( + extension__organization=company, + source=ParserLoadLog.Source.FNS_REPORTS, + ) + lines = list( + OrganizationSourceFinancialLine.objects.filter( + source_record=record + ).order_by("year", "form_code", "line_code") + ) + self.assertEqual( + len(lines), + TEST_FINANCIAL_LINES_PER_YEAR * TEST_FINANCIAL_HISTORY_YEARS, + ) + self.assertTrue( + all( + line.period_start is not None and line.period_end is not None + for line in lines + ) + ) + + lines_by_year = {} + for line in lines: + lines_by_year.setdefault(line.year, {})[ + (line.form_code, line.line_code) + ] = line + + expected_codes = { + *(("1", code) for code in TEST_BALANCE_LINE_NAMES), + *(("2", code) for code in TEST_PROFIT_LOSS_LINE_NAMES), + } + ordered_years = sorted(lines_by_year) + for year in ordered_years: + year_lines = lines_by_year[year] + self.assertEqual(set(year_lines), expected_codes) + values = {key: line.period_end for key, line in year_lines.items()} + + self.assertEqual( + values[("1", "1100")], + sum( + values[("1", code)] + for code in ( + "1110", + "1120", + "1130", + "1140", + "1150", + "1160", + "1170", + "1180", + "1190", + ) + ), + ) + self.assertEqual( + values[("1", "1200")], + sum( + values[("1", code)] + for code in ("1210", "1220", "1230", "1240", "1250", "1260") + ), + ) + self.assertEqual( + values[("1", "1600")], + values[("1", "1100")] + values[("1", "1200")], + ) + self.assertEqual( + values[("1", "1300")], + sum( + values[("1", code)] + for code in ("1310", "1320", "1340", "1350", "1360", "1370") + ), + ) + self.assertEqual( + values[("1", "1400")], + sum(values[("1", code)] for code in ("1410", "1420", "1430", "1450")), + ) + self.assertEqual( + values[("1", "1500")], + sum( + values[("1", code)] + for code in ("1510", "1520", "1530", "1540", "1550") + ), + ) + self.assertEqual(values[("1", "1600")], values[("1", "1700")]) + + self.assertEqual( + values[("2", "2100")], + values[("2", "2110")] - values[("2", "2120")], + ) + self.assertEqual( + values[("2", "2200")], + values[("2", "2100")] - values[("2", "2210")] - values[("2", "2220")], + ) + self.assertEqual( + values[("2", "2300")], + values[("2", "2200")] + + values[("2", "2310")] + + values[("2", "2320")] + - values[("2", "2330")] + + values[("2", "2340")] + - values[("2", "2350")], + ) + self.assertEqual( + values[("2", "2410")], + values[("2", "2411")] + values[("2", "2412")], + ) + self.assertEqual( + values[("2", "2400")], + values[("2", "2300")] - values[("2", "2410")] - values[("2", "2460")], + ) + self.assertEqual( + values[("2", "2500")], + values[("2", "2400")] + + values[("2", "2510")] + + values[("2", "2520")] + - values[("2", "2530")], + ) + + for previous_year, current_year in zip( + ordered_years, + ordered_years[1:], + strict=False, + ): + for key, current_line in lines_by_year[current_year].items(): + self.assertEqual( + current_line.period_start, + lines_by_year[previous_year][key].period_end, + ) + def test_delete_removes_only_the_fixed_twenty_companies(self): untouched = Organization.objects.create( name="Тестовая компания 99",