diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json index 6b6d122..b0a9e97 100644 --- a/api/contracts/examples/result-osv-minimal.json +++ b/api/contracts/examples/result-osv-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.0", + "schema_version": "1.2", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "service": { "name": "tabular-parser", "version": "0.1.0" }, @@ -16,6 +16,9 @@ "period_year": 2025, "period_quarter": "Q1", "is_cumulative": false, + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "is_from_year_start": true, "data_type": "BU", "currency": "RUB", "header_variant": 1 @@ -66,7 +69,8 @@ "turnover_debit": "500.00", "turnover_credit": "200.50", "closing_debit": "0.00", - "closing_credit": "700.50" + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } } ], "checks": { diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json new file mode 100644 index 0000000..81d64a8 --- /dev/null +++ b/api/contracts/examples/result-osv-needs-check.json @@ -0,0 +1,125 @@ +{ + "schema_version": "1.1", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "1.0.0" }, + "status": "needs_check", + "metrics": { + "rows": 3, + "warnings": [ + "sheet=Лист_1 hierarchy=outline", + "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + ] + }, + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": null, + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "ACCOUNT", "name": "Счет" }, + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "D", + "opening_credit": "E", + "turnover_debit": "F", + "turnover_credit": "G", + "closing_debit": "H", + "closing_credit": "I", + "inn": "B" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": "Счет", + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } + }, + { + "row_number": 3, + "level": 2, + "parent_row_number": 2, + "row_value": "Вал.", + "group_type": "OTHER", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "CURRENCY", + "opening_debit": "0.00", + "opening_credit": "400.00", + "turnover_debit": "100.00", + "turnover_credit": "50.00", + "closing_debit": "0.00", + "closing_credit": "400.00" + }, + { + "row_number": 4, + "level": 0, + "parent_row_number": null, + "row_value": "Итого", + "group_type": "TOTAL", + "group_name": null, + "account_code_raw": null, + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [3], + "parent_sum_violations": [] + } + } + ] +} diff --git a/api/contracts/examples/result-statement-excel.json b/api/contracts/examples/result-statement-excel.json new file mode 100644 index 0000000..fcee529 --- /dev/null +++ b/api/contracts/examples/result-statement-excel.json @@ -0,0 +1,46 @@ +{ + "schema_version": "1.2", + "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", + "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "service": { "name": "tabular-parser", "version": "0.3.0" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "documents": [ + { + "document_type": "BALANCE", + "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", + "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + }, + { + "document_type": "INCOME", + "locator": "sheet:Отчет о финансовых результатах", + "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, + "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], + "lines": [ + { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + }, + { + "document_type": "DISCLOSURE", + "locator": "sheet:Пояснения", + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "columns": [], + "lines": [] + } + ] +} diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json index 484f222..51e3b17 100644 --- a/api/contracts/examples/result-statement-minimal.json +++ b/api/contracts/examples/result-statement-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.0", + "schema_version": "1.2", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "service": { "name": "scan-gateway", "version": "0.1.0" }, @@ -44,7 +44,8 @@ { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } ] } - ] + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } } ] } diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index c5eea87..9a168cf 100644 --- a/api/contracts/recognition-result.schema.json +++ b/api/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -53,6 +53,16 @@ }, "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "failedRelation": { + "type": "object", + "required": ["column_index", "relation", "expected", "actual"], + "properties": { + "column_index": { "type": "integer" }, + "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + } + }, "osvDocument": { "type": "object", "required": ["document_type", "locator", "header", "group_schema", "rows"], @@ -70,6 +80,9 @@ "period_year": { "type": ["integer", "null"] }, "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" }, + "period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" }, + "is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" }, "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, "header_variant": { "enum": [1, 2] } @@ -114,7 +127,8 @@ "turnover_debit": { "$ref": "#/$defs/money" }, "turnover_credit": { "$ref": "#/$defs/money" }, "closing_debit": { "$ref": "#/$defs/money" }, - "closing_credit": { "$ref": "#/$defs/money" } + "closing_credit": { "$ref": "#/$defs/money" }, + "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } } } } }, @@ -132,7 +146,7 @@ "type": "object", "required": ["document_type", "locator", "header", "columns", "lines"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "OTHER"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, "locator": { "type": "string", "examples": ["pages:1-2"] }, "header": { "type": "object", @@ -190,6 +204,15 @@ } } } + }, + "checks": { + "type": "object", + "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "properties": { + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе»): только предупреждение" }, + "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } } } } diff --git a/api/src/contract/result.py b/api/src/contract/result.py index d591872..1af260b 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -27,6 +27,7 @@ from src.contract.common import ( ) __all__ = [ + "FailedRelation", "OsvChecks", "OsvDocument", "OsvGroup", @@ -35,6 +36,7 @@ __all__ = [ "RecognitionResult", "ResultError", "ResultStatus", + "StatementChecks", "StatementColumn", "StatementDocument", "StatementHeader", @@ -70,6 +72,9 @@ class OsvHeader(ContractModel): period_year: IntLike | None = None period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) + period_start: ContractDate | None = None # начало периода по шапке (v1.2, OQ-131) + period_end: ContractDate | None = None + is_from_year_start: bool | None = None # period_start — 1 января; is_cumulative не переопределяет data_type: DataType | None = None currency: str | None = None header_variant: OptionalNotNull[HeaderVariant] = None @@ -100,6 +105,7 @@ class OsvRow(ContractModel): turnover_credit: Money = None closing_debit: Money = None closing_credit: Money = None + extra: dict[str, str | None] | None = None # нераспознанные колонки таблицы: заголовок → текст ячейки (§1.2) class OsvChecks(ContractModel): @@ -159,12 +165,33 @@ class StatementLine(ContractModel): values: list[StatementValue] +class FailedRelation(ContractModel): + """Нарушенное соотношение §7 по одной колонке. + + `expected` — левая часть как в файле, `actual` — правая по строкам. + """ + + column_index: IntLike + relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml + expected: Money + actual: Money + + +class StatementChecks(ContractModel): + """Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" + + relations_failed: list[FailedRelation] = Field(default_factory=list) + soft_relations_failed: list[FailedRelation] = Field(default_factory=list) + duplicate_line_codes: list[str] = Field(default_factory=list) + + class StatementDocument(ContractModel): - document_type: Literal["BALANCE", "INCOME", "OTHER"] + document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] locator: str # «pages:1-2» header: StatementHeader columns: list[StatementColumn] lines: list[StatementLine] + checks: OptionalNotNull[StatementChecks] = None ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 7799b78..e8d0f4a 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -65,9 +65,11 @@ EXAMPLE_KIND = { "result-message-processing.json": "message", "result-error.json": "result", "result-osv-minimal.json": "result", + "result-osv-needs-check.json": "result", "result-statement-minimal.json": "result", + "result-statement-excel.json": "result", } -EXAMPLES_COUNT = 8 +EXAMPLES_COUNT = 10 def _load(path: Path) -> Any: