Compare commits
No commits in common. "d096459c757f493c7249e1c12af73bbe52f56e05" and "02d67ff733488e61311b7ff2ac523da556d2aabb" have entirely different histories.
d096459c75
...
02d67ff733
@ -870,8 +870,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
|
||||
|
||||
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
|
||||
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
|
||||
`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
|
||||
(аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации):
|
||||
`GET …/intercompany-matrix/result-table`. Режим PRESET (счета и показатель из правила элиминации):
|
||||
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
|
||||
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
|
||||
@ -1 +1 @@
|
||||
25b2bb81195bb1e99f73b90e336cd175ae8b344c
|
||||
3390f92daddbfd4919d7f799ad2959cc35bfbd92
|
||||
|
||||
@ -17,7 +17,6 @@ _MIN_NUMBERING_CELLS = 3
|
||||
_SHORT_NOTE = 3 # «Пояснения» без подписи: значения не длиннее 3 символов
|
||||
_SKIP_STEP = 2 # скачок через одну пропущенную цифру нумерации
|
||||
_NUMBER = re.compile(r"[0-9]+")
|
||||
_NUMBERING_TAIL = re.compile(r"\s+[0-9]{1,2}$")
|
||||
_FULL_CONFIDENCE = 100.0
|
||||
|
||||
|
||||
@ -25,11 +24,6 @@ def _text(cell: Cell | None) -> str:
|
||||
return "" if cell is None else cell.text.strip()
|
||||
|
||||
|
||||
def caption_text(text: str) -> str:
|
||||
"""Подпись шапки для сравнения: `normalize_caption` без номера столбца, склеенного снизу («Коды\\n2» → «коды»)."""
|
||||
return _NUMBERING_TAIL.sub("", normalize_caption(text))
|
||||
|
||||
|
||||
def cell_at(rows: Grid, row: int, col: int) -> Cell | None:
|
||||
"""Ячейка сетки; вне строки или сетки — `None`."""
|
||||
if row < 0 or row >= len(rows) or col < 0:
|
||||
@ -144,27 +138,17 @@ def first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rule
|
||||
return None
|
||||
|
||||
|
||||
def is_numbering_row(cells: Sequence[Cell | None], rules: Rules, *, noisy: bool = False) -> bool:
|
||||
"""≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска.
|
||||
|
||||
`noisy` — строка сразу под шапкой (AURORA-1517): ячейка со сбоем OCR даёт своё единственное целое («• ■ 1» → 1,
|
||||
«3гола» → 3), ячейка без цифр («/», «V») — пропуск, два целых в ячейке — не нумерация.
|
||||
"""
|
||||
def is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool:
|
||||
"""≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска."""
|
||||
numbers: list[int] = []
|
||||
for cell in cells:
|
||||
text = _text(cell)
|
||||
if not text:
|
||||
continue
|
||||
fixed = fix_lookalikes(text, rules.lookalikes, anywhere=True).strip()
|
||||
if _NUMBER.fullmatch(fixed) is not None:
|
||||
numbers.append(int(fixed))
|
||||
continue
|
||||
if not noisy:
|
||||
if _NUMBER.fullmatch(fixed) is None:
|
||||
return False
|
||||
found = _NUMBER.findall(text)
|
||||
if len(found) > 1:
|
||||
return False
|
||||
numbers.extend(int(number) for number in found)
|
||||
numbers.append(int(fixed))
|
||||
if len(numbers) < _MIN_NUMBERING_CELLS or numbers[0] not in (1, 2):
|
||||
return False
|
||||
skips = 0
|
||||
@ -228,7 +212,7 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul
|
||||
"""Строка заголовка (одна или две, склеенные); не найдена — `()`."""
|
||||
candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)]
|
||||
captioned = [
|
||||
r for r in candidates if rules.headers.code_caption.search(caption_text(_text(cell_at(rows, r, code_col))))
|
||||
r for r in candidates if rules.headers.code_caption.search(normalize_caption(_text(cell_at(rows, r, code_col))))
|
||||
]
|
||||
header: int | None = captioned[-1] if captioned else None
|
||||
if header is None:
|
||||
@ -245,17 +229,15 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul
|
||||
|
||||
|
||||
def segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]:
|
||||
"""Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке); строка сразу под заголовком —
|
||||
нумерация и при сбое OCR (`is_numbering_row(noisy=True)`)."""
|
||||
"""Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке)."""
|
||||
roles: list[RowRole] = []
|
||||
top = min(header_rows) if header_rows else None
|
||||
under = max(header_rows) + 1 if header_rows else None
|
||||
for row in range(first, last + 1):
|
||||
if row in header_rows:
|
||||
roles.append(RowRole.HEADER)
|
||||
elif top is not None and row < top:
|
||||
roles.append(RowRole.TITLE)
|
||||
elif is_numbering_row(rows[row], rules, noisy=row == under):
|
||||
elif is_numbering_row(rows[row], rules):
|
||||
roles.append(RowRole.NUMBERING)
|
||||
else:
|
||||
roles.append(RowRole.DATA)
|
||||
@ -269,7 +251,7 @@ def _caption_column(
|
||||
for col, cell in enumerate(rows[row]):
|
||||
if col == code_col or (left_of_code and col > code_col):
|
||||
continue
|
||||
if pattern.search(caption_text(_text(cell))):
|
||||
if pattern.search(normalize_caption(_text(cell))):
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@ from arm_statements.columns import (
|
||||
from arm_statements.issues import WarningCode, make_warning
|
||||
from arm_statements.layout import (
|
||||
Grid,
|
||||
caption_text,
|
||||
cell_at,
|
||||
find_code_column,
|
||||
find_header_rows,
|
||||
@ -36,6 +35,7 @@ from arm_statements.model import (
|
||||
StatementHeader,
|
||||
StatementWarning,
|
||||
)
|
||||
from arm_statements.text import normalize_caption
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -100,7 +100,7 @@ def _chunks(rows: Grid, breaks: Sequence[int]) -> list[tuple[int, int]]:
|
||||
|
||||
|
||||
def _caption_cells(rows: Grid, header_rows: Sequence[int]) -> frozenset[str]:
|
||||
cells = (caption_text(_text(rows, r, c)) for r in header_rows for c in range(len(rows[r])))
|
||||
cells = (normalize_caption(_text(rows, r, c)) for r in header_rows for c in range(len(rows[r])))
|
||||
return frozenset(c for c in cells if c)
|
||||
|
||||
|
||||
@ -110,7 +110,7 @@ def _is_repeat_header(rows: Grid, row: int, code_col: int | None, reference: fro
|
||||
return False
|
||||
if code_col is not None and is_code_cell(_text(rows, row, code_col), rules):
|
||||
return False
|
||||
normalized = [caption_text(c.text) for c in rows[row] if c is not None and c.text.strip()]
|
||||
normalized = [normalize_caption(c.text) for c in rows[row] if c is not None and c.text.strip()]
|
||||
if any(rules.headers.code_caption.search(text) for text in normalized):
|
||||
return True
|
||||
return sum(1 for text in normalized if text and text in reference) >= _MIN_SAME_CELLS
|
||||
|
||||
@ -215,45 +215,3 @@ def test_headerless_continuation_sheet_with_outside_numbers(rules: Rules) -> Non
|
||||
assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1"
|
||||
assert layout.segments[1].value_cols == ((3, 3), (4, 4))
|
||||
assert layout.segments[1].extra_cols == ()
|
||||
|
||||
|
||||
BALANCE_CAPTIONS = ["Наименование показателя", "Коды", "На 30 июня\n2026 года", "На 31 декабря\n2025 года"]
|
||||
ASSETS = [["Запасы", "1210", "5", "4"], ["Дебиторская задолженность", "1230", "3", "2"], ["Итого", "1200", "8", "6"]]
|
||||
LIABILITIES = [["Заемные средства", "1510", "3", "2"], ["Кредиторская задолженность", "1520", "5", "4"]]
|
||||
|
||||
|
||||
def test_numbering_with_ocr_noise_right_under_header(rules: Rules) -> None:
|
||||
"""AURORA-1517: нумерация под шапкой со сбоем OCR («/» вместо 1, мусор, протёкшее «года», «V» вместо 4)."""
|
||||
for numbering in (
|
||||
["/", "2", "3", "4"],
|
||||
["• ■ 1", "2", "3", "4"],
|
||||
["1", "2", "3гола", "гола\n4"],
|
||||
["1", "2", "3", "V"],
|
||||
):
|
||||
layout = find_layout(ocr([BALANCE_CAPTIONS, numbering, *ASSETS]), parse_header(hdr(), rules), rules)
|
||||
assert layout.roles[1] is RowRole.NUMBERING, numbering
|
||||
|
||||
|
||||
def test_numbering_with_ocr_noise_under_repeat_header(rules: Rules) -> None:
|
||||
"""AURORA-1517, «Светлый путь»: «• ■ 1 2 3 4 5» под повторной шапкой второй страницы."""
|
||||
rows = [BALANCE_CAPTIONS, ["1", "2", "3", "4"], *ASSETS, BALANCE_CAPTIONS, ["• ■ 1", "2", "3", "4"], *LIABILITIES]
|
||||
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
|
||||
assert [s.first_row for s in layout.segments] == [0, 5]
|
||||
assert layout.roles[6] is RowRole.NUMBERING and layout.roles[7] is RowRole.DATA
|
||||
|
||||
|
||||
def test_noisy_numbering_elsewhere_stays_data(rules: Rules) -> None:
|
||||
"""Мягкая проверка — только сразу под шапкой; строка с кодом формы — не нумерация."""
|
||||
rows = [BALANCE_CAPTIONS, ["Запасы 1", "1210", "2", "3"], *ASSETS, ["/", "2", "3", "4"], *LIABILITIES]
|
||||
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
|
||||
assert layout.roles[1] is RowRole.DATA and layout.roles[5] is RowRole.DATA
|
||||
|
||||
|
||||
def test_repeat_header_glued_with_numbering(rules: Rules) -> None:
|
||||
"""AURORA-1517, «Будамшинское»: повторная шапка, в каждой подписи которой снизу номер столбца."""
|
||||
glued = ["Наименование показателя\n1", "Коды\n2", "На 30 июня\n2026 года\n3", "На 31 декабря\n2025 года\n4"]
|
||||
rows = [BALANCE_CAPTIONS, ["1", "2", "3", "4"], *ASSETS, glued, *LIABILITIES]
|
||||
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
|
||||
assert [s.first_row for s in layout.segments] == [0, 5]
|
||||
assert layout.roles[5] is RowRole.HEADER and layout.roles[6] is RowRole.DATA
|
||||
assert layout.segments[1].value_cols == ((2, 2), (3, 3))
|
||||
|
||||
@ -17,7 +17,7 @@ change не входит. Шахматка — чистая функция от
|
||||
`ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422
|
||||
`RULE_REQUIRED`.
|
||||
3. Показатель (`closing`/`turnover`) берётся из правила; ALL и MANUAL остаются на `closing`.
|
||||
4. *Отменено решением владельца 21.10.2026 (п. 3 ниже), в реализацию не входит.* Применённые корректировки правила (`status = APPLIED`, тот же `rule_id` и версия консолидации) уменьшают
|
||||
4. Применённые корректировки правила (`status = APPLIED`, тот же `rule_id` и версия консолидации) уменьшают
|
||||
`amount_a` ячейки `(A, B)` на `debit_adjustment` корректировки (`legal_entity_id = A`, `counterparty_id = B`) и
|
||||
`amount_b` на `credit_adjustment` корректировки `(B, A)`; расхождение, процент и порог пересчитываются, ID
|
||||
корректировок попадают в `correction_ids_debit/credit`.
|
||||
@ -38,7 +38,7 @@ change не входит. Шахматка — чистая функция от
|
||||
3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в
|
||||
ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не
|
||||
утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички.
|
||||
Код корректировок сохранён в ветке `wip/shahmatka-corrections-unpaired` (PR #164 не сливается) и возвращается после решения по аналитике
|
||||
Код корректировок выделен в ветку `AURORA-1747` и возвращается после решения по аналитике
|
||||
AURORA-1643 «Учитывание корректировок в шахматке».
|
||||
4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк
|
||||
(дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны
|
||||
@ -53,8 +53,7 @@ change не входит. Шахматка — чистая функция от
|
||||
|
||||
- 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена
|
||||
ошибки: автор переписывает change.
|
||||
- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues (раздел снят в раунде 2 —
|
||||
все пункты закрыты автором). Обоснование: домен решает
|
||||
- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues. Обоснование: домен решает
|
||||
автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой.
|
||||
- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main
|
||||
его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть.
|
||||
@ -64,6 +63,3 @@ change не входит. Шахматка — чистая функция от
|
||||
(`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая.
|
||||
- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование
|
||||
«XLSX шахматки» (ALL/MANUAL) не менялось, поведение PRESET описано отдельным ADDED-требованием.
|
||||
- 2026-10-22. Раунд 2 ревью: в ветку влит `main` (#163), в README возвращена фраза про XLSX результирующей таблицы,
|
||||
потерянная при merge автора (b7d9794). Показатель пресета в docs (#146, §6) приведён к change: `measure` правила,
|
||||
у правил ББ — `closing`. Цена ошибки: нулевая.
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен
|
||||
режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3).
|
||||
Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова).
|
||||
Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); решения владельца и ревью —
|
||||
`design.md`.
|
||||
Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в
|
||||
`design.md`, `## Known issues`, и в спеке не закреплено как окончательное.
|
||||
|
||||
## What Changes
|
||||
|
||||
|
||||
@ -35,21 +35,14 @@
|
||||
### Requirement: XLSX шахматки пресета
|
||||
`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт
|
||||
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`. Шапка SHALL
|
||||
содержать режим «Пресет», счета дебета и кредита так, как они заданы в правиле (без раскрытия по субсчетам), и отдельно
|
||||
исключения правила («кроме …»), номер правила, статьи сторон дебета и кредита (при `side_1 = K` статьи меняются местами
|
||||
вместе со сторонами), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
|
||||
содержать режим «Пресет», коды счетов дебета и кредита правила, номер правила, статьи А и Б, фактическую версию правил
|
||||
и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
|
||||
«Сальдо»).
|
||||
|
||||
#### Scenario: Выгрузка по оборотам
|
||||
- **WHEN** пользователь выгружает пресет с `measure = turnover`
|
||||
- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-<rule_no>.xlsx`
|
||||
|
||||
#### Scenario: Шапка правила с исключениями
|
||||
- **WHEN** выгружается пресет по правилу № 6 (seed v1: дебет `60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76`,
|
||||
исключения `76.07.5, 76.07.9`)
|
||||
- **THEN** в шапке счета дебета — эти восемь кодов, отдельной строкой — «кроме 76.07.5, 76.07.9»; раскрытых субсчетов
|
||||
(`76.01`, `76.02`, …) в шапке нет
|
||||
|
||||
#### Scenario: Правило не найдено
|
||||
- **WHEN** `rule_id` не существует
|
||||
- **THEN** 404, файл не отдаётся, событие аудита не пишется
|
||||
|
||||
@ -1,23 +1,14 @@
|
||||
## 1. Реализация
|
||||
|
||||
Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — комментарии PR
|
||||
(раунды 1–2) и разделы решений в `design.md`.
|
||||
Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — см.
|
||||
`design.md`, `## Known issues`.
|
||||
|
||||
## 2. Доработки по решениям владельца 2026-10-21 (автор)
|
||||
|
||||
- [x] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`,
|
||||
`correction_ids_*`) вместе с тестами; код сохранён в `wip/shahmatka-corrections-unpaired`.
|
||||
`correction_ids_*`) вместе с тестами; код выделен в `AURORA-1747`.
|
||||
- [x] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами;
|
||||
код сохранён в `wip/shahmatka-corrections-unpaired`.
|
||||
- [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест.
|
||||
- [x] 2.3 Несколько открытых `version_no` — только максимальная версия; вернуть её номер, тест.
|
||||
- [x] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README.
|
||||
|
||||
## 3. Ревью, раунд 2 (2026-10-22)
|
||||
|
||||
- [x] 3.1 Ревьюер: влить `main`, вернуть фразу README про XLSX результирующей таблицы, поправить design/proposal/tasks.
|
||||
- [x] 3.2 Ревьюер: показатель пресета в docs #146 §6 — `measure` правила.
|
||||
- [ ] 3.3 Автор: название и описание PR, шапка xlsx пресета (счета правила и «кроме»), подписи статей при `side_1 = K`,
|
||||
лишняя проверка в `_export_response`, реквизиты правила в `MatrixExportInfo` и `_filename`, описания ошибок preset в
|
||||
OpenAPI.
|
||||
- [ ] 3.4 После слияния: архив change.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user