fix(api): OpenAPI-коды ошибок PATCH формы, confirm и crm_id; organization_name:null — 422 (I-2, M-11)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
b3116acb17
commit
8296c77fe5
10
README.md
10
README.md
@ -941,7 +941,7 @@ email без учёта регистра проверяет только сер
|
|||||||
| `UNKNOWN_LINE` | 422 | `PATCH …/statement`: `lines[].line_id` не этой формы. `detail.line_id` — чужой id, `detail.line_code` |
|
| `UNKNOWN_LINE` | 422 | `PATCH …/statement`: `lines[].line_id` не этой формы. `detail.line_id` — чужой id, `detail.line_code` |
|
||||||
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
|
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
|
||||||
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
|
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
|
||||||
| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку |
|
| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или отметьте строку расшифровкой |
|
||||||
| `DUPLICATE_FORM` | 409 | подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; `detail.document_id` — подтверждённая форма |
|
| `DUPLICATE_FORM` | 409 | подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; `detail.document_id` — подтверждённая форма |
|
||||||
| `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` |
|
| `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` |
|
||||||
| `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id |
|
| `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id |
|
||||||
@ -1044,7 +1044,7 @@ email без учёта регистра проверяет только сер
|
|||||||
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
|
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
|
||||||
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
|
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
|
||||||
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) |
|
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) |
|
||||||
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.18.dbml` |
|
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.19.dbml` |
|
||||||
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
|
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
|
||||||
| `make check` | быстрая проверка без Docker: `lint` + `test` |
|
| `make check` | быстрая проверка без Docker: `lint` + `test` |
|
||||||
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
|
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
|
||||||
@ -1096,7 +1096,7 @@ queue group `arm-core`, подписчики группы получают со
|
|||||||
|
|
||||||
### Проверка ORM и DBML
|
### Проверка ORM и DBML
|
||||||
|
|
||||||
Модель данных — `../docs/model/data-model-v5.18.dbml`. ORM обязана совпадать с ней один в один:
|
Модель данных — `../docs/model/data-model-v5.19.dbml`. ORM обязана совпадать с ней один в один:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
|
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
|
||||||
@ -1119,7 +1119,7 @@ make migration-cycle # проверить цикл upgr
|
|||||||
```
|
```
|
||||||
api/
|
api/
|
||||||
run_server.py точка входа (uvicorn; хост и порт — из настроек)
|
run_server.py точка входа (uvicorn; хост и порт — из настроек)
|
||||||
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18)
|
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19)
|
||||||
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
|
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
|
||||||
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
|
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
|
||||||
seed/dev/ dev-данные: филиалы и пользователи по ролям
|
seed/dev/ dev-данные: филиалы и пользователи по ролям
|
||||||
@ -1203,7 +1203,7 @@ Dockerfile только для локального compose
|
|||||||
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
|
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
|
||||||
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3;
|
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3;
|
||||||
- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и
|
- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и
|
||||||
[`../docs/model/data-model-v5.18.dbml`](../docs/model/data-model-v5.18.dbml) — модель данных;
|
[`../docs/model/data-model-v5.19.dbml`](../docs/model/data-model-v5.19.dbml) — модель данных;
|
||||||
- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
|
- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
|
||||||
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы;
|
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы;
|
||||||
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;
|
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;
|
||||||
|
|||||||
@ -48,7 +48,9 @@ async def list_related_groups(company_group_id: uuid.UUID, service: RelatedGroup
|
|||||||
async def create_related_group(
|
async def create_related_group(
|
||||||
company_group_id: uuid.UUID, data: RelatedGroupCreate, service: RelatedGroupServiceDep
|
company_group_id: uuid.UUID, data: RelatedGroupCreate, service: RelatedGroupServiceDep
|
||||||
) -> RelatedGroupOut:
|
) -> RelatedGroupOut:
|
||||||
"""Создаёт ГСК (`kind = gsk`, код `GSK-NNNNNN`) или ГСЗ (`gsz`, `GSZ-NNNNNN`) в ГК."""
|
"""Создаёт ГСК (`kind = gsk`, код `GSK-NNNNNN`) или ГСЗ (`gsz`, `GSZ-NNNNNN`) в ГК.
|
||||||
|
|
||||||
|
Занятый `crm_id` — 409 `CRM_ID_EXISTS`."""
|
||||||
return await service.summary(await service.create(company_group_id, data))
|
return await service.summary(await service.create(company_group_id, data))
|
||||||
|
|
||||||
|
|
||||||
@ -66,7 +68,8 @@ async def get_related_group(group_id: uuid.UUID, service: RelatedGroupServiceDep
|
|||||||
async def update_related_group(
|
async def update_related_group(
|
||||||
group_id: uuid.UUID, data: RelatedGroupUpdate, service: RelatedGroupServiceDep
|
group_id: uuid.UUID, data: RelatedGroupUpdate, service: RelatedGroupServiceDep
|
||||||
) -> RelatedGroupOut:
|
) -> RelatedGroupOut:
|
||||||
"""Меняет `name`/`created_date`; вид и ГК группы не меняются."""
|
"""Меняет `name`/`created_date`/`crm_id`; вид и ГК группы не меняются. Занятый `crm_id` — 409 `CRM_ID_EXISTS`;
|
||||||
|
смена `crm_id` пишется в аудит как `GROUP_CHANGE`."""
|
||||||
return await service.summary(await service.update(group_id, data))
|
return await service.summary(await service.update(group_id, data))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,9 @@ async def list_entries(
|
|||||||
status.HTTP_409_CONFLICT: {
|
status.HTTP_409_CONFLICT: {
|
||||||
"model": ErrorDetail,
|
"model": ErrorDetail,
|
||||||
"description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР — "
|
"description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР — "
|
||||||
"`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (нарушения проверок — в `detail.failures`)",
|
"`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (нарушения проверок — в `detail.failures`), "
|
||||||
|
"`LINES_UNRESOLVED` (строки без кода — назначьте код или пометьте расшифровкой), "
|
||||||
|
"`DUPLICATE_FORM` (уже подтверждена форма того же типа и периода)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
summary="Подтвердить документ",
|
summary="Подтвердить документ",
|
||||||
|
|||||||
@ -28,11 +28,12 @@ EDIT_ERRORS: dict[int | str, dict[str, Any]] = {
|
|||||||
status.HTTP_409_CONFLICT: {
|
status.HTTP_409_CONFLICT: {
|
||||||
"model": ErrorDetail,
|
"model": ErrorDetail,
|
||||||
"description": "`FILE_SUPERSEDED`, `DOCUMENT_READONLY`, `DOCUMENT_NOT_CONFIRMABLE`, `DOCUMENT_NOT_STATEMENT`, "
|
"description": "`FILE_SUPERSEDED`, `DOCUMENT_READONLY`, `DOCUMENT_NOT_CONFIRMABLE`, `DOCUMENT_NOT_STATEMENT`, "
|
||||||
"`NOTHING_TO_ACCEPT`",
|
"`NOTHING_TO_ACCEPT`, `LINE_CODE_TAKEN` (код занят другой строкой формы)",
|
||||||
},
|
},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||||
"model": ErrorDetail,
|
"model": ErrorDetail,
|
||||||
"description": "`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL` и ошибки схемы",
|
"description": "`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, "
|
||||||
|
"`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE` и ошибки схемы",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +58,14 @@ async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) ->
|
|||||||
summary="Правка формы ББ/ОФР",
|
summary="Правка формы ББ/ОФР",
|
||||||
)
|
)
|
||||||
async def patch_statement(document_id: uuid.UUID, body: StatementPatch, service: StatementServiceDep) -> StatementOut:
|
async def patch_statement(document_id: uuid.UUID, body: StatementPatch, service: StatementServiceDep) -> StatementOut:
|
||||||
"""Значения и шапка (`inn`, `report_date`, `units`): аудит, сброс акцепта, пересчёт статуса.
|
"""Правка формы одним запросом, части применяются по порядку `lines` -> `values` -> `columns` -> `header`:
|
||||||
|
|
||||||
|
- `lines[]` — строки: назначить код, пометить расшифровкой (`detail`, с родителем), подтвердить код (`confirmed`);
|
||||||
|
- `values[]` — значения ячеек, в том числе подтверждение (`confirmed`);
|
||||||
|
- `columns[]` — даты и подтверждение даты колонок;
|
||||||
|
- `header` — `organization_name`, `inn`, `report_date`, `units` (значение или подтверждение `confirmed`).
|
||||||
|
|
||||||
|
Аудит, сброс акцепта, пересчёт статуса. Ошибка в любой части откатывает весь запрос.
|
||||||
Запрос без изменений — 200 без аудита."""
|
Запрос без изменений — 200 без аудита."""
|
||||||
return await service.patch(document_id, body)
|
return await service.patch(document_id, body)
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,7 @@ class LineDraft:
|
|||||||
line_code_confidence: Decimal | None
|
line_code_confidence: Decimal | None
|
||||||
line_name: str
|
line_name: str
|
||||||
line_name_confidence: Decimal | None
|
line_name_confidence: Decimal | None
|
||||||
row_index: int = 0 # индекс строки в `doc.lines` результата; пропущенный повтор кода индекс не занимает
|
row_index: int = 0 # индекс строки в `doc.lines` результата — ключ строки формы; повтор кода — свой индекс
|
||||||
line_status: str = "ok"
|
line_status: str = "ok"
|
||||||
line_code_original_value: str | None = None
|
line_code_original_value: str | None = None
|
||||||
source_ref: str | None = None
|
source_ref: str | None = None
|
||||||
|
|||||||
@ -58,6 +58,7 @@ __all__ = [
|
|||||||
"value_flags",
|
"value_flags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# строк на один INSERT: у строки формы 15 колонок, у значения 10; 15 × 1000 < 32767 параметров asyncpg
|
||||||
BATCH = 1000
|
BATCH = 1000
|
||||||
|
|
||||||
|
|
||||||
@ -69,9 +70,6 @@ class IssueCounts:
|
|||||||
info: int = 0
|
info: int = 0
|
||||||
|
|
||||||
|
|
||||||
# значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg
|
|
||||||
|
|
||||||
|
|
||||||
async def insert_statement(
|
async def insert_statement(
|
||||||
session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft
|
session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft
|
||||||
) -> FinancialStatementMeta:
|
) -> FinancialStatementMeta:
|
||||||
|
|||||||
@ -454,7 +454,8 @@ class HeaderPatch(BaseModel):
|
|||||||
`INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента).
|
`INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента).
|
||||||
|
|
||||||
`units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50
|
`units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50
|
||||||
символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов."""
|
символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов;
|
||||||
|
`null` — 422 (как `inn`)."""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@ -465,6 +466,13 @@ class HeaderPatch(BaseModel):
|
|||||||
Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None
|
Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None
|
||||||
) = None
|
) = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _organization_name_not_null(self) -> "HeaderPatch":
|
||||||
|
"""`organization_name: null` — 422 (наименование очищать нельзя, как ИНН); «не менять» — отсутствие поля."""
|
||||||
|
if "organization_name" in self.model_fields_set and self.organization_name is None:
|
||||||
|
raise ValueError("organization_name не может быть null")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ColumnPatch(BaseModel):
|
class ColumnPatch(BaseModel):
|
||||||
"""Дата колонки: обязательная (`null` — 422 валидации тела)."""
|
"""Дата колонки: обязательная (`null` — 422 валидации тела)."""
|
||||||
|
|||||||
@ -55,6 +55,7 @@ from src.domain.statement_checks import load_check_rules, run_checks
|
|||||||
from src.domain.statement_issues import mapping_warning
|
from src.domain.statement_issues import mapping_warning
|
||||||
from src.domain.statement_lines import load_line_catalog
|
from src.domain.statement_lines import load_line_catalog
|
||||||
from src.domain.statement_mapping import StatementDraft, build_statement, edition_for, statement_status, values_ok
|
from src.domain.statement_mapping import StatementDraft, build_statement, edition_for, statement_status, values_ok
|
||||||
|
from src.domain.statement_status import duplicate_period_forms
|
||||||
from src.models.accounting import ChartOfAccount
|
from src.models.accounting import ChartOfAccount
|
||||||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||||
from src.models.directories import CompanyGroup, LegalEntity
|
from src.models.directories import CompanyGroup, LegalEntity
|
||||||
@ -389,19 +390,15 @@ def _map_statement_docs( # noqa: PLR0913 - все входы именованн
|
|||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
forms.append((document, draft))
|
forms.append((document, draft))
|
||||||
same_period: dict[tuple[DocumentType, int, PeriodQuarter], int] = {}
|
duplicated = duplicate_period_forms(
|
||||||
for document, _ in forms:
|
(document.id, document.document_type, document.period_year, document.period_quarter) for document, _ in forms
|
||||||
if document.period_year is not None and document.period_quarter is not None:
|
)
|
||||||
key = (document.document_type, document.period_year, document.period_quarter)
|
|
||||||
same_period[key] = same_period.get(key, 0) + 1
|
|
||||||
repeats: dict[uuid.UUID, str] = {}
|
repeats: dict[uuid.UUID, str] = {}
|
||||||
for document, _ in forms:
|
for document, _ in forms:
|
||||||
if document.period_year is not None and document.period_quarter is not None:
|
if document.id in duplicated:
|
||||||
key = (document.document_type, document.period_year, document.period_quarter)
|
text = f"в файле есть другая форма {document.document_type.value} за тот же период"
|
||||||
if same_period[key] > 1:
|
repeats[document.id] = text
|
||||||
text = f"в файле есть другая форма {document.document_type.value} за тот же период"
|
warnings.append(f"{document.locator}: {text}")
|
||||||
repeats[document.id] = text
|
|
||||||
warnings.append(f"{document.locator}: {text}")
|
|
||||||
others = [
|
others = [
|
||||||
Document(
|
Document(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|||||||
@ -86,12 +86,15 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool:
|
|||||||
line.line_code, line.line_status = item.line_code, StatementLineStatus.ok
|
line.line_code, line.line_status = item.line_code, StatementLineStatus.ok
|
||||||
line.line_code_confidence, line.line_code_modified = ASSIGNED_CONFIDENCE, True
|
line.line_code_confidence, line.line_code_modified = ASSIGNED_CONFIDENCE, True
|
||||||
line.needs_review = False
|
line.needs_review = False
|
||||||
|
line.is_confirmed = False # M-4: подтверждение относилось к прежнему коду
|
||||||
|
line.parent_row_index = None # M-4: у строки `ok` родителя нет (был у `detail`)
|
||||||
return True
|
return True
|
||||||
if item.line_status == "detail":
|
if item.line_status == "detail":
|
||||||
if line.line_status is StatementLineStatus.detail:
|
if line.line_status is StatementLineStatus.detail:
|
||||||
return False
|
return False
|
||||||
line.line_code, line.line_status = None, StatementLineStatus.detail
|
line.line_code, line.line_status = None, StatementLineStatus.detail
|
||||||
line.needs_review = False
|
line.needs_review = False
|
||||||
|
line.is_confirmed = False # M-4: у расшифровки кода нет — подтверждать нечего
|
||||||
return True
|
return True
|
||||||
if item.confirmed is not None:
|
if item.confirmed is not None:
|
||||||
line.is_confirmed = item.confirmed
|
line.is_confirmed = item.confirmed
|
||||||
@ -139,6 +142,7 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13
|
|||||||
raise _bad(LINE_CODE_TAKEN, "код занят строкой, созданной правкой, с суммами", item, conflict=True)
|
raise _bad(LINE_CODE_TAKEN, "код занят строкой, созданной правкой, с суммами", item, conflict=True)
|
||||||
# запись: пустые шаблонные строки назначаемых кодов уходят, их id — из `document_issue.line_ids`
|
# запись: пустые шаблонные строки назначаемых кодов уходят, их id — из `document_issue.line_ids`
|
||||||
targets = {item.line_id for item in items}
|
targets = {item.line_id for item in items}
|
||||||
|
deleted: list[uuid.UUID] = []
|
||||||
old = {line.id: _snapshot(line) for line in lines}
|
old = {line.id: _snapshot(line) for line in lines}
|
||||||
for template, _ in templates.values():
|
for template, _ in templates.values():
|
||||||
if template.id in targets or template.line_code not in codes:
|
if template.id in targets or template.line_code not in codes:
|
||||||
@ -146,7 +150,7 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13
|
|||||||
await repo.delete_line(session, template.id)
|
await repo.delete_line(session, template.id)
|
||||||
await repo.remove_line_from_issues(session, document.id, template.id)
|
await repo.remove_line_from_issues(session, document.id, template.id)
|
||||||
lines = [line for line in lines if line.id != template.id]
|
lines = [line for line in lines if line.id != template.id]
|
||||||
old.pop(template.id, None)
|
deleted.append(template.id) # T13.2: удаление пустой шаблонной строки — в аудит как `line:<id> -> null`
|
||||||
data_edited = any([_apply_item(by_id[item.line_id], item) for item in items])
|
data_edited = any([_apply_item(by_id[item.line_id], item) for item in items])
|
||||||
_recompute_parents(lines)
|
_recompute_parents(lines)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@ -154,4 +158,6 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13
|
|||||||
now = _snapshot(line)
|
now = _snapshot(line)
|
||||||
if now != old[line.id]:
|
if now != old[line.id]:
|
||||||
before[f"line:{line.id}"], after[f"line:{line.id}"] = old[line.id], now
|
before[f"line:{line.id}"], after[f"line:{line.id}"] = old[line.id], now
|
||||||
|
for line_id in deleted:
|
||||||
|
before[f"line:{line_id}"], after[f"line:{line_id}"] = old[line_id], None
|
||||||
return data_edited
|
return data_edited
|
||||||
|
|||||||
@ -124,7 +124,7 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia
|
|||||||
if lines:
|
if lines:
|
||||||
return FormBlocker(
|
return FormBlocker(
|
||||||
LINES_UNRESOLVED,
|
LINES_UNRESOLVED,
|
||||||
"в форме есть нераспознанные строки со значениями: назначьте код или очистите строку",
|
"в форме есть нераспознанные строки со значениями: назначьте код или отметьте строку расшифровкой",
|
||||||
{
|
{
|
||||||
"lines": [
|
"lines": [
|
||||||
{"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text}
|
{"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text}
|
||||||
@ -269,7 +269,9 @@ class StatementService:
|
|||||||
"""Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED`
|
"""Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED`
|
||||||
не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б.
|
не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б.
|
||||||
|
|
||||||
Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`).
|
`warnings=()`: предупреждения приёма (`MAPPING_WARNING`) хранятся как `document_issue` уровня `info` и
|
||||||
|
определяют статус только при приёме (D-6); после правки аналитика статус строится по данным формы,
|
||||||
|
проверкам, контрагенту и периоду, а эти записи остаются видимыми в `issues` и статус не удерживают.
|
||||||
"""
|
"""
|
||||||
if document.status is DocumentStatus.CONFIRMED:
|
if document.status is DocumentStatus.CONFIRMED:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -167,6 +167,10 @@ async def test_code_taken_by_empty_template(
|
|||||||
stored = await _db(seeded, source.id)
|
stored = await _db(seeded, source.id)
|
||||||
assert stored is not None and stored.line_status.value == "ok" and stored.line_code == CODE
|
assert stored is not None and stored.line_status.value == "ok" and stored.line_code == CODE
|
||||||
assert [item["line_code"] for item in response.json()["lines"] if item["line_code"] == CODE] == [CODE]
|
assert [item["line_code"] for item in response.json()["lines"] if item["line_code"] == CODE] == [CODE]
|
||||||
|
# T13.2: удаление пустой шаблонной строки — в аудите `line:<id> -> null`
|
||||||
|
(event,) = await _edit_events(seeded, document)
|
||||||
|
assert event.before_value[f"line:{template.id}"]["line_code"] == CODE
|
||||||
|
assert event.after_value[f"line:{template.id}"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_mark_detail(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
|
async def test_mark_detail(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
|
||||||
@ -277,6 +281,7 @@ async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_cl
|
|||||||
stored = [await _db(seeded, line.id) for line in (top, parent, *details)]
|
stored = [await _db(seeded, line.id) for line in (top, parent, *details)]
|
||||||
assert [s.parent_row_index for s in stored if s] == [1, 1, 1, 1]
|
assert [s.parent_row_index for s in stored if s] == [1, 1, 1, 1]
|
||||||
response = await _patch(client, document, {"lines": [{"line_id": str(first.id), "line_status": "detail"}]})
|
response = await _patch(client, document, {"lines": [{"line_id": str(first.id), "line_status": "detail"}]})
|
||||||
|
assert response.status_code == HTTPStatus.OK, response.text
|
||||||
stored = [await _db(seeded, line.id) for line in (parent, *details)]
|
stored = [await _db(seeded, line.id) for line in (parent, *details)]
|
||||||
assert all(s.parent_row_index is None for s in stored if s)
|
assert all(s.parent_row_index is None for s in stored if s)
|
||||||
|
|
||||||
@ -297,3 +302,51 @@ async def test_line_edit_audit_and_accept(
|
|||||||
assert events[0].before_value[key]["line_status"] == "unreadable"
|
assert events[0].before_value[key]["line_status"] == "unreadable"
|
||||||
assert events[0].after_value[key]["line_status"] == "ok" and events[0].after_value[key]["line_code"] == CODE
|
assert events[0].after_value[key]["line_status"] == "ok" and events[0].after_value[key]["line_code"] == CODE
|
||||||
assert set(events[0].after_value[key]) == {"line_code", "line_status", "parent_row_index", "is_confirmed"}
|
assert set(events[0].after_value[key]) == {"line_code", "line_status", "parent_row_index", "is_confirmed"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_code_change_resets_confirmation_and_parent(
|
||||||
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
) -> None:
|
||||||
|
"""M-4: смена кода и отметка расшифровкой снимают подтверждение; detail -> ok обнуляет родителя."""
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
confirmed = await insert_line(seeded, document, code="1120", row_index=20, code_original="1120")
|
||||||
|
confirmed.is_confirmed = True
|
||||||
|
to_detail = await insert_line(seeded, document, code="1130", row_index=21, code_original="1130")
|
||||||
|
to_detail.is_confirmed = True
|
||||||
|
detail = await insert_line(seeded, document, code=None, row_index=22, status="detail")
|
||||||
|
detail.parent_row_index = 21
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
body = {
|
||||||
|
"lines": [
|
||||||
|
{"line_id": str(confirmed.id), "line_code": "1121"},
|
||||||
|
{"line_id": str(to_detail.id), "line_status": "detail"},
|
||||||
|
{"line_id": str(detail.id), "line_code": "1122"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
response = await _patch(client, document, body)
|
||||||
|
assert response.status_code == HTTPStatus.OK, response.text
|
||||||
|
first, second, third = [await _db(seeded, line.id) for line in (confirmed, to_detail, detail)]
|
||||||
|
assert first is not None and first.line_code == "1121" and first.is_confirmed is False
|
||||||
|
assert second is not None and second.line_status.value == "detail" and second.is_confirmed is False
|
||||||
|
assert third is not None and third.line_status.value == "ok" and third.parent_row_index is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_values_error_after_lines_persists_nothing(
|
||||||
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
) -> None:
|
||||||
|
"""M-10 (P3-10, отступление A13-9): 422 в `values` после применённых `lines` откатывает и строки, и аудит."""
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
garbage = await insert_line(seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5))
|
||||||
|
meta = await _meta(seeded, document)
|
||||||
|
column_id, garbage_id = (await repo.form_aggregate(seeded, meta.id))[0][0].id, garbage.id # до отката сессии
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
events_before = len(await _edit_events(seeded, document))
|
||||||
|
body = {
|
||||||
|
"lines": [{"line_id": str(garbage_id), "line_code": CODE}],
|
||||||
|
"values": [{"line_id": str(uuid.uuid4()), "column_id": str(column_id), "amount": "1"}],
|
||||||
|
}
|
||||||
|
response = await _patch(client, document, body)
|
||||||
|
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text
|
||||||
|
stored = await _db(seeded, garbage_id)
|
||||||
|
assert stored is not None and stored.line_status.value == "unreadable" and stored.line_code is None
|
||||||
|
assert len(await _edit_events(seeded, document)) == events_before
|
||||||
|
|||||||
@ -2135,7 +2135,7 @@ async def test_patch_organization_name_invalid_is_422(
|
|||||||
) -> None:
|
) -> None:
|
||||||
document = await form(seeded, fixtures_dir)
|
document = await form(seeded, fixtures_dir)
|
||||||
client = await auth_client(KA_RF)
|
client = await auth_client(KA_RF)
|
||||||
for name in ("", " ", "я" * 1001):
|
for name in ("", " ", "я" * 1001, None): # M-11: null, как inn: null, не молчаливый no-op
|
||||||
response = await client.patch(
|
response = await client.patch(
|
||||||
f"/documents/{document.id}/statement", json={"header": {"organization_name": name}}
|
f"/documents/{document.id}/statement", json={"header": {"organization_name": name}}
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user