Merge pull request 'AURORA-1652' (#203) from AURORA-1652 into main
This commit is contained in:
commit
31904e334a
@ -1,7 +1,6 @@
|
|||||||
"""Чтение версионированных правил элиминации и счетов их сторон."""
|
"""Чтение версионированных правил элиминации и счетов их сторон."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Collection
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@ -28,7 +27,7 @@ async def list_rules_with_accounts(
|
|||||||
|
|
||||||
|
|
||||||
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
|
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
|
||||||
"""Максимальный номер версии правил, действующей на дату создания"""
|
"""Максимальный номер версии правил §4, действующей на дату создания."""
|
||||||
return await session.scalar(
|
return await session.scalar(
|
||||||
select(func.max(EliminationRule.version_no)).where(
|
select(func.max(EliminationRule.version_no)).where(
|
||||||
EliminationRule.valid_from <= as_of,
|
EliminationRule.valid_from <= as_of,
|
||||||
@ -43,7 +42,7 @@ def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:
|
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:
|
||||||
"""Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера."""
|
"""Все правила §4 заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера."""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(EliminationRule)
|
select(EliminationRule)
|
||||||
.where(EliminationRule.version_no == version_no)
|
.where(EliminationRule.version_no == version_no)
|
||||||
@ -52,21 +51,6 @@ async def list_rules(session: AsyncSession, version_no: int) -> list[Elimination
|
|||||||
return list((await session.scalars(stmt)).all())
|
return list((await session.scalars(stmt)).all())
|
||||||
|
|
||||||
|
|
||||||
async def list_rule_accounts(
|
|
||||||
session: AsyncSession, rule_ids: Collection[uuid.UUID]
|
|
||||||
) -> list[tuple[EliminationRuleAccount, str]]:
|
|
||||||
"""Счета правил с кодами из плана счетов; `company_no` задаёт сторону, `is_excluded` — исключение."""
|
|
||||||
if not rule_ids:
|
|
||||||
return []
|
|
||||||
stmt = (
|
|
||||||
select(EliminationRuleAccount, ChartOfAccount.code)
|
|
||||||
.join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id)
|
|
||||||
.where(EliminationRuleAccount.rule_id.in_(rule_ids))
|
|
||||||
.order_by(EliminationRuleAccount.rule_id, EliminationRuleAccount.company_no, ChartOfAccount.code)
|
|
||||||
)
|
|
||||||
return [(account, code) for account, code in (await session.execute(stmt)).all()]
|
|
||||||
|
|
||||||
|
|
||||||
async def rule_with_account(
|
async def rule_with_account(
|
||||||
session: AsyncSession, rule_id: uuid.UUID
|
session: AsyncSession, rule_id: uuid.UUID
|
||||||
) -> tuple[EliminationRule | None, list[EliminationRuleAccount]]:
|
) -> tuple[EliminationRule | None, list[EliminationRuleAccount]]:
|
||||||
|
|||||||
@ -336,6 +336,8 @@ class EliminationRuleOut(BaseModel):
|
|||||||
rule_no: int
|
rule_no: int
|
||||||
version_no: int
|
version_no: int
|
||||||
statement_type: StatementType
|
statement_type: StatementType
|
||||||
|
article_1_line: str
|
||||||
|
article_2_line: str
|
||||||
side_1: Literal["D", "K"]
|
side_1: Literal["D", "K"]
|
||||||
measure: Literal["closing", "turnover"]
|
measure: Literal["closing", "turnover"]
|
||||||
company_1: IntercompanyMatrixRuleSideOut
|
company_1: IntercompanyMatrixRuleSideOut
|
||||||
@ -510,6 +512,11 @@ class IntercompanyResultAmountOut(BaseModel):
|
|||||||
company_id: uuid.UUID
|
company_id: uuid.UUID
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
|
|
||||||
|
@field_validator("amount")
|
||||||
|
@classmethod
|
||||||
|
def _two_decimals(cls, value: Decimal) -> Decimal:
|
||||||
|
return value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
class IntercompanyResultArticleOut(BaseModel):
|
class IntercompanyResultArticleOut(BaseModel):
|
||||||
"""Статья ВГО и её суммы по компаниям и группе."""
|
"""Статья ВГО и её суммы по компаниям и группе."""
|
||||||
@ -519,6 +526,11 @@ class IntercompanyResultArticleOut(BaseModel):
|
|||||||
amounts: list[IntercompanyResultAmountOut]
|
amounts: list[IntercompanyResultAmountOut]
|
||||||
group_total: Decimal
|
group_total: Decimal
|
||||||
|
|
||||||
|
@field_validator("group_total")
|
||||||
|
@classmethod
|
||||||
|
def _two_decimals(cls, value: Decimal) -> Decimal:
|
||||||
|
return value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
class IntercompanyResultTableOut(BaseModel):
|
class IntercompanyResultTableOut(BaseModel):
|
||||||
"""Фиксированная результирующая таблица ВГО выбранной версии."""
|
"""Фиксированная результирующая таблица ВГО выбранной версии."""
|
||||||
|
|||||||
@ -39,6 +39,8 @@ class EliminationRuleService:
|
|||||||
"rule_no": rule.rule_no,
|
"rule_no": rule.rule_no,
|
||||||
"version_no": rule.version_no,
|
"version_no": rule.version_no,
|
||||||
"statement_type": rule.statement_type,
|
"statement_type": rule.statement_type,
|
||||||
|
"article_1_line": rule.article_1_line,
|
||||||
|
"article_2_line": rule.article_2_line,
|
||||||
"side_1": rule.side_1,
|
"side_1": rule.side_1,
|
||||||
"measure": rule.measure,
|
"measure": rule.measure,
|
||||||
"company_1": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]),
|
"company_1": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]),
|
||||||
|
|||||||
@ -198,7 +198,7 @@ def calculate_matrix(
|
|||||||
member_companies: Sequence[IntercompanyMatrixCompanyOut],
|
member_companies: Sequence[IntercompanyMatrixCompanyOut],
|
||||||
selection: MatrixSelection,
|
selection: MatrixSelection,
|
||||||
) -> IntercompanyMatrixOut:
|
) -> IntercompanyMatrixOut:
|
||||||
"""Суммы по парам компаний;
|
"""Суммы по парам компаний для шахматки (§6).
|
||||||
|
|
||||||
Сторона 1: дебет ОСВ компании A по контрагенту Б.
|
Сторона 1: дебет ОСВ компании A по контрагенту Б.
|
||||||
Сторона 2: кредит ОСВ компании Б по контрагенту A.
|
Сторона 2: кредит ОСВ компании Б по контрагенту A.
|
||||||
@ -318,7 +318,7 @@ def calculate_result_table(
|
|||||||
member_companies: Sequence[IntercompanyMatrixCompanyOut],
|
member_companies: Sequence[IntercompanyMatrixCompanyOut],
|
||||||
accounts: Sequence[ChartOfAccount],
|
accounts: Sequence[ChartOfAccount],
|
||||||
) -> IntercompanyResultTableOut:
|
) -> IntercompanyResultTableOut:
|
||||||
"""Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ."""
|
"""Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ (§6)."""
|
||||||
by_id, children = _make_account_tree(accounts)
|
by_id, children = _make_account_tree(accounts)
|
||||||
by_code = {account.code: account.id for account in accounts}
|
by_code = {account.code: account.id for account in accounts}
|
||||||
required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)}
|
required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)}
|
||||||
@ -609,15 +609,6 @@ class IntercompanyMatrixService:
|
|||||||
items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members))
|
items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members))
|
||||||
return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items)
|
return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items)
|
||||||
|
|
||||||
async def build(
|
|
||||||
self,
|
|
||||||
version_id: uuid.UUID,
|
|
||||||
selection: MatrixSelection,
|
|
||||||
) -> IntercompanyMatrixOut:
|
|
||||||
"""Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом."""
|
|
||||||
result = await self._osv_service.build_with_members(version_id)
|
|
||||||
return self._calculate(result.consolidated.rows, result.members, selection)
|
|
||||||
|
|
||||||
def _calculate(
|
def _calculate(
|
||||||
self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection
|
self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection
|
||||||
) -> IntercompanyMatrixOut:
|
) -> IntercompanyMatrixOut:
|
||||||
|
|||||||
@ -234,7 +234,9 @@ async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, s
|
|||||||
assert listed.json()["is_version_fixed"] is True
|
assert listed.json()["is_version_fixed"] is True
|
||||||
assert listed.json()["version_no"] == rule.version_no
|
assert listed.json()["version_no"] == rule.version_no
|
||||||
assert "as_of" not in listed.json()
|
assert "as_of" not in listed.json()
|
||||||
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
|
listed_rule = next(item for item in listed.json()["rules"] if item["id"] == str(rule.id))
|
||||||
|
assert listed_rule["article_1_line"] == rule.article_1_line
|
||||||
|
assert listed_rule["article_2_line"] == rule.article_2_line
|
||||||
pairs = await client.get(
|
pairs = await client.get(
|
||||||
f"/consolidations/versions/{world.version.id}/elimination-pairs",
|
f"/consolidations/versions/{world.version.id}/elimination-pairs",
|
||||||
params={
|
params={
|
||||||
@ -688,6 +690,10 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
|
|||||||
export = await _request(client, world.version.id, "result-table/export", world.selection)
|
export = await _request(client, world.version.id, "result-table/export", world.selection)
|
||||||
assert result.status_code == export.status_code == OK
|
assert result.status_code == export.status_code == OK
|
||||||
data = result.json()
|
data = result.json()
|
||||||
|
receivables = next(article for article in data["articles"] if article["code"] == "receivables")
|
||||||
|
assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"]
|
||||||
|
assert receivables["group_total"] == "100.25"
|
||||||
|
assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables)
|
||||||
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"]
|
||||||
|
|||||||
@ -17,6 +17,8 @@ def _rule() -> SimpleNamespace:
|
|||||||
statement_type=StatementType.balance,
|
statement_type=StatementType.balance,
|
||||||
rule_no=2,
|
rule_no=2,
|
||||||
version_no=3,
|
version_no=3,
|
||||||
|
article_1_line="1230",
|
||||||
|
article_2_line="1520",
|
||||||
side_1="D",
|
side_1="D",
|
||||||
measure="closing",
|
measure="closing",
|
||||||
)
|
)
|
||||||
@ -63,5 +65,6 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
assert result.is_version_fixed is True
|
assert result.is_version_fixed is True
|
||||||
assert result.version_no == rule.version_no
|
assert result.version_no == rule.version_no
|
||||||
assert [item.id for item in result.rules] == [rule.id]
|
assert [item.id for item in result.rules] == [rule.id]
|
||||||
|
assert (result.rules[0].article_1_line, result.rules[0].article_2_line) == ("1230", "1520")
|
||||||
assert result.rules[0].company_1.accounts == [account_id]
|
assert result.rules[0].company_1.accounts == [account_id]
|
||||||
listed.assert_awaited_once_with(session, rule.version_no)
|
listed.assert_awaited_once_with(session, rule.version_no)
|
||||||
|
|||||||
@ -115,6 +115,22 @@ def test_consolidated_row_money_has_two_decimals() -> None:
|
|||||||
assert (dumped["opening_debit"], dumped["turnover_debit"], dumped["closing_debit"]) == ("0.00", "30.00", "7.00")
|
assert (dumped["opening_debit"], dumped["turnover_debit"], dumped["closing_debit"]) == ("0.00", "30.00", "7.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_table_money_has_two_decimals_with_half_up_rounding() -> None:
|
||||||
|
company_id = uuid.uuid4()
|
||||||
|
article = schemas.IntercompanyResultArticleOut(
|
||||||
|
code="receivables",
|
||||||
|
name="Дебиторская задолженность",
|
||||||
|
amounts=[
|
||||||
|
schemas.IntercompanyResultAmountOut(company_id=company_id, amount=Decimal("0")),
|
||||||
|
schemas.IntercompanyResultAmountOut(company_id=uuid.uuid4(), amount=Decimal("1.005")),
|
||||||
|
],
|
||||||
|
group_total=Decimal("1.005"),
|
||||||
|
)
|
||||||
|
dumped = article.model_dump(mode="json")
|
||||||
|
assert [item["amount"] for item in dumped["amounts"]] == ["0.00", "1.01"]
|
||||||
|
assert dumped["group_total"] == "1.01"
|
||||||
|
|
||||||
|
|
||||||
def test_entity_files_state_is_frozen() -> None:
|
def test_entity_files_state_is_frozen() -> None:
|
||||||
state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR)
|
state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR)
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
|||||||
@ -258,11 +258,13 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
|||||||
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
|
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
|
||||||
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
|
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
|
||||||
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
||||||
|
`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
|
||||||
|
(например, `"0.00"`).
|
||||||
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||||
|
|
||||||
#### Scenario: Компания без ОСВ
|
#### Scenario: Компания без ОСВ
|
||||||
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
||||||
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
|
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"`
|
||||||
|
|
||||||
#### Scenario: Нет обязательного счёта
|
#### Scenario: Нет обязательного счёта
|
||||||
- **WHEN** в плане счетов отсутствует счёт 58.03
|
- **WHEN** в плане счетов отсутствует счёт 58.03
|
||||||
@ -294,8 +296,9 @@ policy) SHALL возвращать XLSX с теми же статьями, ко
|
|||||||
SHALL возвращать объект `{version_no, is_version_fixed, rules}`: `version_no` — `consolidation_version.elimination_rules_version`
|
SHALL возвращать объект `{version_no, is_version_fixed, rules}`: `version_no` — `consolidation_version.elimination_rules_version`
|
||||||
(с DBML v5.23 задан у каждой версии), `is_version_fixed = true` (поле оставлено для совместимости клиента), `rules` —
|
(с DBML v5.23 задан у каждой версии), `is_version_fixed = true` (поле оставлено для совместимости клиента), `rules` —
|
||||||
правила этой версии; правила других версий, в том числе более новых, MUST NOT попадать в ответ. Каждое правило SHALL
|
правила этой версии; правила других версий, в том числе более новых, MUST NOT попадать в ответ. Каждое правило SHALL
|
||||||
содержать `id`, `rule_no`, `version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`),
|
содержать `id`, `rule_no`, `version_no`, `statement_type`, `article_1_line`, `article_2_line`, `side_1` (`D`/`K`),
|
||||||
`company_1`, `company_2`; каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии
|
`measure` (`closing`/`turnover`),
|
||||||
|
`company_1`, `company_2`; `elimination_mode` в ответ не входит (режим элиминации отдаёт `elimination-totals`). Каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии
|
||||||
счетов. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
|
счетов. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
|
||||||
|
|
||||||
#### Scenario: Правило с исключением
|
#### Scenario: Правило с исключением
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user