тесты + немного поправлен формат выходных данных
This commit is contained in:
parent
010c15386b
commit
827b490137
@ -51,6 +51,28 @@ class PackingResult:
|
|||||||
ambiguous_keys: frozenset[RowKey]
|
ambiguous_keys: frozenset[RowKey]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuleTotals:
|
||||||
|
"""Итог тройки до упаковки строк; упаковка его не меняет."""
|
||||||
|
|
||||||
|
amount_a: Decimal
|
||||||
|
amount_b: Decimal
|
||||||
|
deviation_amount: Decimal
|
||||||
|
deviation_percent: Decimal | None
|
||||||
|
eliminated_amount: Decimal
|
||||||
|
exceeds_threshold: bool
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
|
||||||
|
"""Правило 5 % для всей тройки, независимо от сопоставления отдельных строк."""
|
||||||
|
deviation = abs(amount_a - amount_b)
|
||||||
|
base = min(amount_a, amount_b)
|
||||||
|
percent = deviation / base * Decimal("100") if base > ZERO else None
|
||||||
|
exceeds_threshold = deviation > ZERO if base == ZERO else percent is not None and percent > MAX_DEVIATION_PERCENT
|
||||||
|
eliminated = base if not manual and not exceeds_threshold else ZERO
|
||||||
|
return RuleTotals(amount_a, amount_b, deviation, percent, eliminated, exceeds_threshold)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _Candidate:
|
class _Candidate:
|
||||||
deviation: Decimal
|
deviation: Decimal
|
||||||
@ -80,7 +102,7 @@ def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> Knapsa
|
|||||||
|
|
||||||
if len(candidates) - start < remaining:
|
if len(candidates) - start < remaining:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Промежуточную сумму сравниваем с нижней границей лишь с учётом оставшихся строк.
|
# Промежуточную сумму сравниваем с нижней границей лишь с учётом оставшихся строк.
|
||||||
largest_total = total + sum((row.amount for row in candidates[-remaining:]), ZERO)
|
largest_total = total + sum((row.amount for row in candidates[-remaining:]), ZERO)
|
||||||
if largest_total * limit < capacity * hundred_percent:
|
if largest_total * limit < capacity * hundred_percent:
|
||||||
|
|||||||
@ -377,6 +377,12 @@ class EliminationPairsOut(BaseModel):
|
|||||||
rule_no: int
|
rule_no: int
|
||||||
company_a_id: uuid.UUID
|
company_a_id: uuid.UUID
|
||||||
company_b_id: uuid.UUID
|
company_b_id: uuid.UUID
|
||||||
|
amount_a: Decimal
|
||||||
|
amount_b: Decimal
|
||||||
|
elimination_amount: Decimal
|
||||||
|
deviation_percent: Decimal | None
|
||||||
|
eliminated_amount: Decimal
|
||||||
|
exceeds_threshold: bool
|
||||||
matched: list[EliminationMatchedPairOut]
|
matched: list[EliminationMatchedPairOut]
|
||||||
with_discrepancy: list[EliminationMatchedPairOut]
|
with_discrepancy: list[EliminationMatchedPairOut]
|
||||||
unpaired: list[EliminationUnpairedRowOut]
|
unpaired: list[EliminationUnpairedRowOut]
|
||||||
|
|||||||
@ -12,7 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.common.exceptions import DomainValidationError, NotFoundError
|
from src.common.exceptions import DomainValidationError, NotFoundError
|
||||||
from src.domain.consolidated_osv import ConsolidatedRow, Entity
|
from src.domain.consolidated_osv import ConsolidatedRow, Entity
|
||||||
from src.domain.elimination_packing import PackedGroup, PackRow, RowKey, pack_rule_rows
|
from src.domain.elimination_packing import (
|
||||||
|
PackedGroup,
|
||||||
|
PackRow,
|
||||||
|
RowKey,
|
||||||
|
RuleTotals,
|
||||||
|
calculate_rule_totals,
|
||||||
|
pack_rule_rows,
|
||||||
|
)
|
||||||
from src.domain.policy import VisibilityScope
|
from src.domain.policy import VisibilityScope
|
||||||
from src.models.accounting import ChartOfAccount, EliminationRule
|
from src.models.accounting import ChartOfAccount, EliminationRule
|
||||||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||||
@ -166,6 +173,14 @@ class PairContext:
|
|||||||
company_b_id: uuid.UUID
|
company_b_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RulePacking:
|
||||||
|
matched: list[EliminationMatchedPairOut]
|
||||||
|
with_discrepancy: list[EliminationMatchedPairOut]
|
||||||
|
unpaired: list[EliminationUnpairedRowOut]
|
||||||
|
totals: RuleTotals
|
||||||
|
|
||||||
|
|
||||||
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
||||||
if measure == "closing":
|
if measure == "closing":
|
||||||
value = row.closing_debit if side == "D" else row.closing_credit
|
value = row.closing_debit if side == "D" else row.closing_credit
|
||||||
@ -391,7 +406,7 @@ def _pack_preset_rows(
|
|||||||
rows: Sequence[ConsolidatedRow],
|
rows: Sequence[ConsolidatedRow],
|
||||||
selection: MatrixSelection,
|
selection: MatrixSelection,
|
||||||
context: PairContext,
|
context: PairContext,
|
||||||
) -> tuple[list[EliminationMatchedPairOut], list[EliminationMatchedPairOut], list[EliminationUnpairedRowOut]]:
|
) -> RulePacking:
|
||||||
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
|
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
|
||||||
rule = context.rule
|
rule = context.rule
|
||||||
company_a_id = context.company_a_id
|
company_a_id = context.company_a_id
|
||||||
@ -425,6 +440,11 @@ def _pack_preset_rows(
|
|||||||
if in_credit and credit_amount > ZERO:
|
if in_credit and credit_amount > ZERO:
|
||||||
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
|
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
|
||||||
|
|
||||||
|
rows_a = debit_rows if rule.side_1 == "D" else credit_rows
|
||||||
|
rows_b = credit_rows if rule.side_1 == "D" else debit_rows
|
||||||
|
amount_a = sum((row.amount for row in rows_a), ZERO)
|
||||||
|
amount_b = sum((row.amount for row in rows_b), ZERO)
|
||||||
|
totals = calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
|
||||||
packed = pack_rule_rows(debit_rows, credit_rows)
|
packed = pack_rule_rows(debit_rows, credit_rows)
|
||||||
matched: list[EliminationMatchedPairOut] = []
|
matched: list[EliminationMatchedPairOut] = []
|
||||||
with_discrepancy: list[EliminationMatchedPairOut] = []
|
with_discrepancy: list[EliminationMatchedPairOut] = []
|
||||||
@ -443,7 +463,7 @@ def _pack_preset_rows(
|
|||||||
else:
|
else:
|
||||||
reason = "ZERO_AMOUNT"
|
reason = "ZERO_AMOUNT"
|
||||||
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
|
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
|
||||||
return matched, with_discrepancy, unpaired
|
return RulePacking(matched, with_discrepancy, unpaired, totals)
|
||||||
|
|
||||||
|
|
||||||
class IntercompanyMatrixService:
|
class IntercompanyMatrixService:
|
||||||
@ -469,7 +489,7 @@ class IntercompanyMatrixService:
|
|||||||
# TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
|
# TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
|
||||||
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
|
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
|
||||||
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
|
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
|
||||||
matched, with_discrepancy, unpaired = _pack_preset_rows(
|
packing = _pack_preset_rows(
|
||||||
osv_result.consolidated.rows,
|
osv_result.consolidated.rows,
|
||||||
selection,
|
selection,
|
||||||
PairContext(rule, company_a_id, company_b_id),
|
PairContext(rule, company_a_id, company_b_id),
|
||||||
@ -480,9 +500,15 @@ class IntercompanyMatrixService:
|
|||||||
rule_no=rule.rule_no,
|
rule_no=rule.rule_no,
|
||||||
company_a_id=company_a_id,
|
company_a_id=company_a_id,
|
||||||
company_b_id=company_b_id,
|
company_b_id=company_b_id,
|
||||||
matched=matched,
|
amount_a=packing.totals.amount_a,
|
||||||
with_discrepancy=with_discrepancy,
|
amount_b=packing.totals.amount_b,
|
||||||
unpaired=unpaired,
|
elimination_amount=packing.totals.deviation_amount,
|
||||||
|
deviation_percent=packing.totals.deviation_percent,
|
||||||
|
eliminated_amount=packing.totals.eliminated_amount,
|
||||||
|
exceeds_threshold=packing.totals.exceeds_threshold,
|
||||||
|
matched=packing.matched,
|
||||||
|
with_discrepancy=packing.with_discrepancy,
|
||||||
|
unpaired=packing.unpaired,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def build(
|
async def build(
|
||||||
|
|||||||
@ -170,7 +170,7 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
|
|||||||
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
|
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
|
||||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
group, gsk, _ = await _scenario(seeded)
|
group, gsk, entities = await _scenario(seeded)
|
||||||
today = moscow_date(utc_now())
|
today = moscow_date(utc_now())
|
||||||
source = (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == 1))).one()
|
source = (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == 1))).one()
|
||||||
next_rule = EliminationRule(
|
next_rule = EliminationRule(
|
||||||
@ -203,6 +203,21 @@ async def test_create_fixes_rules_for_creation_date_not_reporting_period(
|
|||||||
assert second_version.elimination_rules_version == next_rule.version_no
|
assert second_version.elimination_rules_version == next_rule.version_no
|
||||||
assert first_version.elimination_rules_version == 1
|
assert first_version.elimination_rules_version == 1
|
||||||
|
|
||||||
|
pairs_url = f"/consolidations/versions/{first_version.id}/elimination-pairs"
|
||||||
|
params = {
|
||||||
|
"rule_id": str(source.id),
|
||||||
|
"company_a_id": str(entities[0].id),
|
||||||
|
"company_b_id": str(entities[1].id),
|
||||||
|
}
|
||||||
|
for _ in range(2):
|
||||||
|
pairs = await client.get(pairs_url, params=params)
|
||||||
|
assert pairs.status_code == HTTP_OK, pairs.text
|
||||||
|
assert pairs.json()["version_no"] == 1
|
||||||
|
assert pairs.json()["matched"] == []
|
||||||
|
assert (await client.get(pairs_url, params={**params, "rule_id": str(next_rule.id)})).status_code == HTTP_NOT_FOUND
|
||||||
|
await seeded.refresh(first_version)
|
||||||
|
assert first_version.elimination_rules_version == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||||
|
|||||||
@ -217,6 +217,16 @@ async def test_preset_uses_current_rules_when_version_unfixed(
|
|||||||
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"]}
|
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
|
||||||
|
pairs = await client.get(
|
||||||
|
f"/consolidations/versions/{world.version.id}/elimination-pairs",
|
||||||
|
params={
|
||||||
|
"rule_id": str(rule.id),
|
||||||
|
"company_a_id": str(world.members[0].id),
|
||||||
|
"company_b_id": str(world.members[1].id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert pairs.status_code == OK, pairs.text
|
||||||
|
assert world.version.elimination_rules_version is None # GET не фиксирует номер правил
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
assert response.status_code == OK, response.text
|
assert response.status_code == OK, response.text
|
||||||
assert (await client.get(f"{url}/export")).status_code == OK
|
assert (await client.get(f"{url}/export")).status_code == OK
|
||||||
@ -305,6 +315,76 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
|||||||
"counterparty_id": world.members[1].id,
|
"counterparty_id": world.members[1].id,
|
||||||
"account_id": world.accounts["60.02"].id,
|
"account_id": world.accounts["60.02"].id,
|
||||||
"closing_debit": Decimal("60"),
|
"closing_debit": Decimal("60"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": world.members[2].name,
|
||||||
|
"counterparty_id": world.members[2].id,
|
||||||
|
"account_id": world.accounts["60.02"].id,
|
||||||
|
"closing_debit": Decimal("20"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
reverse_debit_document = await make_confirmed_osv(
|
||||||
|
seeded,
|
||||||
|
entity=world.members[1],
|
||||||
|
account="62.01",
|
||||||
|
year=2024,
|
||||||
|
quarter=PeriodQuarter.Q1,
|
||||||
|
is_cumulative=True,
|
||||||
|
rows=[
|
||||||
|
{
|
||||||
|
"name": world.members[0].name,
|
||||||
|
"counterparty_id": world.members[0].id,
|
||||||
|
"account_id": world.accounts["62.01"].id,
|
||||||
|
"closing_debit": Decimal("70"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
reverse_credit_document = await make_confirmed_osv(
|
||||||
|
seeded,
|
||||||
|
entity=world.members[0],
|
||||||
|
account="60.01",
|
||||||
|
year=2024,
|
||||||
|
quarter=PeriodQuarter.Q1,
|
||||||
|
is_cumulative=True,
|
||||||
|
rows=[
|
||||||
|
{
|
||||||
|
"name": world.members[1].name,
|
||||||
|
"counterparty_id": world.members[1].id,
|
||||||
|
"account_id": world.accounts["60.01"].id,
|
||||||
|
"closing_credit": Decimal("70"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
manual_debit_document = await make_confirmed_osv(
|
||||||
|
seeded,
|
||||||
|
entity=world.members[0],
|
||||||
|
account="03",
|
||||||
|
year=2024,
|
||||||
|
quarter=PeriodQuarter.Q1,
|
||||||
|
is_cumulative=True,
|
||||||
|
rows=[
|
||||||
|
{
|
||||||
|
"name": world.members[1].name,
|
||||||
|
"counterparty_id": world.members[1].id,
|
||||||
|
"account_id": world.accounts["03"].id,
|
||||||
|
"closing_debit": Decimal("100"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
manual_credit_document = await make_confirmed_osv(
|
||||||
|
seeded,
|
||||||
|
entity=world.members[1],
|
||||||
|
account="76.07.1",
|
||||||
|
year=2024,
|
||||||
|
quarter=PeriodQuarter.Q1,
|
||||||
|
is_cumulative=True,
|
||||||
|
rows=[
|
||||||
|
{
|
||||||
|
"name": world.members[0].name,
|
||||||
|
"counterparty_id": world.members[0].id,
|
||||||
|
"account_id": world.accounts["76.07.1"].id,
|
||||||
|
"closing_credit": Decimal("100"),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -343,7 +423,16 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
|||||||
seeded.add_all(
|
seeded.add_all(
|
||||||
[
|
[
|
||||||
ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)
|
ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)
|
||||||
for document in (debit_document, second_debit_document, credit_document, other_credit_document)
|
for document in (
|
||||||
|
debit_document,
|
||||||
|
second_debit_document,
|
||||||
|
credit_document,
|
||||||
|
other_credit_document,
|
||||||
|
reverse_debit_document,
|
||||||
|
reverse_credit_document,
|
||||||
|
manual_debit_document,
|
||||||
|
manual_credit_document,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
await seeded.flush()
|
await seeded.flush()
|
||||||
@ -364,6 +453,10 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
assert "account_pairs" not in payload
|
assert "account_pairs" not in payload
|
||||||
assert payload["with_discrepancy"] == []
|
assert payload["with_discrepancy"] == []
|
||||||
|
assert (Decimal(payload["amount_a"]), Decimal(payload["amount_b"])) == (Decimal("300"), Decimal("100"))
|
||||||
|
assert Decimal(payload["elimination_amount"]) == Decimal("200")
|
||||||
|
assert payload["exceeds_threshold"] is True
|
||||||
|
assert Decimal(payload["eliminated_amount"]) == 0
|
||||||
assert len(payload["matched"]) == 1
|
assert len(payload["matched"]) == 1
|
||||||
pair = payload["matched"][0]
|
pair = payload["matched"][0]
|
||||||
assert len(pair["osv_entry_ids_a"]) == len((debit_document, second_debit_document))
|
assert len(pair["osv_entry_ids_a"]) == len((debit_document, second_debit_document))
|
||||||
@ -384,7 +477,8 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
|||||||
assert loans.json()["matched"] == []
|
assert loans.json()["matched"] == []
|
||||||
manual = await client.get(url, params={**params, "rule_id": str(rule_ids[1])})
|
manual = await client.get(url, params={**params, "rule_id": str(rule_ids[1])})
|
||||||
assert manual.status_code == OK, manual.text
|
assert manual.status_code == OK, manual.text
|
||||||
assert manual.json()["matched"] == []
|
assert len(manual.json()["matched"]) == 1
|
||||||
|
assert Decimal(manual.json()["eliminated_amount"]) == 0
|
||||||
|
|
||||||
other_pair = await client.get(url, params={**params, "company_b_id": str(company_c_id)})
|
other_pair = await client.get(url, params={**params, "company_b_id": str(company_c_id)})
|
||||||
assert other_pair.status_code == OK, other_pair.text
|
assert other_pair.status_code == OK, other_pair.text
|
||||||
@ -395,14 +489,27 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
|
|||||||
assert Decimal(discrepant["elimination_amount"]) == Decimal("3")
|
assert Decimal(discrepant["elimination_amount"]) == Decimal("3")
|
||||||
assert "article_ids_a" not in discrepant and "article_ids_b" not in discrepant
|
assert "article_ids_a" not in discrepant and "article_ids_b" not in discrepant
|
||||||
assert "deviation_amount" not in discrepant
|
assert "deviation_amount" not in discrepant
|
||||||
|
assert (Decimal(other_pair.json()["amount_a"]), Decimal(other_pair.json()["amount_b"])) == (
|
||||||
|
Decimal("123"),
|
||||||
|
Decimal("100"),
|
||||||
|
)
|
||||||
|
assert other_pair.json()["exceeds_threshold"] is True
|
||||||
|
assert Decimal(other_pair.json()["eliminated_amount"]) == 0
|
||||||
|
|
||||||
reversed_pair = await client.get(
|
reversed_pair = await client.get(
|
||||||
url, params={**params, "company_a_id": str(company_b_id), "company_b_id": str(company_a_id)}
|
url, params={**params, "company_a_id": str(company_b_id), "company_b_id": str(company_a_id)}
|
||||||
)
|
)
|
||||||
assert reversed_pair.status_code == OK, reversed_pair.text
|
assert reversed_pair.status_code == OK, reversed_pair.text
|
||||||
assert reversed_pair.json()["matched"] == []
|
assert len(reversed_pair.json()["matched"]) == 1
|
||||||
assert reversed_pair.json()["with_discrepancy"] == []
|
assert reversed_pair.json()["with_discrepancy"] == []
|
||||||
assert reversed_pair.json()["unpaired"] == []
|
assert reversed_pair.json()["unpaired"] == []
|
||||||
|
assert reversed_pair.json()["matched"][0]["osv_entry_ids_a"]
|
||||||
|
assert reversed_pair.json()["matched"][0]["osv_entry_ids_b"]
|
||||||
|
assert (Decimal(reversed_pair.json()["amount_a"]), Decimal(reversed_pair.json()["amount_b"])) == (
|
||||||
|
Decimal("70"),
|
||||||
|
Decimal("70"),
|
||||||
|
)
|
||||||
|
assert Decimal(reversed_pair.json()["eliminated_amount"]) == Decimal("70")
|
||||||
|
|
||||||
invalid_pair = await client.get(url, params={**params, "company_b_id": str(uuid.uuid4())})
|
invalid_pair = await client.get(url, params={**params, "company_b_id": str(uuid.uuid4())})
|
||||||
assert invalid_pair.status_code == UNPROCESSABLE
|
assert invalid_pair.status_code == UNPROCESSABLE
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, pack_rule_rows
|
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows
|
||||||
|
|
||||||
A = uuid.uuid4()
|
A = uuid.uuid4()
|
||||||
B = uuid.uuid4()
|
B = uuid.uuid4()
|
||||||
@ -77,3 +77,36 @@ def test_five_percent_boundary_and_no_many_to_many() -> None:
|
|||||||
|
|
||||||
over_threshold = pack_rule_rows([_row("100", debit=True)], [_row("106", debit=False)])
|
over_threshold = pack_rule_rows([_row("100", debit=True)], [_row("106", debit=False)])
|
||||||
assert over_threshold.groups == ()
|
assert over_threshold.groups == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_totals_eliminate_minimum_within_five_percent() -> None:
|
||||||
|
totals = calculate_rule_totals(Decimal("110429113.15"), Decimal("106311116.46"), manual=False)
|
||||||
|
|
||||||
|
assert totals.amount_a == Decimal("110429113.15")
|
||||||
|
assert totals.amount_b == Decimal("106311116.46")
|
||||||
|
assert totals.deviation_amount == Decimal("4117996.69")
|
||||||
|
assert totals.deviation_percent is not None
|
||||||
|
assert totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
|
||||||
|
assert totals.eliminated_amount == Decimal("106311116.46")
|
||||||
|
assert totals.exceeds_threshold is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None:
|
||||||
|
over = calculate_rule_totals(Decimal("106"), Decimal("100"), manual=False)
|
||||||
|
assert over.deviation_amount == Decimal("6")
|
||||||
|
assert over.deviation_percent == Decimal("6")
|
||||||
|
assert over.exceeds_threshold is True
|
||||||
|
assert over.eliminated_amount == 0
|
||||||
|
|
||||||
|
manual = calculate_rule_totals(Decimal("100"), Decimal("100"), manual=True)
|
||||||
|
assert manual.exceeds_threshold is False
|
||||||
|
assert manual.eliminated_amount == 0
|
||||||
|
|
||||||
|
boundary = calculate_rule_totals(Decimal("105"), Decimal("100"), manual=False)
|
||||||
|
assert boundary.exceeds_threshold is False
|
||||||
|
assert boundary.eliminated_amount == Decimal("100")
|
||||||
|
|
||||||
|
one_sided = calculate_rule_totals(Decimal("100"), Decimal("0"), manual=False)
|
||||||
|
assert one_sided.deviation_percent is None
|
||||||
|
assert one_sided.exceeds_threshold is True
|
||||||
|
assert one_sided.eliminated_amount == 0
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import pytest
|
|||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
from src.schemas.consolidations import IntercompanyMatrixCompanyOut
|
from src.schemas.consolidations import IntercompanyMatrixCompanyOut
|
||||||
|
from src.services import intercompany_matrix as matrix
|
||||||
from src.services.intercompany_matrix import MatrixSelection, calculate_matrix
|
from src.services.intercompany_matrix import MatrixSelection, calculate_matrix
|
||||||
|
|
||||||
pytestmark = pytest.mark.fixtures
|
pytestmark = pytest.mark.fixtures
|
||||||
@ -116,6 +117,7 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
|
|||||||
legal_entity_id=RB,
|
legal_entity_id=RB,
|
||||||
counterparty_id=BAKERY,
|
counterparty_id=BAKERY,
|
||||||
account_id=credit_account,
|
account_id=credit_account,
|
||||||
|
account_code="66.03",
|
||||||
closing_debit=Decimal(0),
|
closing_debit=Decimal(0),
|
||||||
closing_credit=amount,
|
closing_credit=amount,
|
||||||
entry_ids=(uuid.uuid4(),),
|
entry_ids=(uuid.uuid4(),),
|
||||||
@ -129,12 +131,18 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
|
|||||||
assert one_sided.amount_a == 0
|
assert one_sided.amount_a == 0
|
||||||
assert one_sided.amount_b == amount
|
assert one_sided.amount_b == amount
|
||||||
assert one_sided.one_sided is True
|
assert one_sided.one_sided is True
|
||||||
|
context = matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB)
|
||||||
|
unmatched = matrix._pack_preset_rows([credit_row], selection, context)
|
||||||
|
assert unmatched.matched == []
|
||||||
|
assert [item.reason for item in unmatched.unpaired] == ["NO_MATCH"]
|
||||||
|
assert unmatched.totals.eliminated_amount == 0
|
||||||
|
|
||||||
# В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4.
|
# В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4.
|
||||||
debit_row = SimpleNamespace(
|
debit_row = SimpleNamespace(
|
||||||
legal_entity_id=BAKERY,
|
legal_entity_id=BAKERY,
|
||||||
counterparty_id=RB,
|
counterparty_id=RB,
|
||||||
account_id=debit_account,
|
account_id=debit_account,
|
||||||
|
account_code="58.03",
|
||||||
closing_debit=amount,
|
closing_debit=amount,
|
||||||
closing_credit=Decimal(0),
|
closing_credit=Decimal(0),
|
||||||
entry_ids=(uuid.uuid4(),),
|
entry_ids=(uuid.uuid4(),),
|
||||||
@ -143,3 +151,76 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
|
|||||||
assert paired.amount_a == paired.amount_b == amount
|
assert paired.amount_a == paired.amount_b == amount
|
||||||
assert paired.variance_amount == 0
|
assert paired.variance_amount == 0
|
||||||
assert paired.one_sided is False
|
assert paired.one_sided is False
|
||||||
|
packed = matrix._pack_preset_rows([debit_row, credit_row], selection, context)
|
||||||
|
assert len(packed.matched) == 1
|
||||||
|
assert packed.matched[0].amount_a == packed.matched[0].amount_b == amount
|
||||||
|
assert packed.totals.eliminated_amount == amount
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtures_dir: Path) -> None:
|
||||||
|
"""Четыре реальные дебетовые строки дают итог тройки; упаковка берёт лишь точную 62.01 ↔ 60.01."""
|
||||||
|
source_rows = [
|
||||||
|
_source(fixtures_dir, "schastl", root, number)
|
||||||
|
for root, number in (("62", 21), ("62", 17), ("76", 20), ("76", 29))
|
||||||
|
]
|
||||||
|
credit_source = _source(fixtures_dir, "rb", "60", 409)
|
||||||
|
assert [(row["account_code_raw"], Decimal(row["closing_debit"])) for row in source_rows] == [
|
||||||
|
("62.01", Decimal("106311116.46")),
|
||||||
|
("62.01", Decimal("1401330.00")),
|
||||||
|
("76.06", Decimal("350000.00")),
|
||||||
|
("76.АВ", Decimal("2366666.69")),
|
||||||
|
]
|
||||||
|
assert (credit_source["account_code_raw"], Decimal(credit_source["closing_credit"])) == (
|
||||||
|
"60.01",
|
||||||
|
Decimal("106311116.46"),
|
||||||
|
)
|
||||||
|
account_ids = {code: uuid.uuid4() for code in ("62.01", "76.06", "76.АВ", "60.01")}
|
||||||
|
source_ids = [uuid.uuid4() for _ in source_rows]
|
||||||
|
credit_id = uuid.uuid4()
|
||||||
|
rows = [
|
||||||
|
SimpleNamespace(
|
||||||
|
legal_entity_id=BAKERY,
|
||||||
|
counterparty_id=RB,
|
||||||
|
account_id=account_ids[source["account_code_raw"]],
|
||||||
|
account_code=source["account_code_raw"],
|
||||||
|
closing_debit=Decimal(source["closing_debit"]),
|
||||||
|
closing_credit=Decimal(source["closing_credit"] or "0"),
|
||||||
|
entry_ids=(entry_id,),
|
||||||
|
)
|
||||||
|
for source, entry_id in zip(source_rows, source_ids, strict=True)
|
||||||
|
]
|
||||||
|
rows.append(
|
||||||
|
SimpleNamespace(
|
||||||
|
legal_entity_id=RB,
|
||||||
|
counterparty_id=BAKERY,
|
||||||
|
account_id=account_ids["60.01"],
|
||||||
|
account_code="60.01",
|
||||||
|
closing_debit=Decimal(credit_source["closing_debit"] or "0"),
|
||||||
|
closing_credit=Decimal(credit_source["closing_credit"]),
|
||||||
|
entry_ids=(credit_id,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selection = MatrixSelection(
|
||||||
|
frozenset(account_ids[code] for code in ("62.01", "76.06", "76.АВ")),
|
||||||
|
frozenset({account_ids["60.01"]}),
|
||||||
|
"closing",
|
||||||
|
)
|
||||||
|
packed = matrix._pack_preset_rows(
|
||||||
|
rows,
|
||||||
|
selection,
|
||||||
|
matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB),
|
||||||
|
)
|
||||||
|
assert (packed.totals.amount_a, packed.totals.amount_b) == (
|
||||||
|
Decimal("110429113.15"),
|
||||||
|
Decimal("106311116.46"),
|
||||||
|
)
|
||||||
|
assert packed.totals.eliminated_amount == Decimal("106311116.46")
|
||||||
|
assert packed.totals.deviation_percent is not None
|
||||||
|
assert packed.totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
|
||||||
|
assert packed.with_discrepancy == []
|
||||||
|
assert len(packed.matched) == 1
|
||||||
|
assert packed.matched[0].osv_entry_ids_a == [source_ids[0]]
|
||||||
|
assert packed.matched[0].osv_entry_ids_b == [credit_id]
|
||||||
|
assert packed.matched[0].amount_a == packed.matched[0].amount_b == Decimal("106311116.46")
|
||||||
|
assert {item.row.entry_ids[0] for item in packed.unpaired} == set(source_ids[1:])
|
||||||
|
assert {item.reason for item in packed.unpaired} == {"NO_MATCH"}
|
||||||
|
|||||||
@ -75,6 +75,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
|||||||
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
|
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
|
||||||
# шахматка ВГО и правила элиминации
|
# шахматка ВГО и правила элиминации
|
||||||
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
|
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
|
||||||
|
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
|
||||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
|
||||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
|
||||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-23
|
||||||
3
openspec/changes/elimination-pairs-rule-totals/README.md
Normal file
3
openspec/changes/elimination-pairs-rule-totals/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# elimination-pairs-rule-totals
|
||||||
|
|
||||||
|
Тесты упаковки и итог элиминации для тройки правила и компаний
|
||||||
10
openspec/changes/elimination-pairs-rule-totals/design.md
Normal file
10
openspec/changes/elimination-pairs-rule-totals/design.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
## Решение
|
||||||
|
|
||||||
|
Суммы выбранных строк считаются один раз до упаковки внутри тройки `(rule_id, A, B)` как чистая функция.
|
||||||
|
`elimination_amount` сохраняет ранее согласованное значение абсолютного расхождения; новое `eliminated_amount`
|
||||||
|
содержит `min(ΣA, ΣB)` при пороге не более 5 %, иначе 0, и всегда 0 для `manual`.
|
||||||
|
Нулевая меньшая сторона даёт `deviation_percent = null` и нулевую элиминацию.
|
||||||
|
|
||||||
|
Упаковка не влияет на итог. Для обратной направленной пары клиент переставляет `company_a_id` и `company_b_id`.
|
||||||
|
Эталонный Excel используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны,
|
||||||
|
поэтому его общий итог не равен системному правилу минимума.
|
||||||
34
openspec/changes/elimination-pairs-rule-totals/proposal.md
Normal file
34
openspec/changes/elimination-pairs-rule-totals/proposal.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Срез 5: упаковка ОСВ показывает группы строк, но итог элиминации тройки должен учитывать все выбранные строки,
|
||||||
|
включая оставшиеся без пары. Это нужно закрепить тестами на реальных ОСВ группы «Русский бисквит» и отдельным
|
||||||
|
контрактом для фронта.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Для запроса `(правило, A, B)` добавить общий итог ΣA/ΣB, расхождение, процент, признак порога и сумму
|
||||||
|
элиминации по правилу 5 %; при `manual` сумма элиминации равна нулю.
|
||||||
|
- Проверить упаковку 1:1, 1:N, N:1, ничьи и оба направления пары на реальных и модельных строках.
|
||||||
|
- Проверить неизменность зафиксированной версии правил при повторных GET и сопоставить точные строки с эталонным Excel.
|
||||||
|
- Описать новый ответ в гайде фронта.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `consolidations-api`: общий итог элиминации тройки независимо от упаковки строк.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
`arm-core`: чистый расчёт, сервис и схема ответа, тесты, OpenSpec. `arm-ka-docs`: гайд фронта.
|
||||||
|
Читаются таблицы `consolidation_version`, `consolidation_member`, `consolidation_document`, `osv_entry`,
|
||||||
|
`elimination_rule`, `elimination_rule_account`, `chart_of_account`; DBML и хранимые данные не меняются.
|
||||||
|
Основание — `context/05-domain-rules.md` §4–§5, решение владельца 08.10.2026 о минимуме пары.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР.
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Итог элиминации тройки независимо от упаковки
|
||||||
|
|
||||||
|
Для `(rule_id, company_a_id, company_b_id)` API SHALL возвращать суммы всех выбранных строк ОСВ обеих сторон,
|
||||||
|
абсолютное расхождение `elimination_amount`, процент расхождения к меньшей сумме, `exceeds_threshold` и отдельное
|
||||||
|
`eliminated_amount`. При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`;
|
||||||
|
при превышении порога, нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк
|
||||||
|
не меняет этот итог.
|
||||||
|
|
||||||
|
#### Scenario: Точная строка среди несопоставленных остатков
|
||||||
|
|
||||||
|
- **WHEN** правило № 6 для Счастливочной и АО «Русский бисквит» даёт суммы 110 429 113,15 и 106 311 116,46 руб.,
|
||||||
|
а точная группа 62.01 ↔ 60.01 составляет 106 311 116,46 руб.
|
||||||
|
- **THEN** точная группа присутствует в `matched`, остальные три строки стороны A — в `unpaired`,
|
||||||
|
`eliminated_amount = 106311116.46`, расхождение округляется до 3,87 %.
|
||||||
|
|
||||||
|
#### Scenario: Ручное правило и превышение порога
|
||||||
|
|
||||||
|
- **WHEN** правило ручное либо расхождение больше 5 %
|
||||||
|
- **THEN** `eliminated_amount = 0`, независимо от результата упаковки.
|
||||||
7
openspec/changes/elimination-pairs-rule-totals/tasks.md
Normal file
7
openspec/changes/elimination-pairs-rule-totals/tasks.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
## Реализация
|
||||||
|
|
||||||
|
- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны.
|
||||||
|
- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ.
|
||||||
|
- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил.
|
||||||
|
- [x] Дополнить основной OpenSpec и гайд фронта.
|
||||||
|
- [ ] Запустить `make check-full` и заархивировать change.
|
||||||
@ -269,7 +269,12 @@ SHALL возвращаться только её результат; строк
|
|||||||
Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL
|
Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL
|
||||||
содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы),
|
содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы),
|
||||||
`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного
|
`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного
|
||||||
перечня допустимых пар счетов. Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`,
|
перечня допустимых пар счетов. На уровне тройки ответ SHALL содержать `amount_a`, `amount_b` (суммы всех выбранных
|
||||||
|
строк, включая оставшиеся без пары), `elimination_amount` (абсолютное расхождение), `deviation_percent` (от меньшей
|
||||||
|
суммы, `null` при нуле), `eliminated_amount` (сумма фактической элиминации) и `exceeds_threshold`.
|
||||||
|
При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`; при превышении порога,
|
||||||
|
нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк SHALL не менять этот итог.
|
||||||
|
Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`,
|
||||||
`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`.
|
`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`.
|
||||||
Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться
|
Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться
|
||||||
`abs(amount_a - amount_b)`.
|
`abs(amount_a - amount_b)`.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user