добавление итогов по компаниям в result table (шахматка)
This commit is contained in:
parent
967fa01fb6
commit
0f107f8cf8
@ -699,3 +699,10 @@ class IntercompanyResultTableOut(BaseModel):
|
|||||||
|
|
||||||
companies: list[IntercompanyMatrixCompanyOut]
|
companies: list[IntercompanyMatrixCompanyOut]
|
||||||
articles: list[IntercompanyResultArticleOut]
|
articles: list[IntercompanyResultArticleOut]
|
||||||
|
company_totals: list[IntercompanyResultAmountOut]
|
||||||
|
grand_total: Decimal
|
||||||
|
|
||||||
|
@field_validator("grand_total")
|
||||||
|
@classmethod
|
||||||
|
def _two_decimals(cls, value: Decimal) -> Decimal:
|
||||||
|
return value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||||
|
|||||||
@ -367,7 +367,19 @@ def calculate_result_table(
|
|||||||
group_total=sum((item.amount for item in company_amounts), ZERO),
|
group_total=sum((item.amount for item in company_amounts), ZERO),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
|
company_totals = [
|
||||||
|
IntercompanyResultAmountOut(
|
||||||
|
company_id=company.id,
|
||||||
|
amount=sum((article.amounts[index].amount for article in articles), ZERO),
|
||||||
|
)
|
||||||
|
for index, company in enumerate(companies)
|
||||||
|
]
|
||||||
|
return IntercompanyResultTableOut(
|
||||||
|
companies=list(companies),
|
||||||
|
articles=articles,
|
||||||
|
company_totals=company_totals,
|
||||||
|
grand_total=sum((article.group_total for article in articles), ZERO),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut:
|
def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut:
|
||||||
|
|||||||
@ -180,6 +180,18 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result
|
|||||||
total.number_format = MONEY_FORMAT
|
total.number_format = MONEY_FORMAT
|
||||||
total.font = Font(bold=True)
|
total.font = Font(bold=True)
|
||||||
|
|
||||||
|
summary_row = header_row + len(result.articles) + 1
|
||||||
|
_text(sheet, summary_row, 1, "Итого")
|
||||||
|
sheet.cell(summary_row, 1).font = Font(bold=True)
|
||||||
|
totals = {item.company_id: item.amount for item in result.company_totals}
|
||||||
|
for column, company in enumerate(result.companies, start=2):
|
||||||
|
cell = sheet.cell(summary_row, column, totals[company.id])
|
||||||
|
cell.number_format = MONEY_FORMAT
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
grand_total = sheet.cell(summary_row, total_column, result.grand_total)
|
||||||
|
grand_total.number_format = MONEY_FORMAT
|
||||||
|
grand_total.font = Font(bold=True)
|
||||||
|
|
||||||
sheet.freeze_panes = "B8"
|
sheet.freeze_panes = "B8"
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
workbook.save(buffer)
|
workbook.save(buffer)
|
||||||
|
|||||||
@ -694,6 +694,8 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
|
|||||||
assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"]
|
assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"]
|
||||||
assert receivables["group_total"] == "100.25"
|
assert receivables["group_total"] == "100.25"
|
||||||
assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables)
|
assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables)
|
||||||
|
assert sorted(item["amount"] for item in data["company_totals"]) == ["0.00", "100.25"]
|
||||||
|
assert data["grand_total"] == "100.25"
|
||||||
sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"]
|
sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"]
|
||||||
assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [
|
assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [
|
||||||
company["name"] for company in data["companies"]
|
company["name"] for company in data["companies"]
|
||||||
@ -704,6 +706,12 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
|
|||||||
for col, company in enumerate(data["companies"], start=2):
|
for col, company in enumerate(data["companies"], start=2):
|
||||||
assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]]
|
assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]]
|
||||||
assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"])
|
assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"])
|
||||||
|
summary_row = 8 + len(data["articles"])
|
||||||
|
assert sheet.cell(summary_row, 1).value == "Итого"
|
||||||
|
totals = {item["company_id"]: Decimal(item["amount"]) for item in data["company_totals"]}
|
||||||
|
for col, company in enumerate(data["companies"], start=2):
|
||||||
|
assert Decimal(str(sheet.cell(summary_row, col).value)) == totals[company["id"]]
|
||||||
|
assert Decimal(str(sheet.cell(summary_row, len(data["companies"]) + 2).value)) == Decimal(data["grand_total"])
|
||||||
event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
||||||
assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])}
|
assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])}
|
||||||
|
|
||||||
|
|||||||
@ -414,6 +414,11 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None:
|
|||||||
assert isinstance(result, IntercompanyResultTableOut)
|
assert isinstance(result, IntercompanyResultTableOut)
|
||||||
response = result
|
response = result
|
||||||
assert response.articles[0].amounts[0].company_id == company_a
|
assert response.articles[0].amounts[0].company_id == company_a
|
||||||
|
assert [(item.company_id, item.amount) for item in result.company_totals] == [
|
||||||
|
(company_a, Decimal("140.00")),
|
||||||
|
(company_b, Decimal("110.00")),
|
||||||
|
]
|
||||||
|
assert result.grand_total == Decimal("250.00")
|
||||||
|
|
||||||
|
|
||||||
def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None:
|
def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None:
|
||||||
|
|||||||
@ -339,6 +339,11 @@ async def test_result_table_export_matches_result_and_records_audit() -> None:
|
|||||||
group_total="120.25",
|
group_total="120.25",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
company_totals=[
|
||||||
|
IntercompanyResultAmountOut(company_id=version_id, amount="100.25"),
|
||||||
|
IntercompanyResultAmountOut(company_id=other_id, amount="20.00"),
|
||||||
|
],
|
||||||
|
grand_total="120.25",
|
||||||
)
|
)
|
||||||
info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2)
|
info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2)
|
||||||
service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info)))
|
service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info)))
|
||||||
@ -359,6 +364,7 @@ async def test_result_table_export_matches_result_and_records_audit() -> None:
|
|||||||
assert sheet["B7"].data_type == "s"
|
assert sheet["B7"].data_type == "s"
|
||||||
assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25]
|
assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25]
|
||||||
assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5))
|
assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5))
|
||||||
|
assert [sheet.cell(9, column).value for column in range(1, 5)] == ["Итого", 100.25, 20, 120.25]
|
||||||
assert sheet.freeze_panes == "B8"
|
assert sheet.freeze_panes == "B8"
|
||||||
assert audit.record.await_args.kwargs == {
|
assert audit.record.await_args.kwargs == {
|
||||||
"action": AuditAction.EXPORT,
|
"action": AuditAction.EXPORT,
|
||||||
@ -377,7 +383,12 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte
|
|||||||
version_id = UUID("11111111-1111-1111-1111-111111111111")
|
version_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||||
info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)
|
info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)
|
||||||
service = SimpleNamespace(
|
service = SimpleNamespace(
|
||||||
build_result_export=AsyncMock(return_value=(IntercompanyResultTableOut(companies=[], articles=[]), info))
|
build_result_export=AsyncMock(
|
||||||
|
return_value=(
|
||||||
|
IntercompanyResultTableOut(companies=[], articles=[], company_totals=[], grand_total="0"),
|
||||||
|
info,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
audit = SimpleNamespace(record=AsyncMock())
|
audit = SimpleNamespace(record=AsyncMock())
|
||||||
|
|
||||||
@ -397,6 +408,8 @@ def test_result_table_workbook_rejects_article_without_company_amount() -> None:
|
|||||||
result = IntercompanyResultTableOut(
|
result = IntercompanyResultTableOut(
|
||||||
companies=[company],
|
companies=[company],
|
||||||
articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")],
|
articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")],
|
||||||
|
company_totals=[IntercompanyResultAmountOut(company_id=company.id, amount="0")],
|
||||||
|
grand_total="0",
|
||||||
)
|
)
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2))
|
build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2))
|
||||||
|
|||||||
@ -253,18 +253,21 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
|||||||
|
|
||||||
### Requirement: Результирующая таблица ВГО
|
### Requirement: Результирующая таблица ВГО
|
||||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
|
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
|
||||||
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра
|
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[], company_totals[], grand_total}`. Компании — все члены периметра
|
||||||
по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`.
|
по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`.
|
||||||
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
|
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
|
||||||
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
|
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
|
||||||
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
||||||
`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
|
`company_totals[] {company_id, amount}` SHALL содержать сумму всех статей для каждого ЮЛ в порядке `companies[]`;
|
||||||
|
`grand_total` — сумму `group_total` всех статей. Итоги считаются из округлённых значений ответа.
|
||||||
|
`amount`, `group_total` и `grand_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
|
||||||
(например, `"0.00"`).
|
(например, `"0.00"`).
|
||||||
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||||
|
|
||||||
#### Scenario: Компания без ОСВ
|
#### Scenario: Компания без ОСВ
|
||||||
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
||||||
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"`
|
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"`
|
||||||
|
- **AND** её итог в `company_totals` — строка `"0.00"`
|
||||||
|
|
||||||
#### Scenario: Нет обязательного счёта
|
#### Scenario: Нет обязательного счёта
|
||||||
- **WHEN** в плане счетов отсутствует счёт 58.03
|
- **WHEN** в плане счетов отсутствует счёт 58.03
|
||||||
@ -273,7 +276,8 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
|||||||
### Requirement: XLSX результирующей таблицы ВГО
|
### Requirement: XLSX результирующей таблицы ВГО
|
||||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`,
|
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`,
|
||||||
policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`,
|
policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`,
|
||||||
из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
|
из того же расчёта сводной ОСВ версии. Последняя строка «Итого» содержит `company_totals` и `grand_total`.
|
||||||
|
Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
|
||||||
«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя
|
«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя
|
||||||
файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`. Ошибки — как у JSON-маршрута: невидимая или
|
файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`. Ошибки — как у JSON-маршрута: невидимая или
|
||||||
несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`.
|
несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user