feat: контракт результата 2.0 — документы и предупреждения файла общего кода arm_statements (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:08:44 +04:00
parent 3b9e1f6e19
commit ae2922eb30
33 changed files with 1066 additions and 1586 deletions

View File

@ -1,8 +1,11 @@
{ {
"schema_version": "1.0", "schema_version": "2.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" }, "service": {
"name": "tabular-parser",
"version": "0.1.0"
},
"status": "error", "status": "error",
"error": { "error": {
"code": "NOT_IMPLEMENTED", "code": "NOT_IMPLEMENTED",

View File

@ -1,14 +1,17 @@
{ {
"schema_version": "1.0", "schema_version": "2.0",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"status": "done", "status": "done",
"service": { "name": "scan-gateway", "version": "0.1.0" }, "service": {
"name": "scan-gateway",
"version": "0.1.0"
},
"result_s3": { "result_s3": {
"bucket": "arm-ka", "bucket": "arm-ka",
"key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json"
}, },
"result_schema_version": "1.0", "result_schema_version": "2.0",
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
"metrics": { "metrics": {
"duration_ms": 18400, "duration_ms": 18400,

View File

@ -1,9 +1,12 @@
{ {
"schema_version": "1.0", "schema_version": "2.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"status": "error", "status": "error",
"service": { "name": "tabular-parser", "version": "0.1.0" }, "service": {
"name": "tabular-parser",
"version": "0.1.0"
},
"error": { "error": {
"code": "NOT_IMPLEMENTED", "code": "NOT_IMPLEMENTED",
"message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet"

View File

@ -1,7 +1,10 @@
{ {
"schema_version": "1.0", "schema_version": "2.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"status": "processing", "status": "processing",
"service": { "name": "tabular-parser", "version": "0.1.0" } "service": {
"name": "tabular-parser",
"version": "0.1.0"
}
} }

View File

@ -1,8 +1,11 @@
{ {
"schema_version": "1.2", "schema_version": "2.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" }, "service": {
"name": "tabular-parser",
"version": "0.1.0"
},
"status": "done", "status": "done",
"documents": [ "documents": [
{ {
@ -24,7 +27,11 @@
"header_variant": 1 "header_variant": 1
}, },
"group_schema": [ "group_schema": [
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } {
"type": "COUNTERPARTY",
"name": "Контрагенты",
"has_inn": true
}
], ],
"columns": { "columns": {
"opening_debit": "C", "opening_debit": "C",
@ -70,14 +77,18 @@
"turnover_credit": "200.50", "turnover_credit": "200.50",
"closing_debit": "0.00", "closing_debit": "0.00",
"closing_credit": "700.50", "closing_credit": "700.50",
"extra": { "КПП": "352801001" } "extra": {
"КПП": "352801001"
}
} }
], ],
"checks": { "checks": {
"total_row_matches_root": true, "total_row_matches_root": true,
"balance_identity_violations": [], "balance_identity_violations": [],
"parent_sum_violations": [] "parent_sum_violations": []
} },
"status": "done",
"warnings": []
} }
] ]
} }

View File

@ -1,15 +1,15 @@
{ {
"schema_version": "1.1", "schema_version": "2.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "1.0.0" }, "service": {
"name": "tabular-parser",
"version": "1.0.0"
},
"status": "needs_check", "status": "needs_check",
"metrics": { "metrics": {
"rows": 3, "rows": 3,
"warnings": [ "warnings": []
"sheet=Лист_1 hierarchy=outline",
"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3"
]
}, },
"documents": [ "documents": [
{ {
@ -28,8 +28,15 @@
"header_variant": 1 "header_variant": 1
}, },
"group_schema": [ "group_schema": [
{ "type": "ACCOUNT", "name": "Счет" }, {
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } "type": "ACCOUNT",
"name": "Счет"
},
{
"type": "COUNTERPARTY",
"name": "Контрагенты",
"has_inn": true
}
], ],
"columns": { "columns": {
"opening_debit": "D", "opening_debit": "D",
@ -76,7 +83,9 @@
"turnover_credit": "200.50", "turnover_credit": "200.50",
"closing_debit": "0.00", "closing_debit": "0.00",
"closing_credit": "700.50", "closing_credit": "700.50",
"extra": { "КПП": "352801001" } "extra": {
"КПП": "352801001"
}
}, },
{ {
"row_number": 3, "row_number": 3,
@ -117,9 +126,24 @@
], ],
"checks": { "checks": {
"total_row_matches_root": true, "total_row_matches_root": true,
"balance_identity_violations": [3], "balance_identity_violations": [
3
],
"parent_sum_violations": [] "parent_sum_violations": []
} },
"status": "needs_check",
"warnings": [
{
"code": "OSV_HIERARCHY_BY_OUTLINE",
"severity": "info",
"message": "иерархия строк — по группировке листа (outline)"
},
{
"code": "OSV_BALANCE_IDENTITY",
"severity": "info",
"message": "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3"
}
]
} }
] ]
} }

View File

@ -1,44 +1,99 @@
{ {
"schema_version": "1.2", "schema_version": "2.0",
"job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30",
"file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"service": { "name": "tabular-parser", "version": "0.3.0" }, "service": { "name": "tabular-parser", "version": "0.4.0", "statements_version": "abc1234" },
"status": "done", "status": "needs_check",
"metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, "metrics": { "duration_ms": 412, "rows": 12, "warnings": [] },
"documents": [ "documents": [
{ {
"document_type": "BALANCE", "document_type": "BALANCE",
"locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", "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" }, "status": "done",
"form_variant": "full",
"warnings": [
{ "code": "FRACTIONAL_IN_THOUSANDS", "severity": "info", "message": "значений с дробной частью при единице тыс. руб.: 6 из 14" }
],
"header": {
"form_name": "Бухгалтерский баланс",
"organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100,
"inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100,
"report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "report_date_confidence": 100,
"units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100,
"okud": "0710001", "okud_text": "0710001", "okud_confidence": 100,
"okpo": "12345678", "okpo_text": "12345678", "okpo_confidence": 100,
"okei": "384", "okei_text": "384", "okei_confidence": 100,
"needs_review_fields": []
},
"columns": [ "columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, { "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" },
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } { "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" }
], ],
"lines": [ "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 } ] }, { "row_index": 0, "ref": "'Бухгалтерский баланс. Лист 1'!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false,
{ "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 } ] }, "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "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 } ] }, { "row_index": 1, "ref": "'Бухгалтерский баланс. Лист 1'!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false,
{ "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 } ] }, "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "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 } ] } { "row_index": 2, "ref": "'Бухгалтерский баланс. Лист 1'!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 3, "ref": "'Бухгалтерский баланс. Лист 1'!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 4, "ref": "'Бухгалтерский баланс. Лист 1'!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 5, "ref": "'Бухгалтерский баланс. Лист 2'!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 6, "ref": "'Бухгалтерский баланс. Лист 2'!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 7, "ref": "'Бухгалтерский баланс. Лист 2'!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
], ],
"checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } "checks": {
"relations_failed": [],
"soft_relations_failed": [ { "column_index": 4, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ]
}
}, },
{ {
"document_type": "INCOME", "document_type": "INCOME",
"locator": "sheet:Отчет о финансовых результатах", "locator": "sheet:Отчет о финансовых результатах",
"header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, "status": "needs_check",
"columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], "form_variant": "full",
"lines": [ "warnings": [
{ "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 } ] }, { "code": "INN_NOT_FOUND", "severity": "info", "message": "в шапке не найден ИНН", "field": "inn" }
{ "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": [] } "header": {
"form_name": "Отчет о финансовых результатах",
"organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100,
"inn": null, "inn_text": null, "inn_confidence": null,
"report_date": "2025-09-30", "report_date_text": "за январь - сентябрь 2025 г.", "report_date_confidence": 100,
"units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100,
"okud": "0710002", "okud_text": "0710002", "okud_confidence": 100,
"needs_review_fields": []
},
"columns": [
{ "column_index": 3, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
],
"lines": [
{ "row_index": 0, "ref": "'Отчет о финансовых результатах'!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 1, "ref": "'Отчет о финансовых результатах'!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "'Отчет о финансовых результатах'!C12", "needs_review": false,
"values": [ { "column_index": 3, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 2, "ref": "'Отчет о финансовых результатах'!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ "row_index": 3, "ref": "'Отчет о финансовых результатах'!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false,
"values": [ { "column_index": 3, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] }
],
"checks": {
"relations_failed": [ { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ],
"soft_relations_failed": []
}
}, },
{ {
"document_type": "DISCLOSURE", "document_type": "DISCLOSURE",
"locator": "sheet:Пояснения", "locator": "sheet:Пояснения",
"header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, "status": "done",
"form_variant": null,
"warnings": [],
"header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах", "needs_review_fields": [] },
"columns": [], "columns": [],
"lines": [] "lines": []
} }

View File

@ -1,50 +1,51 @@
{ {
"schema_version": "1.2", "schema_version": "2.0",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"service": { "name": "scan-gateway", "version": "0.1.0" }, "service": { "name": "scan-gateway", "version": "0.2.0", "statements_version": "abc1234" },
"status": "done", "status": "needs_check",
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
"metrics": { "duration_ms": 18400, "pages": 2, "warnings": [] },
"documents": [ "documents": [
{ {
"document_type": "BALANCE", "document_type": "BALANCE",
"locator": "pages:1-2", "locator": "file:8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json;table:0",
"status": "needs_check",
"form_variant": "full",
"warnings": [
{ "code": "ORGANIZATION_UNCERTAIN", "severity": "info", "message": "организация распознана неуверенно", "field": "organization_name" },
{ "code": "ROW_SPLIT", "severity": "info", "message": "строка с несколькими кодами разлеплена: 1370, 1300", "line_refs": ["row:14#1", "row:14#2"] }
],
"header": { "header": {
"form_name": "Бухгалтерский баланс", "form_name": "Бухгалтерский баланс",
"organization_name": "ООО Ромашка", "organization_name": "ООО Ромашка", "organization_name_text": "ООО Ромашка", "organization_name_confidence": 97.5,
"organization_name_confidence": 97.5, "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100,
"inn": "7700000000", "report_date": "2025-09-30", "report_date_text": "30 сентября 2025 г.", "report_date_confidence": 100,
"inn_confidence": 99, "units": "тыс. руб.", "units_text": "тыс. руб.", "units_confidence": 100,
"report_date_text": "30 сентября 2025 г.", "needs_review_fields": []
"report_date": "2025-09-30",
"units": "тыс. руб."
}, },
"columns": [ "columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, { "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" }
], ],
"lines": [ "lines": [
{ { "row_index": 0, "ref": "row:1", "line_status": "ok", "line_code": "1210", "line_code_text": "1210", "line_code_confidence": 100, "line_name": "Запасы", "line_name_confidence": 100, "needs_review": false,
"line_code": "1100", "values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
"line_code_confidence": 99, { "row_index": 1, "ref": "row:2", "line_status": "ok", "line_code": "1230", "line_code_text": "1230", "line_code_confidence": 100, "line_name": "Дебиторская задолжен-ность", "line_name_confidence": 80, "needs_review": false,
"line_name": "Итого по разделу I", "values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
"line_name_confidence": 96, { "row_index": 2, "ref": "row:3", "line_status": "unreadable", "line_code": null, "line_code_text": "ИЧ,250", "line_code_confidence": 25, "line_name": "Финансовые вложения", "line_name_confidence": 67, "needs_review": false,
"values": [ "values": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] },
{ "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, { "row_index": 3, "ref": "row:4", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false,
{ "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
] { "row_index": 4, "ref": "row:5", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
}, "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
{ { "row_index": 5, "ref": "row:14#1", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false,
"line_code": "1600", "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
"line_code_confidence": 99, { "row_index": 6, "ref": "row:14#2", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false,
"line_name": "БАЛАНС (актив)", "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
"line_name_confidence": 98, { "row_index": 7, "ref": "row:15", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false,
"values": [ "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
{ "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, ],
{ "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } "checks": { "relations_failed": [], "soft_relations_failed": [] }
]
}
]
} }
] ]
} }

View File

@ -1,13 +1,13 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result-message/v1", "$id": "arm-ka/recognition-result-message/v2",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v2.0",
"description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата. Схема 1.0 на окне перехода лежит рядом — recognition-result-message.v1.schema.json (примеры — examples/v1/).",
"type": "object", "type": "object",
"required": ["schema_version", "job_id", "file_id", "status", "service"], "required": ["schema_version", "job_id", "file_id", "status", "service"],
"properties": { "properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" },
"job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" },
"file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" },
"status": { "enum": ["processing", "done", "needs_check", "error"] }, "status": { "enum": ["processing", "done", "needs_check", "error"] },
@ -47,7 +47,22 @@
"fields_total": { "type": "integer" }, "fields_total": { "type": "integer" },
"fields_low_confidence": { "type": "integer" }, "fields_low_confidence": { "type": "integer" },
"fields_unrecognized": { "type": "integer" }, "fields_unrecognized": { "type": "integer" },
"warnings": { "type": "array", "items": { "type": "string" } } "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла — те же, что metrics.warnings файла результата" }
}
}
},
"$defs": {
"fileWarning": {
"type": "object",
"required": ["code", "severity", "message"],
"properties": {
"code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
"severity": { "enum": ["error", "info"] },
"message": { "type": "string" },
"line_refs": { "type": "array", "items": { "type": "string" } },
"column_index": { "type": "integer" },
"field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] },
"locator": { "type": "string" }
} }
} }
}, },

View File

@ -1,13 +1,13 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result/v1", "$id": "arm-ka/recognition-result/v2",
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0",
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, header.okei (+ _text, _confidence), смысл is_recognized у OCR отвязан от confidence, statementDocument.form_variant обязателен, OTHER удалён из statementDocument.document_type. Схема 1.2 на окне перехода лежит рядом — recognition-result.v1.schema.json (примеры — examples/v1/); удаляется вместе с маппером 1.x ядра после перехода обоих сервисов.",
"type": "object", "type": "object",
"required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"],
"properties": { "properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" },
"job_id": { "type": "string", "format": "uuid" }, "job_id": { "type": "string", "format": "uuid" },
"file_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" },
"service": { "service": {
@ -15,10 +15,11 @@
"required": ["name", "version"], "required": ["name", "version"],
"properties": { "properties": {
"name": { "enum": ["tabular-parser", "scan-gateway"] }, "name": { "enum": ["tabular-parser", "scan-gateway"] },
"version": { "type": "string" } "version": { "type": "string" },
"statements_version": { "type": "string", "minLength": 1, "description": "коммит репозитория arm-statements, из которого разложена копия общего кода; обязателен, если в documents есть statementDocument" }
} }
}, },
"status": { "enum": ["done", "needs_check", "error"] }, "status": { "enum": ["done", "needs_check", "error"], "description": "done ⇔ все документы done и нет предупреждений файла уровня error; error — документов нет" },
"error": { "error": {
"type": ["object", "null"], "type": ["object", "null"],
"properties": { "properties": {
@ -35,7 +36,7 @@
"fields_total": { "type": "integer" }, "fields_total": { "type": "integer" },
"fields_low_confidence": { "type": "integer" }, "fields_low_confidence": { "type": "integer" },
"fields_unrecognized": { "type": "integer" }, "fields_unrecognized": { "type": "integer" },
"warnings": { "type": "array", "items": { "type": "string" } } "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла (ошибочный json набора ИС, потерянная таблица, формат файла)" }
} }
}, },
"raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" },
@ -44,31 +45,68 @@
"items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] }
} }
}, },
"allOf": [
{
"if": { "if": {
"properties": { "status": { "enum": ["done", "needs_check"] } }, "properties": { "status": { "enum": ["done", "needs_check"] } },
"required": ["status"] "required": ["status"]
}, },
"then": { "then": {
"properties": { "documents": { "minItems": 1 } } "properties": { "documents": { "minItems": 1 } }
}
}, },
{
"if": {
"properties": { "documents": { "contains": { "properties": { "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"] } }, "required": ["document_type"] } } },
"required": ["documents"]
},
"then": {
"properties": { "service": { "required": ["name", "version", "statements_version"] } }
}
}
],
"$defs": { "$defs": {
"money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" },
"confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"documentStatus": { "enum": ["done", "needs_check"] },
"headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] },
"warning": {
"type": "object",
"required": ["code", "severity", "message"],
"properties": {
"code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$", "examples": ["COLUMN_WITHOUT_DATE", "ROW_SPLIT"], "description": "код по таблице 05-domain-rules.md §11; не перечисление — новый код не меняет MAJOR" },
"severity": { "enum": ["error", "info"], "description": "error → документ needs_check; info на статус не влияет" },
"message": { "type": "string" },
"line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа — только строк из statementDocument.lines[]; строки ролей title / header / numbering в line_refs не попадают (о них — message)" },
"column_index": { "type": "integer", "description": "столбец сетки кандидата (как columns[].column_index); у COLUMN_SKIPPED и других предупреждений о столбце вне columns[] — номер этого столбца" },
"field": { "$ref": "#/$defs/headerField" }
}
},
"fileWarning": {
"allOf": [ { "$ref": "#/$defs/warning" } ],
"properties": {
"locator": { "type": "string", "examples": ["file:aep_3_ofr.json", "sheet:Лист3"], "description": "место в файле, к которому относится предупреждение" }
}
},
"failedRelation": { "failedRelation": {
"type": "object", "type": "object",
"required": ["column_index", "relation", "expected", "actual"], "required": ["column_index", "relation", "codes", "expected", "actual"],
"properties": { "properties": {
"column_index": { "type": "integer" }, "column_index": { "type": "integer" },
"relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] },
"expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, "codes": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^\\d{4,5}$" }, "description": "коды цели и членов соотношения: нарушение подсвечивает все строки этих кодов в колонке" },
"actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения (цель) по строкам ok; null — цели нет" },
"actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам ok" }
} }
}, },
"osvDocument": { "osvDocument": {
"type": "object", "type": "object",
"required": ["document_type", "locator", "header", "group_schema", "rows"], "required": ["document_type", "locator", "header", "group_schema", "rows", "status", "warnings"],
"properties": { "properties": {
"document_type": { "const": "OSV" }, "document_type": { "const": "OSV" },
"locator": { "type": "string", "examples": ["sheet:Лист_1"] }, "locator": { "type": "string", "examples": ["sheet:Лист_1"] },
"status": { "$ref": "#/$defs/documentStatus", "description": "done — контрольные суммы §1.4 листа сошлись, иначе needs_check (05-domain-rules.md §1.6)" },
"warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" }, "description": "предупреждения листа ОСВ; все уровня info, код с префиксом OSV_" },
"header": { "header": {
"type": "object", "type": "object",
"required": ["organization_name", "account_code_raw", "period_text"], "required": ["organization_name", "account_code_raw", "period_text"],
@ -142,79 +180,131 @@
} }
} }
}, },
"statementValue": {
"type": "object",
"required": ["column_index", "amount", "text", "confidence", "is_recognized", "needs_review"],
"properties": {
"column_index": { "type": "integer", "description": "колонка значений — columns[].column_index" },
"amount": { "$ref": "#/$defs/money" },
"string_value": { "type": ["string", "null"], "description": "текст ячейки, только когда amount = null и текст непуст и не прочерк" },
"text": { "type": ["string", "null"], "description": "сырой текст источника всегда (у Excel — как показывает числовой формат); null — позиции нет" },
"confidence": { "$ref": "#/$defs/confidence", "description": "уверенность источника (у Excel 100; позиции нет — 100)" },
"is_recognized": { "type": "boolean", "description": "текст разобран как число без выброшенных и исправленных символов; от confidence не зависит" },
"needs_review": { "type": "boolean", "description": "сводный признак: нуждается в покрытии §7 и не покрыто, не подтверждён знак, или в несошедшемся жёстком соотношении (§11)" }
}
},
"statementLine": {
"type": "object",
"required": ["row_index", "ref", "line_status", "line_code", "line_code_text", "line_name", "needs_review", "values"],
"properties": {
"row_index": { "type": "integer", "minimum": 0, "description": "порядковый номер строки в документе с 0, по порядку сетки после разлепления" },
"ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27", "'Бухгалтерский баланс. Лист 1'!C14"], "description": "положение в источнике; уникален в документе. OCR — row:<row_index ИС>[#k]; Excel — <лист>!<A1> по правилу Excel: имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф внутри имени удваивается" },
"line_status": { "enum": ["ok", "unreadable", "missing", "detail"] },
"line_code": { "type": ["string", "null"], "pattern": "^\\d{4,5}$", "description": "разобранный код; null ⇔ line_status ≠ ok" },
"line_code_text": { "type": ["string", "null"], "description": "сырой текст ячейки кода" },
"line_code_confidence": { "$ref": "#/$defs/confidence", "description": "от источника; у кода с заменой двойника — не выше 99" },
"line_name": { "type": ["string", "null"], "description": "распознанное наименование как в источнике (не из справочника)" },
"line_name_confidence": { "$ref": "#/$defs/confidence" },
"parent_ref": { "type": ["string", "null"], "description": "ref строки-родителя у detail" },
"needs_review": { "type": "boolean", "description": "неуверенный код не подтверждён структурой или повтор кода с неуверенной копией" },
"values": { "type": "array", "items": { "$ref": "#/$defs/statementValue" } }
},
"allOf": [
{
"if": { "properties": { "line_status": { "const": "ok" } }, "required": ["line_status"] },
"then": { "properties": { "line_code": { "type": "string" } } },
"else": { "properties": { "line_code": { "type": "null" } } }
},
{
"if": { "properties": { "line_status": { "const": "detail" } }, "required": ["line_status"] },
"then": { "required": ["parent_ref"], "properties": { "parent_ref": { "type": "string" } } }
}
]
},
"statementDocument": { "statementDocument": {
"type": "object", "type": "object",
"required": ["document_type", "locator", "header", "columns", "lines"], "required": ["document_type", "form_variant", "locator", "header", "columns", "lines", "status", "warnings"],
"properties": { "properties": {
"document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"], "description": "OTHER в v2.0 не порождается: лист «прочее» — предупреждение файла SHEET_OTHER, документа нет (значение OTHER — только в схеме 1.x)" },
"locator": { "type": "string", "examples": ["pages:1-2"] }, "locator": { "type": "string", "examples": ["file:aep_3.json;table:0", "sheet:Баланс,Баланс (2)"] },
"status": { "$ref": "#/$defs/documentStatus" },
"form_variant": { "enum": ["full", "simplified", null], "description": "по структуре кодов строк ok (§11); null — DISCLOSURE; обязателен" },
"warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" } },
"header": { "header": {
"type": "object", "type": "object",
"properties": { "properties": {
"form_name": { "type": "string" }, "form_name": { "type": ["string", "null"], "description": "заголовок формы как в источнике" },
"organization_name": { "type": ["string", "null"] }, "organization_name": { "type": ["string", "null"] },
"organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, "organization_name_text": { "type": ["string", "null"] },
"inn": { "type": ["string", "null"] }, "organization_name_confidence": { "$ref": "#/$defs/confidence" },
"inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, "inn": { "type": ["string", "null"], "pattern": "^(\\d{10}|\\d{12})$" },
"report_date_text": { "type": ["string", "null"] }, "inn_text": { "type": ["string", "null"] },
"inn_confidence": { "$ref": "#/$defs/confidence" },
"report_date": { "type": ["string", "null"], "format": "date" }, "report_date": { "type": ["string", "null"], "format": "date" },
"report_date_text": { "type": ["string", "null"] },
"report_date_confidence": { "$ref": "#/$defs/confidence" },
"units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, "units": { "type": ["string", "null"], "examples": ["тыс. руб."] },
"units_text": { "type": ["string", "null"] },
"units_confidence": { "$ref": "#/$defs/confidence" },
"okud": { "type": ["string", "null"] }, "okud": { "type": ["string", "null"] },
"okud_text": { "type": ["string", "null"] },
"okud_confidence": { "$ref": "#/$defs/confidence" },
"okpo": { "type": ["string", "null"] }, "okpo": { "type": ["string", "null"] },
"okpo_text": { "type": ["string", "null"] },
"okpo_confidence": { "$ref": "#/$defs/confidence" },
"okopf": { "type": ["string", "null"] }, "okopf": { "type": ["string", "null"] },
"okopf_text": { "type": ["string", "null"] },
"okopf_confidence": { "$ref": "#/$defs/confidence" },
"okfs": { "type": ["string", "null"] }, "okfs": { "type": ["string", "null"] },
"address": { "type": ["string", "null"] } "okfs_text": { "type": ["string", "null"] },
"okfs_confidence": { "$ref": "#/$defs/confidence" },
"okei": { "type": ["string", "null"], "examples": ["384"], "description": "код ОКЕИ единицы (384 — тыс. руб., 383 — руб., 385 — млн руб.); запасной источник units (§11.2)" },
"okei_text": { "type": ["string", "null"] },
"okei_confidence": { "$ref": "#/$defs/confidence" },
"address": { "type": ["string", "null"] },
"address_text": { "type": ["string", "null"] },
"address_confidence": { "$ref": "#/$defs/confidence" },
"needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки, значение которых требует проверки и держит документ в needs_check (сейчас — только неуверенная дата отчёта, не подтверждённая датами колонок); сомнения в ИНН и организации — предупреждения info с field, сюда не входят (§11.9)" }
} }
}, },
"columns": { "columns": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"required": ["column_index", "label"], "required": ["column_index", "label", "period_kind"],
"properties": { "properties": {
"column_index": { "type": "integer" }, "column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" },
"label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, "label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] },
"period_date": { "type": ["string", "null"], "format": "date" } "label_confidence": { "$ref": "#/$defs/confidence", "description": "у Excel 100; подписи нет — null" },
} "period_date": { "type": ["string", "null"], "format": "date", "description": "конец периода колонки (period_end); у относительной подписи — от эффективной даты документа" },
} "period_kind": { "enum": ["absolute", "relative:current", "relative:prev_year_end", "relative:prev_prev_year_end", "relative:prev_period", "none"] }
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["line_code", "line_name", "values"],
"properties": {
"line_code": { "type": "string" },
"line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"line_name": { "type": "string" },
"line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"values": {
"type": "array",
"items": {
"type": "object",
"required": ["column_index"],
"properties": {
"column_index": { "type": "integer" },
"amount": { "$ref": "#/$defs/money" },
"string_value": { "type": ["string", "null"] },
"confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"is_recognized": { "type": "boolean" }
}
}
}
} }
} }
}, },
"lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" },
"checks": { "checks": {
"type": "object", "type": "object",
"description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", "required": ["relations_failed", "soft_relations_failed"],
"description": "Самопроверка формы по соотношениям §7 общим кодом (оба конвейера). Есть у BALANCE/INCOME со строками, нет у документа без строк. Пустые массивы — «проверено, сошлось».",
"properties": { "properties": {
"relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "жёсткие соотношения, не сошедшиеся вне допуска ±1 и округления 0,001 %: документ needs_check" },
"soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "мягкие соотношения (OQ-12, подстроки) и жёсткие в пределах округления (OQ-138): info" }
"duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } }
} }
} },
} "allOf": [
{
"if": {
"properties": { "document_type": { "enum": ["BALANCE", "INCOME"] }, "lines": { "minItems": 1 } },
"required": ["document_type", "lines"]
},
"then": { "required": ["checks"] }
},
{
"if": { "properties": { "lines": { "maxItems": 0 } }, "required": ["lines"] },
"then": { "not": { "required": ["checks"] } }
}
]
} }
} }
} }

View File

@ -4,8 +4,8 @@
`GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`), `GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`),
`subjects` (`image_request`, `result` — subject'ы NATS из настроек). `subjects` (`image_request`, `result` — subject'ы NATS из настроек).
- lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер - lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер
(`Worker` с `OcrNormalizer`). Справочники нормализатора загружаются из `SCAN_RULES_DIR` в `create_app`: битый (`Worker` с `OcrNormalizer`). Словари нормализатора — `default_rules()` пакета `arm_statements`; копия без `VERSION` —
справочник — `ValueError`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт `ValueError` в `create_app`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт
фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка на subject запроса `image` появляется после фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка на subject запроса `image` появляется после
подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`, подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`,
HTTP-сервер жив. HTTP-сервер жив.
@ -35,7 +35,6 @@ from src.core.nats import NatsGateway, used_subjects
from src.core.worker import Worker from src.core.worker import Worker
from src.gateway import create_hotfolder from src.gateway import create_hotfolder
from src.normalizer import OcrNormalizer from src.normalizer import OcrNormalizer
from src.normalizer.rules import load_rules
async def run_nats(gateway: NatsGateway, worker: Worker) -> None: async def run_nats(gateway: NatsGateway, worker: Worker) -> None:
@ -66,7 +65,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
version = settings.VERSION version = settings.VERSION
up_time = datetime.now(UTC) up_time = datetime.now(UTC)
hotfolder = create_hotfolder(settings) hotfolder = create_hotfolder(settings)
normalizer = OcrNormalizer(load_rules(settings.SCAN_RULES_DIR)) # битый справочник — сервис не стартует (D-7) normalizer = OcrNormalizer() # словари — default_rules(); нет VERSION копии — не стартует (P5-11)
subjects = used_subjects(settings.OPENBAO.NATS) subjects = used_subjects(settings.OPENBAO.NATS)
health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder, subjects=subjects) health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder, subjects=subjects)

View File

@ -1,4 +1,4 @@
"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). """Контракт обмена с сервисами распознавания v2.0 (запрос — v1.0; схемы — `api/contracts/`, копии `docs/contracts/`).
Публичный API: pydantic-модели запроса, конверта и файла результата, конструкторы конвертов, subject'ы NATS, Публичный API: pydantic-модели запроса, конверта и файла результата, конструкторы конвертов, subject'ы NATS,
идентификаторы сообщений (`Nats-Msg-Id`), проверка MAJOR версии. идентификаторы сообщений (`Nats-Msg-Id`), проверка MAJOR версии.
@ -8,11 +8,14 @@ from src.contract.common import (
CONTRACT_VERSION, CONTRACT_VERSION,
SUPPORTED_MAJOR, SUPPORTED_MAJOR,
ContractModel, ContractModel,
DocumentWarning,
ErrorCode, ErrorCode,
FileWarning,
Metrics, Metrics,
S3Ref, S3Ref,
ServiceInfo, ServiceInfo,
ServiceName, ServiceName,
Severity,
is_supported_major, is_supported_major,
) )
from src.contract.message import ( from src.contract.message import (
@ -52,8 +55,10 @@ __all__ = [
"SUPPORTED_MAJOR", "SUPPORTED_MAJOR",
"TABULAR_REQUEST_SUBJECT", "TABULAR_REQUEST_SUBJECT",
"ContractModel", "ContractModel",
"DocumentWarning",
"ErrorCode", "ErrorCode",
"FileType", "FileType",
"FileWarning",
"MessageError", "MessageError",
"MessageStatus", "MessageStatus",
"Metrics", "Metrics",
@ -66,6 +71,7 @@ __all__ = [
"S3Ref", "S3Ref",
"ServiceInfo", "ServiceInfo",
"ServiceName", "ServiceName",
"Severity",
"StatementDocument", "StatementDocument",
"ack_msg_id", "ack_msg_id",
"build_ack_message", "build_ack_message",

View File

@ -1,4 +1,4 @@
"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. """Общие основы моделей контракта v2.0 (запрос — v1.0): базовый класс, версия, типы, метрики и ошибки.
Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false`
(кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — (кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) —
@ -27,20 +27,21 @@ from pydantic import (
model_validator, model_validator,
) )
CONTRACT_VERSION = "1.0" CONTRACT_VERSION = "2.0" # файл результата и конверт (включая ack); запрос остаётся 1.x (P5-13)
SUPPORTED_MAJOR = 1 # сервис принимает запросы только этого MAJOR; любой MINOR SUPPORTED_MAJOR = 1 # MAJOR ЗАПРОСА: сервис принимает запросы только этого MAJOR; любой MINOR
# Шаблоны версий — буквально из схем # Шаблоны версий — буквально из схем
SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR SCHEMA_VERSION_PATTERN = r"^2\.\d+$" # recognition-result и recognition-result-message: MAJOR 2, любой MINOR
REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # recognition-request: MAJOR 1, любой MINOR
RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version
T = TypeVar("T") T = TypeVar("T")
_SUPPORTED_VERSION_RE = re.compile(SCHEMA_VERSION_PATTERN, re.ASCII) # ровно шаблон схемы, без int() _SUPPORTED_VERSION_RE = re.compile(REQUEST_SCHEMA_VERSION_PATTERN, re.ASCII) # ровно шаблон схемы запроса, без int()
def is_supported_major(schema_version: object) -> bool: def is_supported_major(schema_version: object) -> bool:
"""MAJOR версии `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`). """MAJOR версии запроса `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`).
Принимает любой объект (версия берётся из ещё не проверенного тела): не строка и не `MAJOR.MINOR` — `False`. Принимает любой объект (версия берётся из ещё не проверенного тела): не строка и не `MAJOR.MINOR` — `False`.
""" """
@ -188,6 +189,7 @@ HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)]
NonEmptyStr = Annotated[str, Field(min_length=1)] NonEmptyStr = Annotated[str, Field(min_length=1)]
SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)]
RequestSchemaVersion = Annotated[str, Field(pattern=REQUEST_SCHEMA_VERSION_PATTERN)]
ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)]
# Уверенность распознавания, проценты (число 0–100) # Уверенность распознавания, проценты (число 0–100)
Confidence = Annotated[float, Field(ge=0, le=100)] | None Confidence = Annotated[float, Field(ge=0, le=100)] | None
@ -223,6 +225,7 @@ class ServiceInfo(ContractModel):
name: LaxServiceName name: LaxServiceName
version: str version: str
statements_version: OptionalNotNull[NonEmptyStr] = None # коммит arm-statements; только в файле результата
class S3Ref(ContractModel): class S3Ref(ContractModel):
@ -232,6 +235,36 @@ class S3Ref(ContractModel):
key: NonEmptyStr key: NonEmptyStr
class Severity(StrEnum):
ERROR = "error"
INFO = "info"
LaxSeverity = Annotated[Severity, Field(strict=False)]
HeaderFieldName = Literal[
"organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"
]
WARNING_CODE_PATTERN = r"^[A-Z][A-Z0-9_]*$"
class DocumentWarning(ContractModel):
"""`warning` схемы v2.0: предупреждение документа (уровень и привязка)."""
code: Annotated[str, Field(pattern=WARNING_CODE_PATTERN)]
severity: LaxSeverity
message: str
line_refs: OptionalNotNull[list[str]] = None
column_index: OptionalNotNull[IntLike] = None
field: OptionalNotNull[HeaderFieldName] = None
class FileWarning(DocumentWarning):
"""`fileWarning` схемы: предупреждение файла, `locator` — место в файле."""
locator: OptionalNotNull[str] = None
class Metrics(ContractModel): class Metrics(ContractModel):
"""Метрики обработки; все поля необязательны.""" """Метрики обработки; все поля необязательны."""
@ -241,7 +274,7 @@ class Metrics(ContractModel):
fields_total: OptionalNotNull[IntLike] = None fields_total: OptionalNotNull[IntLike] = None
fields_low_confidence: OptionalNotNull[IntLike] = None fields_low_confidence: OptionalNotNull[IntLike] = None
fields_unrecognized: OptionalNotNull[IntLike] = None fields_unrecognized: OptionalNotNull[IntLike] = None
warnings: OptionalNotNull[list[str]] = None warnings: OptionalNotNull[list[FileWarning]] = None
DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"] DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"]

View File

@ -1,4 +1,4 @@
"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v1.0. """Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v2.0.
`processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог. `processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог.
Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и

View File

@ -9,14 +9,22 @@ from typing import Annotated, Literal
from pydantic import Field from pydantic import Field
from src.contract.common import ContractModel, ContractUUID, IntLike, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime from src.contract.common import (
ContractModel,
ContractUUID,
IntLike,
NonEmptyStr,
RequestSchemaVersion,
S3Ref,
UtcDatetime,
)
from src.contract.subjects import Pipeline from src.contract.subjects import Pipeline
FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"]
class RecognitionRequest(ContractModel): class RecognitionRequest(ContractModel):
schema_version: SchemaVersion schema_version: RequestSchemaVersion
job_id: ContractUUID # recognition_job.id job_id: ContractUUID # recognition_job.id
file_id: ContractUUID # stored_file.id file_id: ContractUUID # stored_file.id
pipeline: Pipeline pipeline: Pipeline

View File

@ -1,4 +1,4 @@
"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0. """Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v2.0.
Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров; Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров;
маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`).
@ -8,7 +8,7 @@
from enum import StrEnum from enum import StrEnum
from typing import Annotated, Literal, Self from typing import Annotated, Literal, Self
from pydantic import Field, model_validator from pydantic import Field, field_validator, model_validator
from src.contract.common import ( from src.contract.common import (
Confidence, Confidence,
@ -16,11 +16,14 @@ from src.contract.common import (
ContractModel, ContractModel,
ContractUUID, ContractUUID,
DataType, DataType,
DocumentWarning,
HeaderFieldName,
HeaderVariant, HeaderVariant,
IntLike, IntLike,
LaxErrorCode, LaxErrorCode,
Metrics, Metrics,
Money, Money,
NonEmptyStr,
OptionalNotNull, OptionalNotNull,
SchemaVersion, SchemaVersion,
ServiceInfo, ServiceInfo,
@ -45,6 +48,9 @@ __all__ = [
] ]
DocumentStatus = Literal["done", "needs_check"]
class ResultStatus(StrEnum): class ResultStatus(StrEnum):
DONE = "done" DONE = "done"
NEEDS_CHECK = "needs_check" NEEDS_CHECK = "needs_check"
@ -117,6 +123,8 @@ class OsvChecks(ContractModel):
class OsvDocument(ContractModel): class OsvDocument(ContractModel):
document_type: Literal["OSV"] document_type: Literal["OSV"]
locator: str # «sheet:Лист_1» locator: str # «sheet:Лист_1»
status: DocumentStatus # done — контрольные суммы §1.4 листа сошлись, иначе needs_check
warnings: list[DocumentWarning] # все уровня info, код с префиксом OSV_
header: OsvHeader header: OsvHeader
group_schema: list[OsvGroup] group_schema: list[OsvGroup]
columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН
@ -124,75 +132,148 @@ class OsvDocument(ContractModel):
checks: OptionalNotNull[OsvChecks] = None checks: OptionalNotNull[OsvChecks] = None
# --- ББ / ОФР / прочее ---------------------------------------------------------------------------------------------- # --- ББ / ОФР / раскрытия -------------------------------------------------------------------------------------------
PeriodKind = Literal[
"absolute",
"relative:current",
"relative:prev_year_end",
"relative:prev_prev_year_end",
"relative:prev_period",
"none",
]
LineStatus = Literal["ok", "unreadable", "missing", "detail"]
CODE_PATTERN = r"^\d{4,5}$"
INN_PATTERN = r"^(\d{10}|\d{12})$"
NonNegativeInt = Annotated[IntLike, Field(ge=0)]
class StatementHeader(ContractModel): class StatementHeader(ContractModel):
form_name: OptionalNotNull[str] = None """Шапка формы: значение, сырой текст и уверенность каждого поля (схема v2.0, все поля необязательны)."""
form_name: str | None = None
organization_name: str | None = None organization_name: str | None = None
organization_name_text: str | None = None
organization_name_confidence: Confidence = None organization_name_confidence: Confidence = None
inn: str | None = None inn: Annotated[str, Field(pattern=INN_PATTERN)] | None = None
inn_text: str | None = None
inn_confidence: Confidence = None inn_confidence: Confidence = None
report_date_text: str | None = None
report_date: ContractDate | None = None report_date: ContractDate | None = None
report_date_text: str | None = None
report_date_confidence: Confidence = None
units: str | None = None # «тыс. руб.» units: str | None = None # «тыс. руб.»
units_text: str | None = None
units_confidence: Confidence = None
okud: str | None = None okud: str | None = None
okud_text: str | None = None
okud_confidence: Confidence = None
okpo: str | None = None okpo: str | None = None
okpo_text: str | None = None
okpo_confidence: Confidence = None
okopf: str | None = None okopf: str | None = None
okopf_text: str | None = None
okopf_confidence: Confidence = None
okfs: str | None = None okfs: str | None = None
okfs_text: str | None = None
okfs_confidence: Confidence = None
okei: str | None = None # код ОКЕИ единицы: 384 — тыс. руб., 383 — руб., 385 — млн руб.
okei_text: str | None = None
okei_confidence: Confidence = None
address: str | None = None address: str | None = None
address_text: str | None = None
address_confidence: Confidence = None
needs_review_fields: OptionalNotNull[list[HeaderFieldName]] = None
@field_validator("needs_review_fields")
@classmethod
def _unique_fields(cls, value: list[HeaderFieldName] | None) -> list[HeaderFieldName] | None:
if value is not None and len(set(value)) != len(value):
raise ValueError("needs_review_fields: поля не должны повторяться (uniqueItems)")
return value
class StatementColumn(ContractModel): class StatementColumn(ContractModel):
column_index: IntLike column_index: NonNegativeInt # номер столбца сетки кандидата шага 1; уникален в документе
label: str # «На 30 сентября 2025 г.» label: str # «На 30 сентября 2025 г.»
label_confidence: Confidence = None
period_date: ContractDate | None = None period_date: ContractDate | None = None
period_kind: PeriodKind
class StatementValue(ContractModel): class StatementValue(ContractModel):
column_index: IntLike column_index: IntLike
amount: Money = None amount: Money
string_value: str | None = None string_value: str | None = None
confidence: Confidence = None text: str | None
is_recognized: OptionalNotNull[bool] = None confidence: Confidence
is_recognized: bool
needs_review: bool
class StatementLine(ContractModel): class StatementLine(ContractModel):
line_code: str row_index: NonNegativeInt
ref: NonEmptyStr # положение в источнике; уникален в документе
line_status: LineStatus
line_code: Annotated[str, Field(pattern=CODE_PATTERN)] | None
line_code_text: str | None
line_code_confidence: Confidence = None line_code_confidence: Confidence = None
line_name: str line_name: str | None
line_name_confidence: Confidence = None line_name_confidence: Confidence = None
parent_ref: str | None = None
needs_review: bool
values: list[StatementValue] values: list[StatementValue]
@model_validator(mode="after")
def _check_status_rules(self) -> Self:
"""Буквально из `allOf` схемы: `line_code` задан ⇔ `line_status = ok`; у `detail` обязателен `parent_ref`."""
if (self.line_status == "ok") != (self.line_code is not None):
raise ValueError("line_code задан тогда и только тогда, когда line_status = ok")
if self.line_status == "detail" and self.parent_ref is None:
raise ValueError("у строки detail обязателен parent_ref")
return self
class FailedRelation(ContractModel): class FailedRelation(ContractModel):
"""Нарушенное соотношение §7 по одной колонке. """Нарушенное соотношение §7 по одной колонке.
`expected` — левая часть как в файле, `actual` — правая по строкам. `expected` — левая часть (цель), `actual` — правая по строкам ok; `null` — цели нет или все члены пусты.
""" """
column_index: IntLike column_index: IntLike
relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml relation: str # «1600 = 1100 + 1200»
codes: Annotated[list[Annotated[str, Field(pattern=CODE_PATTERN)]], Field(min_length=1)]
expected: Money expected: Money
actual: Money actual: Money
class StatementChecks(ContractModel): class StatementChecks(ContractModel):
"""Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" """Самопроверка формы общим кодом: пустые списки — «проверено, сошлось»."""
relations_failed: list[FailedRelation] = Field(default_factory=list) relations_failed: list[FailedRelation]
soft_relations_failed: list[FailedRelation] = Field(default_factory=list) soft_relations_failed: list[FailedRelation]
duplicate_line_codes: list[str] = Field(default_factory=list)
class StatementDocument(ContractModel): class StatementDocument(ContractModel):
document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]
locator: str # «pages:1-2» form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE
locator: str # «file:aep_3.json;table:0»
status: DocumentStatus
warnings: list[DocumentWarning]
header: StatementHeader header: StatementHeader
columns: list[StatementColumn] columns: list[StatementColumn]
lines: list[StatementLine] lines: list[StatementLine]
checks: OptionalNotNull[StatementChecks] = None checks: OptionalNotNull[StatementChecks] = None
@model_validator(mode="after")
def _check_checks_presence(self) -> Self:
"""`allOf` схемы: `checks` обязателен у BALANCE/INCOME со строками и запрещён у документа без строк."""
if self.lines:
if self.document_type in ("BALANCE", "INCOME") and self.checks is None:
raise ValueError(f"у {self.document_type} со строками обязателен checks")
elif self.checks is not None:
raise ValueError("у документа без строк checks не допускается")
return self
ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")]
@ -214,3 +295,10 @@ class RecognitionResult(ContractModel):
if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents: if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents:
raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") raise ValueError(f"при status = {self.status.value} список documents не может быть пустым")
return self return self
@model_validator(mode="after")
def _check_statements_version(self) -> Self:
"""Есть `StatementDocument` ⇒ `service.statements_version` задан (`allOf` схемы)."""
if any(isinstance(d, StatementDocument) for d in self.documents) and self.service.statements_version is None:
raise ValueError("при документах ББ/ОФР обязателен service.statements_version")
return self

View File

@ -38,6 +38,7 @@ from pydantic import ValidationError
from src.contract import ( from src.contract import (
CONTRACT_VERSION, CONTRACT_VERSION,
ErrorCode, ErrorCode,
FileWarning,
Metrics, Metrics,
RecognitionRequest, RecognitionRequest,
RecognitionResult, RecognitionResult,
@ -45,6 +46,7 @@ from src.contract import (
ResultStatus, ResultStatus,
S3Ref, S3Ref,
ServiceInfo, ServiceInfo,
Severity,
build_ack_message, build_ack_message,
build_done_message, build_done_message,
build_error_message, build_error_message,
@ -65,6 +67,7 @@ __all__ = [
"ObjectStorage", "ObjectStorage",
"ResultPublisher", "ResultPublisher",
"Worker", "Worker",
"build_result",
"raw_key", "raw_key",
"result_key", "result_key",
"utc_now", "utc_now",
@ -73,8 +76,8 @@ __all__ = [
# Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на # Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на
# символ (T10.1). Длинное сообщение обрезается с многоточием. # символ (T10.1). Длинное сообщение обрезается с многоточием.
MAX_ERROR_MESSAGE_CHARS = 4000 MAX_ERROR_MESSAGE_CHARS = 4000
# Предел `metrics.warnings` конверта (R-30): не больше MAX_WARNINGS строк по MAX_WARNING_CHARS символов — конверт # Предел `metrics.warnings` конверта (R-30, P5-10): не больше MAX_WARNINGS структур, `message` не длиннее
# остаётся далеко меньше лимита NATS (1 МБ) при любом числе ошибочных файлов; лишние строки сводятся в последнюю # MAX_WARNING_CHARS символов — конверт остаётся далеко меньше лимита NATS (1 МБ); при переполнении остаются все `error`
MAX_WARNINGS = 100 MAX_WARNINGS = 100
MAX_WARNING_CHARS = 1000 MAX_WARNING_CHARS = 1000
# Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов # Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов
@ -576,7 +579,7 @@ class Worker:
return self._error( return self._error(
request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key
) )
metrics = _outcome_metrics(outcome, key) metrics = _outcome_metrics(outcome)
if outcome.status is ResultStatus.ERROR: if outcome.status is ResultStatus.ERROR:
error = outcome.error error = outcome.error
if error is None: # исключено инвариантом NormalizeOutcome if error is None: # исключено инвариантом NormalizeOutcome
@ -584,17 +587,13 @@ class Worker:
return self._error(request, error.code, error.message, raw_s3_key=key, metrics=metrics) return self._error(request, error.code, error.message, raw_s3_key=key, metrics=metrics)
# 6. Успех: файл результата в S3, затем конверт с result_s3 # 6. Успех: файл результата в S3, затем конверт с result_s3
result = RecognitionResult.model_validate( result = build_result(
{ outcome,
"schema_version": CONTRACT_VERSION, job_id=request.job_id,
"job_id": request.job_id, file_id=request.file_id,
"file_id": request.file_id, service=self.service,
"service": self.service, raw_s3_key=key,
"status": outcome.status, metrics=metrics,
"raw_s3_key": key,
"documents": list(outcome.documents),
**({"metrics": metrics} if metrics is not None else {}),
}
) )
target = result_key(request.file_id, request.job_id) target = result_key(request.file_id, request.job_id)
try: try:
@ -667,22 +666,61 @@ class Worker:
await self._sleep(min(self._settings.SCAN_POLL_SECONDS, (deadline - now).total_seconds())) await self._sleep(min(self._settings.SCAN_POLL_SECONDS, (deadline - now).total_seconds()))
def _outcome_metrics(outcome: NormalizeOutcome, raw_s3_key: str) -> Metrics | None: def build_result( # noqa: PLR0913 - идентификаторы + исход + сервис; всё именованное
"""`metrics` конверта: метрики исхода + `warnings` исхода (R-25) в конце `metrics.warnings`. outcome: NormalizeOutcome,
*,
job_id: uuid.UUID,
file_id: uuid.UUID,
service: ServiceInfo,
raw_s3_key: str,
metrics: Metrics | None,
) -> RecognitionResult:
"""Файл результата по исходу нормализации: `schema_version = CONTRACT_VERSION`, `service` с `statements_version`
исхода (P5-11; в конверт он не пишется)."""
result_service = (
service.model_copy(update={"statements_version": outcome.statements_version})
if outcome.statements_version is not None
else service
)
return RecognitionResult.model_validate(
{
"schema_version": CONTRACT_VERSION,
"job_id": job_id,
"file_id": file_id,
"service": result_service,
"status": outcome.status,
"raw_s3_key": raw_s3_key,
"documents": list(outcome.documents),
**({"metrics": metrics} if metrics is not None else {}),
}
)
Пустые `warnings` опускаются. Строки безопасны для конверта (`clean_text`), их не больше `MAX_WARNINGS` по
`MAX_WARNING_CHARS` символов; лишние сводятся в последнюю строку со ссылкой на сырой ответ (R-30). def _limit_warnings(warnings: list[FileWarning]) -> list[FileWarning]:
"""Не больше `MAX_WARNINGS`: все `error`, затем `info` по порядку; итоговый порядок — исходный (P5-10)."""
if len(warnings) <= MAX_WARNINGS:
return warnings
errors = [i for i, w in enumerate(warnings) if w.severity is Severity.ERROR]
infos = [i for i, w in enumerate(warnings) if w.severity is not Severity.ERROR]
keep = sorted([*errors, *infos][:MAX_WARNINGS])
return [warnings[i] for i in keep]
def _outcome_metrics(outcome: NormalizeOutcome) -> Metrics | None:
"""`metrics` конверта и файла результата: метрики исхода + `warnings` исхода (R-25, P5-10).
Пустые `warnings` опускаются. `message` безопасен для конверта (`clean_text`), структур не больше `MAX_WARNINGS`;
при переполнении остаются все `error`, затем `info` (строки-сводки нет). Тот же список — в конверте и в файле.
""" """
metrics = outcome.metrics metrics = outcome.metrics
lines = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings] warnings = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings]
if not lines: if not warnings:
return metrics return metrics
if len(lines) > MAX_WARNINGS: cleaned = [
hidden = len(lines) - (MAX_WARNINGS - 1) w.model_copy(update={"message": clean_text(w.message, MAX_WARNING_CHARS)}) for w in _limit_warnings(warnings)
lines = [*lines[: MAX_WARNINGS - 1], f"ещё предупреждений: {hidden}; все ошибки файлов — в {raw_s3_key}"] ]
warnings = [clean_text(line, MAX_WARNING_CHARS) for line in lines] base = metrics.model_dump(exclude_none=True, exclude={"warnings"}) if metrics is not None else {}
base = metrics.model_dump(exclude_none=True) if metrics is not None else {} return Metrics.model_validate({**base, "warnings": cleaned})
return Metrics.model_validate({**base, "warnings": warnings})
def _is_uuid(value: object) -> bool: def _is_uuid(value: object) -> bool:

View File

@ -1,23 +1,20 @@
"""Нормализатор ответа ИС Сканирование (D-5, R-12, R-25, решения №57, №58, №60; slice-0-skeleton). """Нормализатор ответа ИС Сканирование (D-5, R-12, R-25, решения №57, №58, №60; контракт 2.0, #46).
Нормализатор получает склеенный ответ ИС (`raw: object`, D-4): `{"job_id", "files": [{"name", "response", Нормализатор получает склеенный ответ ИС (`raw: object`, D-4): `{"job_id", "files": [{"name", "response",
"parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой "parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой
(`src.gateway.merge.merge_answers`). Результат — `NormalizeOutcome`, из которого воркер собирает конверт (`src.gateway.merge.merge_answers`). Результат — `NormalizeOutcome`, из которого воркер собирает конверт
(`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`. (`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`
(`src.core.worker.build_result`).
Срез 3a: настоящая нормализация — `OcrNormalizer` (`src/normalizer/ocr/`, тесты — Настоящая нормализация — `OcrNormalizer` (`src/normalizer/ocr/`, общий код `arm_statements`); `NullNormalizer`
`tests/unit/normalizer/test_ocr_normalizer.py`); `NullNormalizer` остаётся для тестов воркера Среза 0. остаётся для тестов воркера (P5-19): сводит ошибки по файлам (успех файла — `error.code == ""`), при хотя бы одном
успешном файле отвечает `NOT_IMPLEMENTED`; ошибочные файлы — предупреждения `JSON_FAILED` файла.
В Срезе 0 единственная реализация — `NullNormalizer`: сводит ошибки по файлам (успех файла — `error.code == ""`),
при хотя бы одном успешном файле отвечает `NOT_IMPLEMENTED`. Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`)
появится в Срезе 3 новой реализацией протокола `Normalizer`.
""" """
from typing import Protocol, Self, runtime_checkable from typing import Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, model_validator from arm_statements import default_rules, warning_to_contract
from src.contract import ErrorCode, FileWarning, MessageError, ResultStatus
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument
from src.normalizer.files import ( from src.normalizer.files import (
BAD_ENTRY_MESSAGE, BAD_ENTRY_MESSAGE,
NO_FILES_MESSAGE, NO_FILES_MESSAGE,
@ -27,6 +24,9 @@ from src.normalizer.files import (
FileError, FileError,
file_error, file_error,
) )
from src.normalizer.ocr import OcrNormalizer
from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE
from src.normalizer.outcome import NormalizeOutcome
__all__ = [ __all__ = [
"BAD_ENTRY_MESSAGE", "BAD_ENTRY_MESSAGE",
@ -45,38 +45,6 @@ __all__ = [
NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3" NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3"
class NormalizeOutcome(BaseModel):
"""Итог нормализации: статус, документы, ошибка, метрики, предупреждения.
`warnings` — строки для `metrics.warnings` конверта (R-25; внутренний тип, контракт не меняется): воркер
опускает поле, если список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check
`documents` непусты (как в схеме файла результата), для error — пусты. Статус `processing` исключён: он не бывает
итогом.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
status: ResultStatus
documents: list[StatementDocument] = Field(default_factory=list)
error: MessageError | None = None
metrics: Metrics | None = None
warnings: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def _check_invariants(self) -> Self:
if self.status is ResultStatus.ERROR:
if self.error is None:
raise ValueError("status=error требует error")
if self.documents:
raise ValueError("status=error не может содержать documents")
else:
if self.error is not None:
raise ValueError(f"status={self.status.value} не может содержать error")
if not self.documents:
raise ValueError(f"status={self.status.value} требует непустой documents")
return self
@runtime_checkable @runtime_checkable
class Normalizer(Protocol): class Normalizer(Protocol):
"""Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`. """Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`.
@ -90,11 +58,12 @@ class Normalizer(Protocol):
class NullNormalizer: class NullNormalizer:
"""Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет. """Заглушка (D-5, P5-19): сводка ошибок по файлам склейки, нормализации нет.
Все файлы ошибочны — `status = error` с кодом и сообщением первого ошибочного файла (порядок `files` — по Все файлы ошибочны — `status = error` с кодом и сообщением первого ошибочного файла (порядок `files` — по
возрастанию имени, D-4); есть хотя бы один успешный — `NOT_IMPLEMENTED`. В обоих случаях каждый ошибочный файл — возрастанию имени, D-4); есть хотя бы один успешный — `NOT_IMPLEMENTED`. В обоих случаях каждый ошибочный файл —
строка `warnings` `<имя>: <код ИС>: <сообщение>`. Структура склейки нарушена — `INVALID_FORMAT`. предупреждение `JSON_FAILED` (`message` — `<код ИС>: <сообщение>`, `locator` — `file:<имя>`). Структура склейки
нарушена — `INVALID_FORMAT`.
""" """
def normalize(self, raw: object) -> NormalizeOutcome: def normalize(self, raw: object) -> NormalizeOutcome:
@ -111,21 +80,18 @@ class NullNormalizer:
succeeded += 1 succeeded += 1
else: else:
errors.append(error) errors.append(error)
warnings = [error.warning for error in errors] rules = default_rules()
warnings = [
FileWarning.model_validate(warning_to_contract(error.to_warning(rules), file_level=True))
for error in errors
]
if succeeded: if succeeded:
return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE, warnings) return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE, warnings)
first = errors[0] first = errors[0]
return _error_outcome(first.code, first.summary, warnings) return _error_outcome(first.code, first.summary, warnings)
def _error_outcome(code: ErrorCode, message: str, warnings: list[str] | None = None) -> NormalizeOutcome: def _error_outcome(code: ErrorCode, message: str, warnings: list[FileWarning] | None = None) -> NormalizeOutcome:
return NormalizeOutcome( return NormalizeOutcome(
status=ResultStatus.ERROR, error=MessageError(code=code, message=message), warnings=warnings or [] status=ResultStatus.ERROR, error=MessageError(code=code, message=message), warnings=warnings or []
) )
_file_error = file_error # прежнее имя: его берёт старый OcrNormalizer до задачи 4
# в конце модуля: `OcrNormalizer` берёт `NormalizeOutcome` и помощники ошибок из этого модуля лениво (цикл импортов)
from src.normalizer.ocr import NO_TABLES_MESSAGE, OcrNormalizer # noqa: E402

View File

@ -14,19 +14,14 @@ BAD_ENTRY_MESSAGE = "элемент files не соответствует стр
@dataclass(frozen=True) @dataclass(frozen=True)
class FileError: class FileError:
"""Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`.""" """Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для предупреждения `JSON_FAILED`."""
name: str name: str
code: ErrorCode # error.code сводки, если этот файл — первый ошибочный code: ErrorCode # error.code сводки, если этот файл — первый ошибочный
source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT) source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT)
message: str # сообщение файла: строка warnings message: str # сообщение файла: часть message предупреждения
summary: str # error.message сводки, если этот файл — первый ошибочный summary: str # error.message сводки, если этот файл — первый ошибочный
@property
def warning(self) -> str:
"""Строка `warnings` прежнего формата (до задачи 4)."""
return f"{self.name}: {self.source_code}: {self.message}"
def to_warning(self, rules: Rules) -> StatementWarning: def to_warning(self, rules: Rules) -> StatementWarning:
"""Предупреждение `JSON_FAILED` пакета: уровень задаёт словарь правил.""" """Предупреждение `JSON_FAILED` пакета: уровень задаёт словарь правил."""
return make_warning( return make_warning(

View File

@ -1,103 +1,60 @@
"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document` """Нормализатор ответа ИС Сканирование на общем коде `arm_statements` (спека scan-gateway, контракт 2.0).
(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены
дословно; DB-обвязка не переносится."""
from collections.abc import Mapping Обход набора json — `answer_set.normalize_answer_set` (адаптер шага 1, `normalize_candidate` пакета на каждую таблицу);
from typing import TYPE_CHECKING, Any здесь результат пакета переводится в модели контракта функциями пакета `to_contract` / `warning_to_contract` (P5-15):
своего уплощения шапки и отбора ключей шлюз не пишет.
"""
import src.normalizer as normalizer_package # цикл: `src.normalizer` импортирует этот модуль в конце своего `__init__` from arm_statements import (
from src.contract import ErrorCode, Metrics, ResultStatus, StatementDocument EnvelopeStatus,
from src.normalizer.ocr.document import build_document Rules,
from src.normalizer.ocr.header import parse_file_header default_rules,
from src.normalizer.rules import OcrRules statements_version,
to_contract,
warning_to_contract,
)
from src.contract import FileWarning, MessageError, Metrics, ResultStatus, StatementDocument
from src.normalizer.ocr.answer_set import normalize_answer_set
from src.normalizer.outcome import NormalizeOutcome
if TYPE_CHECKING: __all__ = ["OcrNormalizer"]
from src.normalizer import NormalizeOutcome
__all__ = ["NO_TABLES_MESSAGE", "OcrNormalizer"]
NO_TABLES_MESSAGE = "ни одной таблицы не разобрано"
NO_CODE_COLUMN_MESSAGE = "нет шапки и столбца кода"
FULL_CONFIDENCE = 100.0
class OcrNormalizer: class OcrNormalizer:
"""Нормализатор ответа ИС Сканирование: ошибочные файлы — как в Срезе 0, из успешных собираются документы по """Нормализатор ответа ИС: статус и документы определяет пакет, шлюз переводит их в контракт 2.0.
одному на таблицу; статус — `done` только когда всё распознано и ни один файл набора не ошибочен (D-6, №28)."""
def __init__(self, rules: OcrRules) -> None: `version` — версия копии `arm_statements` (по умолчанию — из файла `VERSION` копии, P5-11); нет версии —
self._rules = rules сервис не стартует.
"""
def normalize(self, raw: object) -> "NormalizeOutcome": def __init__(self, rules: Rules | None = None, *, version: str | None = None) -> None:
# имена пакета берутся при вызове: при импорте этого модуля `src.normalizer` ещё не дочитан до конца self._rules = rules if rules is not None else default_rules()
outcome_cls = normalizer_package.NormalizeOutcome resolved = version if version is not None else statements_version()
error_outcome = normalizer_package._error_outcome if resolved is None:
file_error = normalizer_package._file_error raise ValueError("копия arm_statements без VERSION: разложите её sync-statements")
self._version = resolved
if not isinstance(raw, dict) or not isinstance(raw.get("files"), list): @property
return error_outcome(ErrorCode.INVALID_FORMAT, normalizer_package.NOT_MERGED_MESSAGE) def statements_version(self) -> str:
files: list[object] = raw["files"] return self._version
if not files:
return error_outcome(ErrorCode.INVALID_FORMAT, normalizer_package.NO_FILES_MESSAGE)
warnings: list[str] = []
documents: list[StatementDocument] = []
fully_recognized = True
succeeded = 0
failed = 0
first_error = None
for index, entry in enumerate(files):
error = file_error(index, entry)
if error is not None:
warnings.append(error.warning)
first_error = first_error or error
failed += 1
continue
succeeded += 1
assert isinstance(entry, dict) # проверено _file_error
file_documents, file_warnings, file_recognized = self._normalize_file(entry)
documents.extend(file_documents)
warnings.extend(file_warnings)
fully_recognized = fully_recognized and file_recognized
if not succeeded and first_error is not None:
return error_outcome(first_error.code, first_error.summary, warnings)
if not documents:
return error_outcome(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE, warnings)
# №28: потерянный ошибочный файл (например, ОФР при успешном балансе) не остаётся под `done`
status = ResultStatus.DONE if fully_recognized and not failed else ResultStatus.NEEDS_CHECK
rows = sum(len(document.lines) for document in documents)
return outcome_cls(status=status, documents=documents, metrics=Metrics(rows=rows), warnings=warnings)
def _normalize_file(self, entry: dict[str, Any]) -> tuple[list[StatementDocument], list[str], bool]: def normalize(self, raw: object) -> NormalizeOutcome:
"""Документы одного успешного файла, предупреждения и признак «всё распознано».""" result = normalize_answer_set(raw, self._rules)
name = str(entry["name"]) warnings = [FileWarning.model_validate(warning_to_contract(w, file_level=True)) for w in result.file_warnings]
response = entry["response"] if result.status is EnvelopeStatus.ERROR:
data_ocr = response.get("data_ocr") if isinstance(response, dict) else None error = result.error
if not isinstance(data_ocr, Mapping): if error is None: # исключено инвариантом AnswerSetResult
return [], [f"{name}: data_ocr отсутствует или не объект"], False raise ValueError("AnswerSetResult со status=error без error")
header = parse_file_header(data_ocr, self._rules.lookalikes) return NormalizeOutcome(
warnings: list[str] = [] status=ResultStatus.ERROR,
if header.inn is None: error=MessageError(code=error.code, message=error.message),
warnings.append(f"{name}: ИНН не распознан") warnings=warnings,
elif header.inn_confidence != FULL_CONFIDENCE: )
warnings.append(f"{name}: ИНН распознан неуверенно") documents = [StatementDocument.model_validate(to_contract(d)) for d in result.documents]
if header.report_date is None: return NormalizeOutcome(
warnings.append(f"{name}: дата отчёта не разобрана") status=ResultStatus(result.status.value),
tables = data_ocr.get("table_structure") documents=documents,
documents: list[StatementDocument] = [] metrics=Metrics(rows=sum(len(d.lines) for d in documents)),
recognized = True warnings=warnings,
for index, table in enumerate(tables if isinstance(tables, list) else []): statements_version=self._version,
prefix = f"{name}: table:{index}: " )
try:
built = build_document(table if isinstance(table, Mapping) else {}, index, name, header, self._rules)
except Exception as exc: # §11.1 п. 9: ошибка одной таблицы не роняет файл
warnings.append(f"{prefix}BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}")
recognized = False
continue
if built is None:
warnings.append(prefix + NO_CODE_COLUMN_MESSAGE)
recognized = False
continue
documents.append(built.document)
warnings.extend(prefix + warning for warning in built.warnings)
recognized = recognized and built.fully_recognized
return documents, warnings, recognized and not warnings

View File

@ -1,264 +0,0 @@
"""Сборка `statementDocument` v1.2 из одной таблицы `data_ocr.table_structure[i]` (spec «Нормализация ответа ИС»,
D-6, D-7): тип по label, при расхождении с единодушными кодами — по кодам (#30), колонки из раскладки, строки только
с кодом, значения с `is_recognized`, имена строк из справочника редакции."""
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Literal
from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue
from src.normalizer.ocr.amounts import is_blank, parse_amount
from src.normalizer.ocr.dates import is_date_sized, parse_date
from src.normalizer.ocr.header import FileHeader
from src.normalizer.ocr.layout import Cell, cell_text
from src.normalizer.ocr.rows import (
ParsedRow,
ParsedTable,
RowKind,
parse_table_rows,
table_type_by_codes,
table_type_by_unanimous_codes,
)
from src.normalizer.ocr.text import clean_value, collapse_whitespace, fix_lookalikes, normalize_header
from src.normalizer.rules import Edition, OcrRules
__all__ = ["BuiltDocument", "build_document"]
FULL_CONFIDENCE = 100.0
CAPPED_CONFIDENCE = 99.0
MONEY_DECIMAL_PLACES = 2
DocumentType = Literal["BALANCE", "INCOME", "OTHER"]
@dataclass(frozen=True)
class BuiltDocument:
document: StatementDocument
warnings: list[str]
fully_recognized: bool
edition_id: str | None
@dataclass
class _Build:
"""Накопитель предупреждений и признака «всё распознано» на время сборки одного документа."""
warnings: list[str]
fully_recognized: bool = True
def warn(self, text: str) -> None:
self.warnings.append(text)
self.fully_recognized = False
def _label_text(table: Mapping[str, object]) -> str:
label = table.get("label")
form_name = label.get("form_name") if isinstance(label, Mapping) else None
return collapse_whitespace(form_name) if isinstance(form_name, str) else ""
def _type_by_label(label: str, rules: OcrRules) -> DocumentType | None:
normalized = normalize_header(label)
for needle, kind in rules.labels.items():
if needle in normalized:
return kind
return None
def _resolve_type(label: str, parsed: ParsedTable, rules: OcrRules, build: _Build) -> DocumentType:
"""Тип документа: label и коды согласны → label; расходятся при единодушных кодах → коды (#30), при смешанных →
label; label не сопоставлен → коды по большинству; иначе OTHER. Любое расхождение — предупреждение."""
by_label = _type_by_label(label, rules)
by_codes = table_type_by_codes(parsed)
if by_label is not None:
if by_codes is None or by_codes == by_label:
return by_label
unanimous = table_type_by_unanimous_codes(parsed)
if unanimous is not None:
build.warn(f"тип по label ({label}) не совпал с кодами: взят {unanimous}")
return unanimous
build.warn(f"тип по кодам ({by_codes}) не совпал с label ({label})")
return by_label
if by_codes is not None:
build.warn("тип определён по кодам")
return by_codes
build.warn("тип не определён: OTHER")
return "OTHER"
def _columns(parsed: ParsedTable, lookalikes: Mapping[str, str], build: _Build) -> list[StatementColumn]:
"""Колонки значений первой шапки: `label` — текст ячейки шапки, `period_date` — `parse_date`."""
header = parsed.rows[0]
columns: list[StatementColumn] = []
for position, source_idx in enumerate(header.layout.value_idxs):
cell = header.cells.get(source_idx)
label = collapse_whitespace(cell.value) if cell is not None else ""
# решение №31: подпись длиннее даты не разбирается и не проходит `fix_lookalikes`
period = parse_date(fix_lookalikes(label, lookalikes)) if label and is_date_sized(label) else None
if period is None and not parsed.synthetic_header:
build.warn(f"колонка {position} без даты: {label}")
columns.append(StatementColumn(column_index=position, label=label, period_date=period))
if parsed.synthetic_header:
build.warn("шапка синтезирована: колонки без дат")
if not columns:
build.warn("нет колонок значений")
return columns
def _latest_period(columns: list[StatementColumn]) -> date | None:
dates = [column.period_date for column in columns if column.period_date is not None]
return max(dates) if dates else None
def _choose_edition(
header: FileHeader, columns: list[StatementColumn], codes: list[str], rules: OcrRules, build: _Build
) -> Edition | None:
edition = rules.edition_for(header.report_date) or rules.edition_for(_latest_period(columns))
if edition is None:
edition = rules.edition_by_codes(codes)
if edition is None:
build.warn("редакция не определена")
return None
build.warn(f"редакция определена по кодам: {edition.id}")
if not edition.lines:
build.warn(f"редакция {edition.id} не заполнена")
return edition
def _decimal_places(amount: Decimal) -> int:
exponent = amount.as_tuple().exponent
return -exponent if isinstance(exponent, int) and exponent < 0 else 0
def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValue:
if cell is None:
build.fully_recognized = False
return StatementValue(
column_index=column_index, amount=None, string_value=None, confidence=0, is_recognized=False
)
parsed = parse_amount(cell.value)
text = collapse_whitespace(cell.value)
amount = parsed.amount
verified = parsed.verified
if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES:
# решение №30: `Money` контракта — не больше двух знаков; «1,234» теперь = 1234 (правило тысяч в parse_amount,
# AURORA-1405), а «0,125», «12.3456» и т. п. — артефакт OCR: сумма не принимается, текст остаётся в
# string_value, иначе ValidationError ронял бы всю таблицу
amount, verified = None, False
# DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана
string_value = text if amount is None and text and not is_blank(text) else None
recognized = cell.confidence == FULL_CONFIDENCE and verified
if not recognized:
build.fully_recognized = False
return StatementValue(
column_index=column_index,
amount=amount,
string_value=string_value,
confidence=cell.confidence,
is_recognized=recognized,
)
def _line(row: ParsedRow, column_count: int, edition: Edition | None, rules: OcrRules, build: _Build) -> StatementLine:
row_cells = row.cells
code_cell = row_cells[row.layout.code_idx]
code = clean_value(code_cell.value)
code_clean = code == code_cell.value.strip()
label_idx = row.layout.label_idx
name_cell = row_cells.get(label_idx) if label_idx is not None else None
if edition is not None and code in edition.lines:
name = edition.lines[code]
# spec: уверенность кода и словарного имени — confidence ячейки, если текст чистый и код в справочнике
code_confidence = code_cell.confidence if code_clean else min(code_cell.confidence, CAPPED_CONFIDENCE)
name_confidence = code_confidence
else:
# пустая редакция уже даёт одно «редакция <id> не заполнена» — по строке не предупреждаем
elsewhere = rules.editions_with(code) if edition is not None and edition.lines else []
if elsewhere:
build.warn(f"код {code} из редакции {', '.join(elsewhere)}")
name = collapse_whitespace(name_cell.value) if name_cell is not None else ""
# кода нет в выбранной редакции: «иначе min(confidence, 99)» — всегда, имя из OCR со своим confidence
code_confidence = min(code_cell.confidence, CAPPED_CONFIDENCE)
name_confidence = name_cell.confidence if name_cell is not None else 0.0
if code_confidence < FULL_CONFIDENCE or name_confidence < FULL_CONFIDENCE:
build.fully_recognized = False
# values — по позиции колонки документа (R-3): лишние индексы раскладки строки отбрасываются, недостающие —
# «ячейки нет»
value_idxs = row.layout.value_idxs
values = [
_value(row_cells.get(value_idxs[position]) if position < len(value_idxs) else None, position, build)
for position in range(column_count)
]
return StatementLine(
line_code=code,
line_code_confidence=code_confidence,
line_name=name,
line_name_confidence=name_confidence,
values=values,
)
def _statement_header(label: str, header: FileHeader, build: _Build) -> StatementHeader:
if header.inn_confidence != FULL_CONFIDENCE or header.organization_confidence != FULL_CONFIDENCE:
build.fully_recognized = False
if header.report_date is None or not header.report_date_recognized:
build.fully_recognized = False
# form_name в схеме без null: нет label — поле не передаётся (явный None контрактная модель отвергает)
form_name = {"form_name": label} if label else {}
return StatementHeader(
**form_name,
organization_name=header.organization_name,
organization_name_confidence=header.organization_confidence,
inn=header.inn,
inn_confidence=header.inn_confidence,
report_date_text=header.report_date_text,
report_date=header.report_date,
units=header.units,
)
def build_document(
table: Mapping[str, object], index: int, file_name: str, header: FileHeader, rules: OcrRules
) -> BuiltDocument | None:
rows = table.get("rows")
if not isinstance(rows, list):
return None
parsed = parse_table_rows(rows, rules.known_codes())
if parsed is None or len(parsed.rows) < 2: # noqa: PLR2004 — шапка и хотя бы одна строка данных
return None
build = _Build(warnings=[])
label = _label_text(table)
document_type = _resolve_type(label, parsed, rules, build)
columns = _columns(parsed, rules.lookalikes, build)
data_rows = [row for row in parsed.rows[1:] if row.kind is not RowKind.HEADER]
if any(len(row.layout.value_idxs) != len(columns) for row in data_rows):
build.warn("повторная шапка изменила число колонок значений") # одно предупреждение на таблицу
# №29: строки, отброшенные разбором молча (мусорный код; пустой код при наличии сумм) — по одному предупреждению
if parsed.skipped_non_numeric_code:
build.warn(f"строк с нечисловым кодом пропущено: {parsed.skipped_non_numeric_code}")
if parsed.skipped_no_code_with_amounts:
build.warn(f"строк без кода с суммами пропущено: {parsed.skipped_no_code_with_amounts}")
# OQ-156: склеенные строки ИС разлеплены — разбивку подтверждает аналитик
if parsed.split_merged_codes:
build.warn(f"строк с несколькими кодами разлеплено: {parsed.split_merged_codes}")
codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows]
edition = _choose_edition(header, columns, codes, rules, build)
lines: list[StatementLine] = []
seen: set[str] = set()
for row in data_rows:
code = clean_value(cell_text(row.cells, row.layout.code_idx))
if code in seen: # решение №15: DBML уникален по (meta_id, line_code), как §12 п. 5 у Excel-пути
build.warn(f"код {code} повторяется: оставлена первая строка")
continue
seen.add(code)
lines.append(_line(row, len(columns), edition, rules, build))
statement_header = _statement_header(label, header, build)
document = StatementDocument(
document_type=document_type,
locator=f"file:{file_name};table:{index}",
header=statement_header,
columns=columns,
lines=lines,
)
return BuiltDocument(document, build.warnings, build.fully_recognized, edition.id if edition else None)

View File

@ -0,0 +1,44 @@
"""Итог нормализации `NormalizeOutcome` (отдельный модуль: им пользуются и `src.normalizer`, и `OcrNormalizer`)."""
from typing import Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from src.contract import FileWarning, MessageError, Metrics, ResultStatus, StatementDocument
__all__ = ["NormalizeOutcome"]
class NormalizeOutcome(BaseModel):
"""Итог нормализации: статус, документы, ошибка, метрики, предупреждения файла.
`warnings` — структуры `metrics.warnings` конверта и файла результата (P5-10): воркер опускает поле, если
список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check `documents` непусты (как в
схеме файла результата) и `statements_version` задан (P5-11), для error — документы пусты. Статус `processing`
исключён: он не бывает итогом.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
status: ResultStatus
documents: list[StatementDocument] = Field(default_factory=list)
error: MessageError | None = None
metrics: Metrics | None = None
warnings: list[FileWarning] = Field(default_factory=list)
statements_version: str | None = None # версия копии arm_statements; нужна, когда есть документы
@model_validator(mode="after")
def _check_invariants(self) -> Self:
if self.status is ResultStatus.ERROR:
if self.error is None:
raise ValueError("status=error требует error")
if self.documents:
raise ValueError("status=error не может содержать documents")
else:
if self.error is not None:
raise ValueError(f"status={self.status.value} не может содержать error")
if not self.documents:
raise ValueError(f"status={self.status.value} требует непустой documents")
if self.documents and self.statements_version is None:
raise ValueError("documents требуют statements_version")
return self

View File

@ -9,15 +9,16 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from arm_statements import default_rules
from src.gateway.hotfolder import AnswerFile from src.gateway.hotfolder import AnswerFile
from src.gateway.merge import merge_answers from src.gateway.merge import merge_answers
from src.normalizer import NormalizeOutcome, OcrNormalizer from src.normalizer import NormalizeOutcome, OcrNormalizer
from src.normalizer.rules import load_rules
API_DIR = Path(__file__).resolve().parents[1] API_DIR = Path(__file__).resolve().parents[1]
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
GOLDEN_DIR = REAL_FIXTURES / "expected_normalize" GOLDEN_DIR = REAL_FIXTURES / "expected_normalize"
JOB = "3f1c2b7a-0000-4000-8000-000000000001" JOB = "3f1c2b7a-0000-4000-8000-000000000001"
GOLDEN_VERSION = "0" * 40 # фиксированная версия копии: golden от раскладки копии не зависит
def golden_cases() -> list[str]: def golden_cases() -> list[str]:
@ -44,7 +45,7 @@ def real_set(name: str) -> dict[str, Any]:
def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]: def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]:
return { return {
"status": outcome.status.value, "status": outcome.status.value,
"warnings": list(outcome.warnings), "warnings": [w.to_wire() for w in outcome.warnings],
"metrics": outcome.metrics.model_dump(mode="json", exclude_none=True) if outcome.metrics else None, "metrics": outcome.metrics.model_dump(mode="json", exclude_none=True) if outcome.metrics else None,
"error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None, "error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None,
"documents": [doc.model_dump(mode="json", exclude_none=True) for doc in outcome.documents], "documents": [doc.model_dump(mode="json", exclude_none=True) for doc in outcome.documents],
@ -55,10 +56,10 @@ def main() -> int:
if not REAL_FIXTURES.is_dir(): if not REAL_FIXTURES.is_dir():
print(f"нет каталога {REAL_FIXTURES}", file=sys.stderr) print(f"нет каталога {REAL_FIXTURES}", file=sys.stderr)
return 2 return 2
rules = load_rules(API_DIR / "rules") rules = default_rules()
GOLDEN_DIR.mkdir(exist_ok=True) GOLDEN_DIR.mkdir(exist_ok=True)
for name in golden_cases(): for name in golden_cases():
payload = outcome_to_json(OcrNormalizer(rules).normalize(real_set(name))) payload = outcome_to_json(OcrNormalizer(rules, version=GOLDEN_VERSION).normalize(real_set(name)))
(GOLDEN_DIR / f"{name}.json").write_text( (GOLDEN_DIR / f"{name}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8" json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8"
) )

View File

@ -0,0 +1,88 @@
"""Граница «пакет arm_statements → модели контракта шлюза»: `dict` пакета проходит модели и схему v2.0.
Правила перевода проверяет пакет; здесь — что его результат принимают модели шлюза и JSON Schema `result`.
"""
import json
import uuid
import pytest
import src.normalizer.ocr as ocr_module
from arm_statements import WarningCode, default_rules, make_warning, warning_to_contract
from src.contract import ErrorCode, FileWarning, ServiceInfo, ServiceName
from src.core.worker import build_result
from src.normalizer import NullNormalizer, OcrNormalizer
from tests.unit.normalizer.ocr_builders import BAD, BALANCE, INCOME, merged, ocr_table, response
from tests.unit.test_contract import VALIDATORS
SERVICE = ServiceInfo(name=ServiceName.SCAN_GATEWAY, version="test")
def test_mapped_document_passes_schema_v2() -> None:
outcome = OcrNormalizer(default_rules(), version="abc1234").normalize(
merged(
(
"j.json",
response(
ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)
),
)
)
)
result = build_result(
outcome, job_id=uuid.uuid4(), file_id=uuid.uuid4(), service=SERVICE, raw_s3_key="raw/x.json", metrics=None
)
payload = result.to_wire()
VALIDATORS["result"].validate(payload)
assert payload["schema_version"] == "2.0" and payload["service"]["statements_version"] == "abc1234"
head = payload["documents"][0]["header"]
assert (head["form_name"], head["inn"], head["inn_text"], head["inn_confidence"]) == (
"Бухгалтерский баланс",
"7700000000",
"7700000000",
100.0,
)
assert "column_key" not in json.dumps(payload)
def test_package_warning_fits_file_warning_model() -> None:
w = make_warning(WarningCode.ROW_SPLIT, "разлеплено", default_rules(), line_refs=("row:3#1", "row:3#2"))
assert FileWarning.model_validate(warning_to_contract(w, file_level=True)).to_wire() == {
"code": "ROW_SPLIT",
"severity": "info",
"message": "разлеплено",
"line_refs": ["row:3#1", "row:3#2"],
}
def test_empty_target_relation_passes_model() -> None:
rows = [
("Наименование показателя", "Код", "На 31 декабря 2024 г."),
("Запасы", "1210", "60"),
("Итого II", "1200", "60"),
]
outcome = OcrNormalizer(default_rules(), version="abc1234").normalize(
merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows))))
)
failed = outcome.documents[0].checks.relations_failed # type: ignore[union-attr]
assert any(f.relation == "1600 = 1100 + 1200" and f.expected is None for f in failed)
def test_null_normalizer_structured_json_failed() -> None:
outcome = NullNormalizer().normalize(merged(("j.json", response()), ("j_ofr.json", response(error=BAD))))
assert outcome.error is not None and outcome.error.code is ErrorCode.NOT_IMPLEMENTED
assert [w.to_wire() for w in outcome.warnings] == [
{
"code": "JSON_FAILED",
"severity": "error",
"message": "INVALID_DOCUMENT: Не удалось определить тип документа.",
"locator": "file:j_ofr.json",
}
]
def test_normalizer_requires_statements_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ocr_module, "statements_version", lambda path=None: None)
with pytest.raises(ValueError, match="без VERSION"):
OcrNormalizer(default_rules())

View File

@ -1,678 +0,0 @@
"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7)."""
import json
import time
from dataclasses import replace
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from src.normalizer.ocr.document import BuiltDocument, build_document
from src.normalizer.ocr.header import FileHeader
from src.normalizer.rules import Edition, OcrRules, load_rules
API_DIR = Path(__file__).resolve().parents[3]
RULES = load_rules(API_DIR / "rules")
HEADER_2024 = FileHeader(
"ООО Ромашка", 100.0, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", date(2024, 12, 31), True
)
def _row(index: int, *cells: tuple[int, str, float]) -> dict:
return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]}
def _table(label: str, *rows: dict) -> dict:
return {"label": {"form_name": label, "confidence": 100}, "rows": list(rows)}
CLEAN_BALANCE = _table(
"Бухгалтерский баланс",
_row(
0,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
),
_row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1 234", 100), (3, "(56)", 100)),
_row(2, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1 234", 100), (3, "-", 100)),
)
def _build(table: dict, header: FileHeader = HEADER_2024, index: int = 0) -> BuiltDocument:
built = build_document(table, index, "job.json", header, RULES)
assert built is not None
return built
class TestCleanTable:
def test_document_fields(self) -> None:
built = _build(CLEAN_BALANCE)
doc = built.document
assert doc.document_type == "BALANCE"
assert doc.locator == "file:job.json;table:0"
assert doc.header.form_name == "Бухгалтерский баланс"
assert doc.header.inn == "7700000000" and doc.header.inn_confidence == 100
assert doc.header.report_date == date(2024, 12, 31) and doc.header.report_date_text == "на 31 декабря 2024 г."
assert doc.header.units == "тыс. руб." and doc.header.okud is None
assert [(c.column_index, c.label, c.period_date) for c in doc.columns] == [
(0, "На 31 декабря 2024 г.", date(2024, 12, 31)),
(1, "На 31 декабря 2023 г.", date(2023, 12, 31)),
]
assert doc.checks is None
def test_lines_and_values(self) -> None:
doc = _build(CLEAN_BALANCE).document
assert [line.line_code for line in doc.lines] == ["1150", "1100"]
first = doc.lines[0]
assert (
first.line_name == "Основные средства"
and first.line_code_confidence == 100
and first.line_name_confidence == 100
)
values = first.values
assert [v.column_index for v in values] == [0, 1]
assert (
values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True
)
assert values[1].amount == Decimal("-56") and values[1].is_recognized is True
blank = doc.lines[1].values[1]
assert blank.amount is None and blank.string_value is None and blank.is_recognized is True
def test_fully_recognized_and_edition(self) -> None:
built = _build(CLEAN_BALANCE)
assert built.fully_recognized is True and built.warnings == [] and built.edition_id == "66n"
class TestNoise:
def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
doc = built.document
# кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)»)
assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка"
assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100
assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается
assert doc.lines[1].line_name == "Основные средства"
assert built.fully_recognized is False
def test_non_integer_code_row_is_dropped(self) -> None:
"""Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100)),
)
assert [line.line_code for line in _build(table).document.lines] == ["1110"]
def test_duplicate_code_keeps_first(self) -> None:
"""Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150"]
assert built.document.lines[0].values[0].amount == Decimal("1")
assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False
def test_missing_cells_keep_column_count(self) -> None:
"""Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок."""
table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100)))
values = _build(table).document.lines[0].values
assert [(v.column_index, v.amount, v.string_value, v.confidence, v.is_recognized) for v in values] == [
(0, None, None, 0, False),
(1, None, None, 0, False),
]
def test_unverified_amount_is_not_recognized(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100)),
)
values = _build(table).document.lines[0].values
assert (
values[0].amount == Decimal("7744536")
and values[0].is_recognized is False
and values[0].string_value is None
)
assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x"
def test_synthetic_header_columns_without_dates(self) -> None:
table = _table(
"Бухгалтерский баланс",
_row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)),
_row(1, (1, "1110", 100), (0, "НМА", 100), (2, "2", 100)),
)
built = _build(table)
assert [(c.column_index, c.label, c.period_date) for c in built.document.columns] == [(0, "", None)]
assert "шапка синтезирована" in " ".join(built.warnings) and built.fully_recognized is False
class TestTypeAndEdition:
def test_type_by_codes_when_label_unknown(self) -> None:
built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"]))
assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings
def test_unanimous_codes_win_over_label_with_warning(self) -> None:
"""#30: label «Бухгалтерский баланс» от ИС при кодах только 2xxx — тип по кодам, label в предупреждении."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1 182 108", 100), (3, "807 029", 100)),
_row(2, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(618 060)", 100), (3, "(371 193)", 100)),
)
built = _build(table)
assert built.document.document_type == "INCOME"
assert built.document.header.form_name == "Бухгалтерский баланс" # первичка не мутируется
assert "тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME" in built.warnings
assert built.fully_recognized is False
def test_label_wins_over_mixed_codes_with_warning(self) -> None:
"""Коды обеих групп (шум OCR) — кодам не верим, тип по label."""
table = _table(
"Отчет о финансовых результатах",
*CLEAN_BALANCE["rows"],
_row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.document.document_type == "INCOME"
assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings)
assert built.fully_recognized is False
def test_other_when_nothing_known(self) -> None:
table = _table(
"Приложение",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "b", 100), (1, "5200", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.document.document_type == "OTHER" and "тип не определён: OTHER" in built.warnings
def test_edition_2025_is_empty(self) -> None:
header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False)
table = _table(
"Бухгалтерский баланс",
_row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня 2025 г.", 100)),
_row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100)),
)
built = _build(table, header)
assert built.edition_id == "fsbu4" and "редакция fsbu4 не заполнена" in built.warnings
assert built.document.lines[0].line_name == "Основные средства" # из OCR
assert built.document.lines[0].line_name_confidence == 100 # confidence ячейки наименования
assert built.fully_recognized is False
def test_edition_by_codes_when_no_dates(self) -> None:
header = FileHeader(None, None, None, None, None, None, None, False)
table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)))
built = _build(table, header)
assert built.edition_id == "66n" and "редакция определена по кодам: 66n" in built.warnings
def test_unparsed_table_is_none(self) -> None:
assert (
build_document(_table("x", _row(0, (0, "a", 100), (1, "b", 100))), 0, "job.json", HEADER_2024, RULES)
is None
)
def test_missing_label_omits_form_name() -> None:
"""Контракт: `form_name` без `null` — при отсутствии label поле не задаётся, сборка не падает."""
table = {"rows": CLEAN_BALANCE["rows"]}
built = _build(table)
assert built.document.header.form_name is None
assert "form_name" not in built.document.to_wire()["header"]
def test_table_rows_not_list_is_none() -> None:
assert build_document({"label": {"form_name": "x"}, "rows": "oops"}, 0, "job.json", HEADER_2024, RULES) is None
REPEATED_HEADER = "повторная шапка изменила число колонок значений"
def _head(*dates: str) -> dict:
cells = [(0, "Наименование показателя", 100), (1, "Код", 100)]
cells += [(2 + i, text, 100) for i, text in enumerate(dates)]
return _row(0, *cells)
class TestRepeatedHeader:
"""R-3 (design #21): `columns` — по первой шапке, `values` каждой строки — по позиции этой колонки."""
def test_wider_first_header_pads_missing_values(self) -> None:
table = _table(
"Бухгалтерский баланс",
_head("На 31 декабря 2024 г.", "На 31 декабря 2023 г.", "На 31 декабря 2022 г."),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100), (4, "3", 100)),
_row(
2,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
),
_row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150", "1110"]
assert all(len(line.values) == 3 for line in built.document.lines)
last = built.document.lines[1].values[2]
assert (last.amount, last.string_value, last.confidence, last.is_recognized) == (None, None, 0, False)
assert built.document.lines[1].values[1].amount == Decimal("5")
assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False
def test_narrower_first_header_drops_extra_indexes(self) -> None:
table = _table(
"Бухгалтерский баланс",
_head("На 31 декабря 2024 г.", "На 31 декабря 2023 г."),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(
2,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
(4, "На 31 декабря 2022 г.", 100),
),
_row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100), (4, "6", 100)),
)
built = _build(table)
assert [c.column_index for c in built.document.columns] == [0, 1]
for line in built.document.lines:
assert [v.column_index for v in line.values] == [0, 1] # нет «сироты» с column_index = 2
assert built.document.lines[1].values[1].amount == Decimal("5")
assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False
def test_same_layout_after_repeated_header_is_silent(self) -> None:
table = _table(
"Бухгалтерский баланс",
*CLEAN_BALANCE["rows"][:2],
_row(
5,
(0, "Наименование показателя", 100),
(1, "Код", 100),
(2, "На 31 декабря 2024 г.", 100),
(3, "На 31 декабря 2023 г.", 100),
),
_row(6, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150", "1100"]
assert built.warnings == [] and built.fully_recognized is True
def test_latin_o_in_code_drops_row() -> None:
"""R-4 (design #22): лукалайки применяются только к датам — «11O0» не целое число, строка отбрасывается."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "Итого", 100), (1, "11O0", 100), (2, "1", 100), (3, "2", 100)),
)
assert [line.line_code for line in _build(table).document.lines] == ["1150"]
class TestEditionWarnings:
def test_empty_edition_gives_single_warning(self) -> None:
"""R-5 (design #23): пустая fsbu4 — одно «не заполнена», без «код N из редакции 66n» по строкам."""
header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False)
table = _table(
"Бухгалтерский баланс",
_head("На 30 июня 2025 г"),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "2", 100)),
)
built = _build(table, header)
assert built.edition_id == "fsbu4"
assert built.warnings == ["редакция fsbu4 не заполнена"]
assert not any("из редакции" in w for w in built.warnings)
def test_code_from_other_nonempty_edition_is_reported(self) -> None:
rules = OcrRules(
editions=[
Edition(id="old", valid_until=date(2024, 12, 31), lines={"1150": "Основные средства"}),
Edition(id="new", valid_from=date(2025, 1, 1), lines={"1110": "Нематериальные активы"}),
],
lookalikes=RULES.lookalikes,
labels=RULES.labels,
)
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "3", 100), (3, "4", 100)),
)
built = build_document(table, 0, "job.json", HEADER_2024, rules)
assert built is not None and built.edition_id == "old"
assert built.warnings == ["код 1110 из редакции new"]
assert built.document.lines[1].line_name == "НМА" and built.document.lines[1].line_code_confidence == 99
def test_table_without_value_columns() -> None:
"""R-6 (design #25): нет колонок значений — предупреждение и needs_check; values пустые."""
table = _table(
"Бухгалтерский баланс",
_row(0, (0, "Наименование показателя", 100), (1, "Код", 100)),
_row(1, (0, "ОС", 100), (1, "1150", 100)),
)
built = _build(table)
assert built.document.columns == [] and built.document.lines[0].values == []
assert "нет колонок значений" in built.warnings and built.fully_recognized is False
assert REPEATED_HEADER not in built.warnings
class TestHeaderConditions:
"""D-6: каждое отклонение шапки файла лишает документ статуса «всё распознано»."""
@pytest.mark.parametrize(
"changes",
[
{"inn_confidence": 90.0},
{"inn": None, "inn_confidence": None},
{"organization_confidence": 90.0},
{"organization_name": None, "organization_confidence": None},
{"report_date": None, "report_date_recognized": False},
{"report_date_recognized": False},
],
ids=["inn-low", "inn-none", "org-low", "org-none", "no-date", "date-not-recognized"],
)
def test_header_deviation_is_not_fully_recognized(self, changes: dict) -> None:
assert _build(CLEAN_BALANCE, replace(HEADER_2024, **changes)).fully_recognized is False
def test_baseline_is_fully_recognized(self) -> None:
assert _build(CLEAN_BALANCE, HEADER_2024).fully_recognized is True
def test_column_without_date_warning() -> None:
table = _table(
"Бухгалтерский баланс",
_head("На отчетную дату"),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
)
built = _build(table)
assert "колонка 0 без даты: На отчетную дату" in built.warnings
assert built.document.columns[0].period_date is None and built.fully_recognized is False
def test_edition_not_determined_warning() -> None:
header = FileHeader(None, None, None, None, None, None, None, False)
table = _table(
"Приложение",
_row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 1 января", 100)),
_row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100)),
_row(2, (0, "b", 100), (1, "5200", 100), (2, "2", 100)),
)
built = _build(table, header)
assert built.edition_id is None and "редакция не определена" in built.warnings
assert built.document.lines[0].line_name == "a" and built.fully_recognized is False
class TestKnownCodeConfidence:
"""R-2, ветка «код в выбранной редакции»: confidence ячейки, если текст чистый, иначе не выше 99."""
def test_clean_code_keeps_cell_confidence(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 80), (1, "1150", 80), (2, "1", 100), (3, "2", 100)),
)
line = _build(table).document.lines[0]
assert line.line_code_confidence == 80 and line.line_name_confidence == 80
assert line.line_name == "Основные средства"
def test_dirty_code_is_capped_at_99(self) -> None:
"""«+1150» проходит `int()`, `clean_value` убирает «+» — текст не равен сырому, уверенность ≤ 99."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "+1150", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
line = built.document.lines[0]
assert line.line_code == "1150" and line.line_name == "Основные средства"
assert line.line_code_confidence == 99 and line.line_name_confidence == 99
assert built.fully_recognized is False
class TestMoneyPrecision:
"""Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу."""
@pytest.mark.parametrize("text", ["12,3456", "0,125", "12.3456", "(0,125)"])
def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
built = _build(table)
value = built.document.lines[0].values[0]
assert value.amount is None and value.string_value == text and value.is_recognized is False
assert value.confidence == 100
assert built.fully_recognized is False
def test_neighbours_stay_intact_and_document_is_built(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "0,125", 100), (3, "56,78", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)),
)
built = _build(table)
first, second = built.document.lines
assert (first.values[0].amount, first.values[0].string_value) == (None, "0,125")
assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True)
assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True)
assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True)
built.document.to_wire() # контрактная модель сериализуется без ValidationError
@pytest.mark.parametrize(
("text", "amount"),
[("4,200", Decimal("4200")), ("(4,200)", Decimal("-4200")), ("1,234,567", Decimal("1234567"))],
)
def test_thousands_comma_gives_amount(self, text: str, amount: Decimal) -> None:
"""AURORA-1405, scan-gateway#35: «4,200» в PDF — это 4200, а не 4.200 (отсекаемое №30)."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount == amount and value.is_recognized is True and value.string_value is None
@pytest.mark.parametrize(
("text", "amount"),
[
("4.200", Decimal("4200")),
("837.994", Decimal("837994")),
("(4.200)", Decimal("-4200")),
("1.234.567", Decimal("1234567")),
("1234.567", Decimal("1234567")),
("1 234.567", Decimal("1234567")),
],
)
def test_thousands_dot_gives_amount(self, text: str, amount: Decimal) -> None:
"""AURORA-1405, scan-gateway#37: ИС вернула точку как разделитель тысяч — «4.200» это 4200, не текст (№30)."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount == amount and value.is_recognized is True and value.string_value is None
def test_thousands_dot_low_confidence_is_not_recognized(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "4.200", 90), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount == Decimal("4200") and value.is_recognized is False
@pytest.mark.parametrize("text", ["4.20", "4.2", "1.2345"])
def test_dot_fraction_is_not_thousands(self, text: str) -> None:
"""Не три цифры после точки — дробь, как раньше: до двух знаков принимается, больше — текст (№30)."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
if text == "1.2345":
assert value.amount is None and value.string_value == text
else:
assert value.amount == Decimal(text)
@pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"])
def test_up_to_two_decimals_is_kept(self, text: str) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount is not None and value.string_value is None and value.is_recognized is True
class TestConfidenceRange:
"""Решение №30: confidence ячеек приводится к [0, 100], NaN — в 0 (контракт требует 0..100)."""
@pytest.mark.parametrize(
("raw", "expected"), [(float("nan"), 0), (150, 100), (-5, 0), (float("inf"), 0), (float("-inf"), 0)]
)
def test_out_of_range_cell_confidence(self, raw: float, expected: float) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "5", raw), (3, "2", 100)),
)
built = _build(table)
assert built.document.lines[0].values[0].confidence == expected
built.document.to_wire()
def test_header_with_nan_confidence_builds_document(self) -> None:
header = replace(HEADER_2024, inn_confidence=0.0, organization_confidence=0.0)
built = _build(CLEAN_BALANCE, header)
assert built.document.header.inn_confidence == 0 and built.fully_recognized is False
class TestSkippedRowsWarnings:
"""Решение №29: отброшенные строки — по одному табличному предупреждению каждого вида, needs_check."""
def test_both_warnings_and_not_fully_recognized(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "НМА", 100), (1, "1110 :", 100), (2, "3", 100), (3, "4", 100)),
_row(3, (0, "Мусор", 100), (1, "11x0", 100), (2, "3", 100), (3, "4", 100)),
_row(4, (0, "БАЛАНС", 100), (1, "", 100), (2, "5", 100), (3, "6", 100)),
_row(5, (0, "Итого", 100), (1, "", 100), (2, "7", 100), (3, "", 100)),
_row(6, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), # без сумм — без счёта
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150"]
assert built.warnings == [
"строк с нечисловым кодом пропущено: 2",
"строк без кода с суммами пропущено: 2",
]
assert built.fully_recognized is False
def test_group_titles_without_amounts_are_silent(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "I. Внеоборотные активы", 100), (1, "", 100)),
_row(2, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)),
_row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.warnings == [] and built.fully_recognized is True
def test_aep_like_table_with_synthetic_header(self) -> None:
"""Как в aep_3: пустой код у «Выручка» (мусор «211(1 |» — в колонке значения) и мусорный код «2.10»."""
table = _table(
"Отчет о финансовых результатах",
_row(0, (0, "Выручка", 100), (1, "", 100), (2, "211(1 |", 71), (3, "8096738", 100)),
_row(1, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(5)", 100), (3, "(6)", 100)),
_row(2, (0, "Доходы от участия", 100), (1, "2.10", 100), (2, "5060", 100), (3, "*", 100)),
_row(3, (0, "Валовая прибыль", 100), (1, "2100", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["2120", "2100"]
assert "строк с нечисловым кодом пропущено: 1" in built.warnings
assert "строк без кода с суммами пропущено: 1" in built.warnings
assert "шапка синтезирована: колонки без дат" in built.warnings
def test_merged_codes_are_split_with_warning(self) -> None:
"""OQ-156: склеенная строка ИС «1500\\n1700» даёт две строки документа и предупреждение таблицы."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
_row(2, (0, "Итого V\nБАЛАНС", 100), (1, "1500\n1700", 100), (2, "3\n4", 100), (3, "5\n6", 100)),
)
built = _build(table)
assert [line.line_code for line in built.document.lines] == ["1150", "1500", "1700"]
assert "строк с несколькими кодами разлеплено: 1" in built.warnings
assert built.fully_recognized is False
@pytest.mark.fixtures
class TestSkippedRowsRealFixtures:
@pytest.mark.parametrize(
("name", "index", "expected"),
[
("aep_3.json", 0, ["строк с нечисловым кодом пропущено: 8", "строк без кода с суммами пропущено: 2"]),
("aep_3.json", 1, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 2"]),
("aep_6.json", 0, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 19"]),
("aep_6.json", 1, ["строк с нечисловым кодом пропущено: 1", "строк без кода с суммами пропущено: 2"]),
],
)
def test_warnings_on_real_tables(self, name: str, index: int, expected: list[str]) -> None:
real = Path(API_DIR).parent.parent / "fixtures" / "ocr" / name
if not real.is_file():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
data_ocr = json.loads(real.read_text(encoding="utf-8"))["data_ocr"]
built = build_document(data_ocr["table_structure"][index], index, "job.json", HEADER_2024, RULES)
assert built is not None
skipped = [w for w in built.warnings if w.startswith(("строк с нечисловым", "строк без кода"))]
assert skipped == expected
assert built.fully_recognized is False
@pytest.mark.parametrize(
"label",
[
"на 2024" + " " * 4000 + "b",
"на 2024" + "\n" * 4000 + "b",
"на 2024" + "О" * 5000,
], # «на » — признак колонки значений
ids=["spaces", "nl", "look"],
)
def test_long_column_label_is_fast_and_has_no_date(label: str) -> None:
"""Решение №31: подпись колонки длиннее даты не разбирается и не проходит `fix_lookalikes` (нет квадратичного
роста через `_columns`)."""
table = _table(
"Бухгалтерский баланс",
_head(label),
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
)
start = time.perf_counter()
built = _build(table)
assert time.perf_counter() - start < 1.0
assert built.document.columns[0].period_date is None and built.fully_recognized is False
assert any(w.startswith("колонка 0 без даты") for w in built.warnings)

View File

@ -8,12 +8,12 @@ from pathlib import Path
import pytest import pytest
from arm_statements import default_rules
from src.normalizer import OcrNormalizer from src.normalizer import OcrNormalizer
from src.normalizer.rules import load_rules from tests.golden_update import GOLDEN_DIR, GOLDEN_VERSION, REAL_FIXTURES, golden_cases, outcome_to_json, real_set
from tests.golden_update import GOLDEN_DIR, REAL_FIXTURES, golden_cases, outcome_to_json, real_set
API_DIR = Path(__file__).resolve().parents[3] API_DIR = Path(__file__).resolve().parents[3]
RULES = load_rules(API_DIR / "rules") RULES = default_rules()
pytestmark = pytest.mark.fixtures pytestmark = pytest.mark.fixtures
@ -25,8 +25,9 @@ def _cases() -> list[str]:
def test_matches_golden(name: str) -> None: def test_matches_golden(name: str) -> None:
if not REAL_FIXTURES.is_dir(): if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
pytest.skip("golden v2 пересобирается в задаче 6 (эталоны в fixtures пока прежнего формата)")
expected_path = GOLDEN_DIR / f"{name}.json" expected_path = GOLDEN_DIR / f"{name}.json"
assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff" assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff"
expected = json.loads(expected_path.read_text(encoding="utf-8")) expected = json.loads(expected_path.read_text(encoding="utf-8"))
actual = outcome_to_json(OcrNormalizer(RULES).normalize(real_set(name))) actual = outcome_to_json(OcrNormalizer(RULES, version=GOLDEN_VERSION).normalize(real_set(name)))
assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures" assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures"

View File

@ -1,220 +1,109 @@
"""`OcrNormalizer` (spec «Статус результата нормализации», «Результат по контракту», D-6).""" """`OcrNormalizer`: набор json ИС → `NormalizeOutcome` (статус, ошибка набора, предупреждения файла, метрики, документы
контракта 2.0). Реальные ответы ИС — `test_golden.py` / `test_real_answers.py` (задача 6)."""
import copy
import json
from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
import src.normalizer.ocr as ocr_module import src.normalizer.ocr as ocr_module
from src.contract import ErrorCode, ResultStatus import src.normalizer.ocr.answer_set as answer_set_module
from src.gateway.hotfolder import AnswerFile from arm_statements import default_rules
from src.gateway.merge import merge_answers from src.contract import ErrorCode, FileWarning, ResultStatus, Severity, StatementDocument
from src.normalizer import NO_TABLES_MESSAGE, OcrNormalizer from src.normalizer import OcrNormalizer
from src.normalizer.rules import load_rules from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE
from tests.unit.normalizer.ocr_builders import BAD, BALANCE, HEAD, INCOME, OK, merged, ocr_table, response
from tests.worker_helpers import SUCCESS_ANSWER from tests.worker_helpers import SUCCESS_ANSWER
API_DIR = Path(__file__).resolve().parents[3] VERSION = "abc1234"
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
RULES = load_rules(API_DIR / "rules")
def _merged(*files: tuple[str, Any]) -> dict[str, Any]: def normalizer() -> OcrNormalizer:
answers = [AnswerFile(name, json.dumps(body, ensure_ascii=False).encode()) for name, body in files] return OcrNormalizer(default_rules(), version=VERSION)
return dict(merge_answers(JOB, answers))
def _real_set(*names: str) -> dict[str, Any]: def two_forms() -> dict[str, Any]:
if not REAL_FIXTURES.is_dir(): return response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME))
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
files = []
for name in names:
suffix = name.removesuffix(".json").partition("_")[2].partition("_")[2]
target = f"{JOB}_{suffix}.json" if suffix else f"{JOB}.json"
files.append(AnswerFile(target, (REAL_FIXTURES / name).read_bytes()))
return dict(merge_answers(JOB, files))
CLEAN: dict[str, Any] = { def test_done_with_documents_metrics_and_version() -> None:
"file_uid": JOB, outcome = normalizer().normalize(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE)))))
"error": {"code": "", "message": ""}, assert outcome.status is ResultStatus.DONE and outcome.error is None and outcome.warnings == []
"data_ocr": { assert [d.document_type for d in outcome.documents] == ["BALANCE"]
"label": {"form_name": "Бухгалтерская отчетность", "confidence": 100}, assert all(isinstance(d, StatementDocument) for d in outcome.documents)
"organization": {"form_name": "ООО Ромашка", "confidence": 100}, assert outcome.metrics is not None
"inn": {"form_name": "7700000000", "confidence": 100}, assert outcome.metrics.rows == sum(len(d.lines) for d in outcome.documents) > 0
"units": {"form_name": "тыс. руб.", "confidence": 100}, assert outcome.statements_version == VERSION
"date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100},
"table_structure": [
{
"label": {"form_name": "Бухгалтерский баланс", "confidence": 100},
"rows": [
{
"row_index": 0,
"cells": [
{"column_index": 0, "value": "Наименование показателя", "confidence": 100},
{"column_index": 1, "value": "Код", "confidence": 100},
{"column_index": 2, "value": "На 31 декабря 2024 г.", "confidence": 100},
],
},
{
"row_index": 1,
"cells": [
{"column_index": 0, "value": "Основные средства", "confidence": 100},
{"column_index": 1, "value": "1150", "confidence": 100},
{"column_index": 2, "value": "1 234", "confidence": 100},
],
},
],
}
],
},
}
class TestStatus: def test_one_document_needs_check_makes_set_needs_check() -> None:
def test_done_when_everything_recognized(self) -> None: """Синтетический ОФР без строки 2300 не сходится по §7: документ needs_check, набор — тоже."""
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN))) outcome = normalizer().normalize(merged(("j.json", two_forms())))
assert outcome.status is ResultStatus.DONE and outcome.warnings == [] assert [d.document_type for d in outcome.documents] == ["BALANCE", "INCOME"]
assert len(outcome.documents) == 1 and outcome.documents[0].document_type == "BALANCE" assert [d.status for d in outcome.documents] == ["done", "needs_check"]
assert outcome.metrics is not None and outcome.metrics.rows == 1 assert outcome.status is ResultStatus.NEEDS_CHECK and outcome.warnings == []
def test_needs_check_with_table_warning(self) -> None:
noisy = copy.deepcopy(CLEAN)
noisy["data_ocr"]["table_structure"][0]["rows"][1]["cells"][2]["confidence"] = 90
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
assert outcome.status is ResultStatus.NEEDS_CHECK
def test_no_tables_is_recognition_failed(self) -> None: def test_lost_json_keeps_documents_and_gives_needs_check() -> None:
empty = copy.deepcopy(CLEAN) outcome = normalizer().normalize(
empty["data_ocr"]["table_structure"] = [] merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD)))
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", empty))) )
assert outcome.status is ResultStatus.ERROR assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
assert outcome.error is not None and outcome.error.code is ErrorCode.RECOGNITION_FAILED [warning] = outcome.warnings
assert outcome.error.message == NO_TABLES_MESSAGE assert isinstance(warning, FileWarning)
assert (warning.code, warning.severity, warning.locator) == ("JSON_FAILED", Severity.ERROR, "file:j_ofr.json")
assert warning.message == "INVALID_DOCUMENT: Не удалось определить тип документа."
def test_skipped_table_is_warned(self) -> None:
bad = copy.deepcopy(CLEAN)
empty_table = {"label": {"form_name": "x"}, "rows": [{"row_index": 0, "cells": []}]}
bad["data_ocr"]["table_structure"].append(empty_table)
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", bad)))
assert outcome.status is ResultStatus.NEEDS_CHECK
assert f"{JOB}.json: table:1: нет шапки и столбца кода" in outcome.warnings
def test_exception_in_one_table_becomes_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_all_json_failed_takes_first_code() -> None:
real = ocr_module.build_document outcome = normalizer().normalize(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект")))
assert outcome.status is ResultStatus.ERROR and outcome.error is not None
assert (outcome.error.code, outcome.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"])
assert outcome.documents == [] and outcome.metrics is None and outcome.statements_version is None
assert [w.locator for w in outcome.warnings] == ["file:j.json", "file:j_ofr.json"]
def boom(table: Any, index: int, *args: Any, **kwargs: Any) -> Any:
if index == 1: def test_no_tables_is_recognition_failed() -> None:
outcome = normalizer().normalize(merged(("j.json", {"error": OK, "data_ocr": {**HEAD, "table_structure": []}})))
assert outcome.status is ResultStatus.ERROR and outcome.error is not None
assert (outcome.error.code, outcome.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE)
assert [(w.code, w.message) for w in outcome.warnings] == [("JSON_FAILED", "в ответе ИС нет таблиц")]
def test_table_exception_becomes_table_parse_error(monkeypatch: pytest.MonkeyPatch) -> None:
real = answer_set_module.normalize_candidate
def flaky(candidate: Any, rules: Any = None) -> Any:
if candidate.locator.endswith("table:1"):
raise RuntimeError("сломалось") raise RuntimeError("сломалось")
return real(table, index, *args, **kwargs) return real(candidate, rules)
monkeypatch.setattr(ocr_module, "build_document", boom) monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky)
two = copy.deepcopy(CLEAN) outcome = normalizer().normalize(merged(("j.json", two_forms())))
two["data_ocr"]["table_structure"].append(copy.deepcopy(two["data_ocr"]["table_structure"][0]))
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", two)))
assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
assert f"{JOB}.json: table:1: BACKEND_PARSING_ERROR: RuntimeError: сломалось" in outcome.warnings [warning] = outcome.warnings
assert (warning.code, warning.locator) == ("TABLE_PARSE_ERROR", "file:j.json;table:1")
def test_file_level_warnings(self) -> None: assert warning.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось"
noisy = copy.deepcopy(CLEAN)
noisy["data_ocr"]["inn"] = {"form_name": "+", "confidence": 0}
noisy["data_ocr"]["date"] = {"form_name": "Общество", "confidence": 100}
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
assert f"{JOB}.json: ИНН не распознан" in outcome.warnings
assert not any("ИНН распознан неуверенно" in w for w in outcome.warnings)
assert f"{JOB}.json: дата отчёта не разобрана" in outcome.warnings
def test_inn_found_but_unsure_is_worded_differently(self) -> None:
"""F-B: «не распознан» — только когда ИНН не извлечён; извлечённый с confidence < 100 — «неуверенно»."""
unsure = copy.deepcopy(CLEAN)
unsure["data_ocr"]["inn"] = {"form_name": "7700000000", "confidence": 75}
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", unsure)))
assert outcome.status is ResultStatus.NEEDS_CHECK
assert f"{JOB}.json: ИНН распознан неуверенно" in outcome.warnings
assert not any("ИНН не распознан" in w for w in outcome.warnings)
def test_inn_with_noise_is_unsure(self) -> None:
"""ИНН извлечён из мусора (confidence ограничивается 99) — тоже «неуверенно», а не «не распознан»."""
noisy = copy.deepcopy(CLEAN)
noisy["data_ocr"]["inn"] = {"form_name": "77 0000 0000 |", "confidence": 100}
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
assert outcome.warnings == [f"{JOB}.json: ИНН распознан неуверенно"]
def test_inn_missing_field_is_not_recognized(self) -> None:
missing = copy.deepcopy(CLEAN)
del missing["data_ocr"]["inn"]
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", missing)))
assert outcome.warnings == [f"{JOB}.json: ИНН не распознан"]
class TestErrorFilesStillHandled: @pytest.mark.parametrize("raw", [None, {"job_id": "j"}, {"job_id": "j", "files": []}])
def test_all_files_failed_keeps_first_code(self) -> None: def test_not_merged_structure_is_invalid_format(raw: object) -> None:
failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}} outcome = normalizer().normalize(raw)
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", failed)))
assert outcome.status is ResultStatus.ERROR and outcome.error is not None assert outcome.status is ResultStatus.ERROR and outcome.error is not None
assert outcome.error.code is ErrorCode.INVALID_DOCUMENT assert outcome.error.code is ErrorCode.INVALID_FORMAT and outcome.warnings == []
assert outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: нет"]
def test_mixed_files(self) -> None:
failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN), (f"{JOB}_ofr.json", failed)))
# №28: потерянный ошибочный файл не остаётся под `done`, даже если успешный файл распознан полностью
assert outcome.status is ResultStatus.NEEDS_CHECK
assert outcome.error is None and len(outcome.documents) == 1
assert outcome.warnings == [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет"]
assert OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN))).status is ResultStatus.DONE
def test_mixed_files_error_first_in_order(self) -> None:
"""Порядок файлов не важен: ошибочный первым, успешный вторым — тоже needs_check с документом."""
failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}_a.json", failed), (f"{JOB}_b.json", CLEAN)))
assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
def test_not_merged_structure(self) -> None:
outcome = OcrNormalizer(RULES).normalize({"job_id": JOB})
assert outcome.status is ResultStatus.ERROR and outcome.error is not None
assert outcome.error.code is ErrorCode.INVALID_FORMAT
def test_success_answer_helper_from_worker_tests(self) -> None:
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", SUCCESS_ANSWER)))
assert outcome.status is ResultStatus.NEEDS_CHECK # confidence < 100 у шапки
@pytest.mark.fixtures def test_success_answer_helper_from_worker_tests() -> None:
class TestRealFixtures: outcome = normalizer().normalize(merged(("j.json", SUCCESS_ANSWER)))
def test_aep6(self) -> None: assert outcome.status is not ResultStatus.ERROR and len(outcome.documents) == 1
outcome = OcrNormalizer(RULES).normalize(_real_set("aep_6.json"))
assert outcome.status is ResultStatus.NEEDS_CHECK
types = [d.document_type for d in outcome.documents]
assert types == ["BALANCE", "INCOME"]
balance, income = outcome.documents
assert [(c.column_index, c.label, str(c.period_date)) for c in balance.columns] == [
(0, "На 30 июня 2025 г", "2025-06-30"),
(1, "На 31 декабря 2024 г.", "2024-12-31"),
(2, "На 31 декабря 2023 г.", "2023-12-31"),
]
codes = [line.line_code for line in balance.lines]
assert "1105" in codes and "1110" in codes
assert all(line.line_code for line in balance.lines)
assert [str(c.period_date) for c in income.columns] == ["2025-06-30", "2024-06-30"]
revenue = next(line for line in income.lines if line.line_code == "2110")
assert str(revenue.values[0].amount) == "13070"
assert balance.header.units == "тыс. руб." and balance.header.inn is None and balance.header.report_date is None
def test_aep3(self) -> None:
outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json"))
assert outcome.status is ResultStatus.NEEDS_CHECK
balance = next(d for d in outcome.documents if d.document_type == "BALANCE")
assert all(c.period_date is None for c in balance.columns)
assert balance.header.inn == "3121200351"
assert balance.header.inn_confidence is not None and balance.header.inn_confidence < 100
assert str(balance.header.report_date) == "2024-12-31"
assert any(line.line_code == "1120" for line in balance.lines)
assert any("шапка синтезирована" in w for w in outcome.warnings)
def test_aep3_with_failed_ofr(self) -> None: def test_default_rules_and_version_from_package() -> None:
outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json", "aep_3_ofr.json")) created = OcrNormalizer()
assert outcome.status is ResultStatus.NEEDS_CHECK assert created.statements_version == ocr_module.statements_version()
assert any(w.startswith(f"{JOB}_ofr.json: INVALID_DOCUMENT:") for w in outcome.warnings)
def test_requires_statements_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ocr_module, "statements_version", lambda path=None: None)
with pytest.raises(ValueError, match="без VERSION"):
OcrNormalizer(default_rules())
assert OcrNormalizer(default_rules(), version="x").statements_version == "x"

View File

@ -278,11 +278,10 @@ async def test_unhandled_error_has_json_500(local_env: Path) -> None:
assert response.json() == {"detail": "внутренняя ошибка сервера"} assert response.json() == {"detail": "внутренняя ошибка сервера"}
def test_create_app_fails_on_broken_rules(tmp_path: Path) -> None: def test_create_app_uses_package_rules() -> None:
(tmp_path / "statement_lines.yaml").write_text("editions: []\n", encoding="utf-8") app = create_app(get_settings().model_copy(update={"SCAN_HOTFOLDER_PATH": "."}))
settings = get_settings().model_copy(update={"SCAN_RULES_DIR": str(tmp_path), "SCAN_HOTFOLDER_PATH": str(tmp_path)}) expected = (Path(__file__).resolve().parents[2] / "arm_statements" / "VERSION").read_text(encoding="utf-8").strip()
with pytest.raises(ValueError, match="statement_lines.yaml"): assert app.state.normalizer.statements_version == expected
create_app(settings)
def test_create_app_uses_ocr_normalizer() -> None: def test_create_app_uses_ocr_normalizer() -> None:

View File

@ -1,4 +1,4 @@
"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-contract, slice-0-skeleton). """Контракт обмена с сервисами распознавания v2.0, запрос v1.0 (spec recognition-contract, slice-0-skeleton).
Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53); Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53);
каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же
@ -28,6 +28,7 @@ from src.contract import (
SUPPORTED_MAJOR, SUPPORTED_MAJOR,
ContractModel, ContractModel,
ErrorCode, ErrorCode,
FileWarning,
MessageStatus, MessageStatus,
Metrics, Metrics,
RecognitionRequest, RecognitionRequest,
@ -176,12 +177,11 @@ def test_example_passes_model_and_round_trips(name: str) -> None:
def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None: def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None:
result = RecognitionResult.model_validate(_example("result-statement-minimal.json")) result = RecognitionResult.model_validate(_example("result-statement-minimal.json"))
values = result.documents[0].lines[1].values # type: ignore[union-attr] values = result.documents[0].lines[1].values # type: ignore[union-attr]
assert values[0].amount == Decimal("5678.00") assert values[0].amount == Decimal("428311")
assert values[1].amount == Decimal("-12.30") assert isinstance(values[0].amount, Decimal)
assert isinstance(values[1].amount, Decimal)
wire = result.to_wire() wire = result.to_wire()
assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30" assert wire["documents"][0]["lines"][1]["values"][0]["amount"] == "428311" # без «.00» — как пришло
assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло assert wire["documents"][0]["lines"][0]["values"][0]["amount"] == "100"
def test_osv_amount_keeps_kopecks() -> None: def test_osv_amount_keeps_kopecks() -> None:
@ -269,8 +269,10 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
("message/status-unknown", "result-message-done.json", _set("status", "queued"), False), ("message/status-unknown", "result-message-done.json", _set("status", "queued"), False),
("message/no-service", "result-message-processing.json", _drop("service"), False), ("message/no-service", "result-message-processing.json", _drop("service"), False),
("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False), ("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False),
("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False), ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), True),
("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True), ("message/schema_version-2.7", "result-message-processing.json", _set("schema_version", "2.7"), True),
("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), False),
("message/schema_version-3.0", "result-message-processing.json", _set("schema_version", "3.0"), False),
("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False), ("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False),
("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False), ("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False),
("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True), ("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True),
@ -297,7 +299,8 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
True, True,
), ),
("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True), ("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True),
("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False), ("message/metrics-warnings-not-structures", "result-message-done.json", _set("metrics.warnings", [1]), False),
("message/metrics-warnings-strings", "result-message-done.json", _set("metrics.warnings", ["a"]), False),
("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False), ("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False),
("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True), ("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True),
("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False), ("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False),
@ -351,8 +354,10 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
("result/error-null", "result-error.json", _set("error", None), True), ("result/error-null", "result-error.json", _set("error", None), True),
("result/error-empty-object", "result-error.json", _set("error", {}), True), ("result/error-empty-object", "result-error.json", _set("error", {}), True),
("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False), ("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False),
("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False), ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), True),
("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True), ("result/schema_version-2.3", "result-osv-minimal.json", _set("schema_version", "2.3"), True),
("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), False),
("result/schema_version-3.0", "result-osv-minimal.json", _set("schema_version", "3.0"), False),
("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False), ("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False),
("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False), ("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False),
("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False), ("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False),
@ -550,6 +555,21 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
False, False,
), ),
("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True), ("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True),
# --- v2.0: документы ББ/ОФР ---
("result/statement-type-OTHER", "result-statement-minimal.json", _set("documents.0.document_type", "OTHER"), False),
("result/statement-no-form_variant", "result-statement-minimal.json", _drop("documents.0.form_variant"), False),
(
"result/statement-unreadable-with-code",
"result-statement-minimal.json",
_set("documents.0.lines.2.line_code", "1100"),
False,
),
(
"result/statement-no-statements_version",
"result-statement-minimal.json",
_drop("service.statements_version"),
False,
),
] ]
@ -604,7 +624,7 @@ def test_message_status_enum_values() -> None:
def test_contract_version_constant() -> None: def test_contract_version_constant() -> None:
assert CONTRACT_VERSION == "1.0" assert CONTRACT_VERSION == "2.0"
def test_python_float_amount_is_rejected_with_clear_error() -> None: def test_python_float_amount_is_rejected_with_clear_error() -> None:
@ -746,7 +766,7 @@ def test_build_ack_message() -> None:
message = build_ack_message(JOB_ID, FILE_ID, SERVICE) message = build_ack_message(JOB_ID, FILE_ID, SERVICE)
wire = message.to_wire() wire = message.to_wire()
assert wire == { assert wire == {
"schema_version": "1.0", "schema_version": "2.0",
"job_id": str(JOB_ID), "job_id": str(JOB_ID),
"file_id": str(FILE_ID), "file_id": str(FILE_ID),
"status": "processing", "status": "processing",
@ -769,13 +789,18 @@ def test_build_error_message_with_raw_s3_key_and_metrics() -> None:
ErrorCode.TIMEOUT, ErrorCode.TIMEOUT,
"ИС Сканирование не ответила за 300 с", "ИС Сканирование не ответила за 300 с",
raw_s3_key=f"raw/{JOB_ID}.json", raw_s3_key=f"raw/{JOB_ID}.json",
metrics=Metrics(duration_ms=300000, warnings=["нет ответа"]), metrics=Metrics(
duration_ms=300000, warnings=[FileWarning(code="JSON_FAILED", severity="error", message="нет ответа")]
),
) )
wire = message.to_wire() wire = message.to_wire()
assert wire["status"] == "error" assert wire["status"] == "error"
assert wire["error"] == {"code": "TIMEOUT", "message": "ИС Сканирование не ответила за 300 с"} assert wire["error"] == {"code": "TIMEOUT", "message": "ИС Сканирование не ответила за 300 с"}
assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json" assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json"
assert wire["metrics"] == {"duration_ms": 300000, "warnings": ["нет ответа"]} assert wire["metrics"] == {
"duration_ms": 300000,
"warnings": [{"code": "JSON_FAILED", "severity": "error", "message": "нет ответа"}],
}
assert "result_s3" not in wire assert "result_s3" not in wire
VALIDATORS["message"].validate(wire) VALIDATORS["message"].validate(wire)
assert RecognitionResultMessage.model_validate(wire) == message assert RecognitionResultMessage.model_validate(wire) == message
@ -805,7 +830,7 @@ def test_build_done_message() -> None:
wire = message.to_wire() wire = message.to_wire()
assert wire["status"] == "done" assert wire["status"] == "done"
assert wire["result_s3"] == {"bucket": "arm-ka", "key": f"results/{FILE_ID}/{JOB_ID}/result.json"} assert wire["result_s3"] == {"bucket": "arm-ka", "key": f"results/{FILE_ID}/{JOB_ID}/result.json"}
assert wire["result_schema_version"] == "1.0" assert wire["result_schema_version"] == "2.0"
assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json" assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json"
VALIDATORS["message"].validate(wire) VALIDATORS["message"].validate(wire)
assert RecognitionResultMessage.model_validate(wire) == message assert RecognitionResultMessage.model_validate(wire) == message

View File

@ -608,7 +608,8 @@ class TestAnswerSet:
assert outcome.error.code == "NOT_IMPLEMENTED" assert outcome.error.code == "NOT_IMPLEMENTED"
assert outcome.warnings is not None assert outcome.warnings is not None
assert len(outcome.warnings) == 1 assert len(outcome.warnings) == 1
assert outcome.warnings[0].startswith(f"{job_id}_ofr.json: INVALID_DOCUMENT: ") assert outcome.warnings[0].locator == f"file:{job_id}_ofr.json"
assert outcome.warnings[0].message.startswith("INVALID_DOCUMENT: ")
class TestSettings: class TestSettings:

View File

@ -16,7 +16,7 @@ from typing import Any
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument from src.contract import ErrorCode, FileWarning, MessageError, Metrics, ResultStatus, Severity, StatementDocument
from src.gateway.hotfolder import AnswerFile from src.gateway.hotfolder import AnswerFile
from src.gateway.merge import merge_answers from src.gateway.merge import merge_answers
from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, Normalizer, NullNormalizer from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, Normalizer, NullNormalizer
@ -79,9 +79,17 @@ def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | Non
assert outcome.metrics is None assert outcome.metrics is None
def _lines(outcome: NormalizeOutcome) -> list[str]:
"""Предупреждения `JSON_FAILED` в прежней записи `<имя>: <код ИС>: <сообщение>` (для краткости сверок)."""
for warning in outcome.warnings:
assert warning.code == "JSON_FAILED" and warning.severity is Severity.ERROR
assert warning.locator is not None and warning.locator.startswith("file:")
return [f"{(w.locator or '').removeprefix('file:')}: {w.message}" for w in outcome.warnings]
def _assert_not_implemented(outcome: NormalizeOutcome, warnings: list[str]) -> None: def _assert_not_implemented(outcome: NormalizeOutcome, warnings: list[str]) -> None:
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
assert outcome.warnings == warnings assert _lines(outcome) == warnings
@pytest.mark.fixtures @pytest.mark.fixtures
@ -103,7 +111,7 @@ class TestRealFixtures:
[entry] = merged["files"] [entry] = merged["files"]
outcome = NullNormalizer().normalize(merged) outcome = NullNormalizer().normalize(merged)
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT, REAL_MESSAGE) _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, REAL_MESSAGE)
assert outcome.warnings == [f"{entry['name']}: INVALID_DOCUMENT: {REAL_MESSAGE}"] assert _lines(outcome) == [f"{entry['name']}: INVALID_DOCUMENT: {REAL_MESSAGE}"]
class TestFileSuccess: class TestFileSuccess:
@ -133,7 +141,7 @@ class TestFileError:
def test_known_code_and_message_verbatim(self) -> None: def test_known_code_and_message_verbatim(self) -> None:
outcome = NullNormalizer().normalize(_one(_failure("FILE_READ_ERROR", "Файл повреждён: стр. 3 «пусто»"))) outcome = NullNormalizer().normalize(_one(_failure("FILE_READ_ERROR", "Файл повреждён: стр. 3 «пусто»")))
_assert_error(outcome, ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»") _assert_error(outcome, ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»")
assert outcome.warnings == [f"{JOB}.json: FILE_READ_ERROR: Файл повреждён: стр. 3 «пусто»"] assert _lines(outcome) == [f"{JOB}.json: FILE_READ_ERROR: Файл повреждён: стр. 3 «пусто»"]
@pytest.mark.parametrize("code", [c.value for c in ErrorCode]) @pytest.mark.parametrize("code", [c.value for c in ErrorCode])
def test_every_contract_code_passes(self, code: str) -> None: def test_every_contract_code_passes(self, code: str) -> None:
@ -145,14 +153,14 @@ class TestFileError:
assert outcome.error is not None assert outcome.error is not None
assert "SOME_NEW_CODE" in outcome.error.message assert "SOME_NEW_CODE" in outcome.error.message
assert "Превышена квота" in outcome.error.message assert "Превышена квота" in outcome.error.message
assert outcome.warnings == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"] assert _lines(outcome) == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"]
def test_lowercase_code_is_unknown(self) -> None: def test_lowercase_code_is_unknown(self) -> None:
outcome = NullNormalizer().normalize(_one(_failure("invalid_document", "m"))) outcome = NullNormalizer().normalize(_one(_failure("invalid_document", "m")))
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR) _assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None assert outcome.error is not None
assert "invalid_document" in outcome.error.message assert "invalid_document" in outcome.error.message
assert outcome.warnings == [f"{JOB}.json: invalid_document: m"] assert _lines(outcome) == [f"{JOB}.json: invalid_document: m"]
@pytest.mark.parametrize("code", [5, ["INVALID_DOCUMENT"], {"a": 1}, True]) @pytest.mark.parametrize("code", [5, ["INVALID_DOCUMENT"], {"a": 1}, True])
def test_code_not_a_string_is_unknown_error(self, code: object) -> None: def test_code_not_a_string_is_unknown_error(self, code: object) -> None:
@ -161,7 +169,7 @@ class TestFileError:
assert outcome.error is not None assert outcome.error is not None
assert repr(code) in outcome.error.message assert repr(code) in outcome.error.message
assert "m-text" in outcome.error.message assert "m-text" in outcome.error.message
assert outcome.warnings == [f"{JOB}.json: {code!r}: m-text"] assert _lines(outcome) == [f"{JOB}.json: {code!r}: m-text"]
@pytest.mark.parametrize("message", [None, ""]) @pytest.mark.parametrize("message", [None, ""])
def test_missing_or_empty_message_gets_default(self, message: object) -> None: def test_missing_or_empty_message_gets_default(self, message: object) -> None:
@ -170,7 +178,7 @@ class TestFileError:
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT) _assert_error(outcome, ErrorCode.INVALID_DOCUMENT)
assert outcome.error is not None assert outcome.error is not None
assert "INVALID_DOCUMENT" in outcome.error.message assert "INVALID_DOCUMENT" in outcome.error.message
assert outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: {outcome.error.message}"] assert _lines(outcome) == [f"{JOB}.json: INVALID_DOCUMENT: {outcome.error.message}"]
def test_message_absent_gets_default(self) -> None: def test_message_absent_gets_default(self) -> None:
outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}})) outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}}))
@ -201,7 +209,7 @@ class TestFileError:
_assert_error(outcome, ErrorCode.INVALID_FORMAT) _assert_error(outcome, ErrorCode.INVALID_FORMAT)
assert outcome.error is not None assert outcome.error is not None
assert "Expecting value" in outcome.error.message assert "Expecting value" in outcome.error.message
[warning] = outcome.warnings [warning] = _lines(outcome)
assert warning.startswith(f"{JOB}.json: INVALID_FORMAT: ") assert warning.startswith(f"{JOB}.json: INVALID_FORMAT: ")
assert "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" in warning assert "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" in warning
@ -210,7 +218,7 @@ class TestFileError:
"""Сценарий «Корень ответа не объект».""" """Сценарий «Корень ответа не объект»."""
outcome = NullNormalizer().normalize(_one(response)) outcome = NullNormalizer().normalize(_one(response))
_assert_error(outcome, ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE) _assert_error(outcome, ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE)
assert outcome.warnings == [f"{JOB}.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"] assert _lines(outcome) == [f"{JOB}.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"]
class TestSummary: class TestSummary:
@ -223,8 +231,9 @@ class TestSummary:
outcome = NullNormalizer().normalize(merged) outcome = NullNormalizer().normalize(merged)
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT, "нет типа") _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, "нет типа")
assert len(outcome.warnings) == 2 assert len(outcome.warnings) == 2
assert outcome.warnings[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа" lines = _lines(outcome)
assert outcome.warnings[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ") assert lines[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа"
assert lines[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ")
def test_first_erroneous_parse_error_gives_invalid_format(self) -> None: def test_first_erroneous_parse_error_gives_invalid_format(self) -> None:
merged = _merged( merged = _merged(
@ -279,7 +288,7 @@ class TestMergedStructure:
merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]} merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]}
outcome = NullNormalizer().normalize(merged) outcome = NullNormalizer().normalize(merged)
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED) _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED)
[warning] = outcome.warnings [warning] = _lines(outcome)
assert warning.startswith("files[0]: INVALID_FORMAT: ") assert warning.startswith("files[0]: INVALID_FORMAT: ")
def test_real_merge_output_is_accepted(self) -> None: def test_real_merge_output_is_accepted(self) -> None:
@ -293,13 +302,15 @@ class TestNormalizeOutcome:
assert outcome.status is ResultStatus.ERROR assert outcome.status is ResultStatus.ERROR
assert outcome.documents == [] assert outcome.documents == []
assert outcome.metrics is None assert outcome.metrics is None
assert outcome.warnings == [] assert _lines(outcome) == []
def test_warnings_are_kept(self) -> None: def test_warnings_are_kept(self) -> None:
outcome = NormalizeOutcome( outcome = NormalizeOutcome(
status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"), warnings=["a: B: c"] status="error",
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
warnings=[FileWarning(code="JSON_FAILED", severity=Severity.ERROR, message="B: c", locator="file:a")],
) )
assert outcome.warnings == ["a: B: c"] assert _lines(outcome) == ["a: B: c"]
def test_error_requires_error(self) -> None: def test_error_requires_error(self) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@ -311,6 +322,7 @@ class TestNormalizeOutcome:
NormalizeOutcome( NormalizeOutcome(
status=status, status=status,
documents=[_statement()], documents=[_statement()],
statements_version="abc1234",
error=MessageError(code=ErrorCode.UNREADABLE, message="m"), error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
) )
@ -319,10 +331,17 @@ class TestNormalizeOutcome:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NormalizeOutcome(status=status) NormalizeOutcome(status=status)
@pytest.mark.parametrize("status", ["done", "needs_check"])
def test_documents_require_statements_version(self, status: str) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(status=status, documents=[_statement()])
@pytest.mark.parametrize("status", ["done", "needs_check"]) @pytest.mark.parametrize("status", ["done", "needs_check"])
def test_success_outcome(self, status: str) -> None: def test_success_outcome(self, status: str) -> None:
metrics = Metrics(pages=2, rows=10) metrics = Metrics(pages=2, rows=10)
outcome = NormalizeOutcome(status=status, documents=[_statement()], metrics=metrics) outcome = NormalizeOutcome(
status=status, documents=[_statement()], statements_version="abc1234", metrics=metrics
)
assert outcome.status is ResultStatus(status) assert outcome.status is ResultStatus(status)
assert outcome.error is None assert outcome.error is None
assert outcome.metrics == metrics assert outcome.metrics == metrics
@ -333,6 +352,7 @@ class TestNormalizeOutcome:
NormalizeOutcome( NormalizeOutcome(
status="error", status="error",
documents=[_statement()], documents=[_statement()],
statements_version="abc1234",
error=MessageError(code=ErrorCode.UNREADABLE, message="m"), error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
) )
@ -373,7 +393,7 @@ class TestProtocol:
def test_custom_implementation_is_accepted(self) -> None: def test_custom_implementation_is_accepted(self) -> None:
class Custom: class Custom:
def normalize(self, raw: object) -> NormalizeOutcome: def normalize(self, raw: object) -> NormalizeOutcome:
return NormalizeOutcome(status="done", documents=[_statement()]) return NormalizeOutcome(status="done", documents=[_statement()], statements_version="abc1234")
normalizer: Normalizer = Custom() normalizer: Normalizer = Custom()
assert normalizer.normalize({}).status is ResultStatus.DONE assert normalizer.normalize({}).status is ResultStatus.DONE
@ -383,7 +403,10 @@ def _statement() -> StatementDocument:
return StatementDocument.model_validate( return StatementDocument.model_validate(
{ {
"document_type": "BALANCE", "document_type": "BALANCE",
"locator": "pages:1-2", "form_variant": "full",
"locator": "file:a.json;table:0",
"status": "done",
"warnings": [],
"header": {}, "header": {},
"columns": [], "columns": [],
"lines": [], "lines": [],

View File

@ -18,9 +18,11 @@ from jsonschema import Draft202012Validator, FormatChecker
from src.contract import ( from src.contract import (
ErrorCode, ErrorCode,
FileWarning,
MessageError, MessageError,
Metrics, Metrics,
ResultStatus, ResultStatus,
Severity,
StatementDocument, StatementDocument,
) )
from src.core.clients import JSON_CONTENT_TYPE from src.core.clients import JSON_CONTENT_TYPE
@ -28,7 +30,6 @@ from src.core.worker import MAX_ERROR_MESSAGE_CHARS, MAX_WARNING_CHARS, MAX_WARN
from src.gateway.hotfolder import AnswerFile from src.gateway.hotfolder import AnswerFile
from src.gateway.merge import dump_merged, merge_answers from src.gateway.merge import dump_merged, merge_answers
from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, OcrNormalizer from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, OcrNormalizer
from src.normalizer.rules import load_rules
from tests.worker_helpers import ( from tests.worker_helpers import (
API_DIR, API_DIR,
BUCKET, BUCKET,
@ -57,6 +58,11 @@ INVALID_DOCUMENT_ANSWER = {
} }
def _failed(name: str, message: str) -> dict[str, str]:
"""Ожидаемая структура `JSON_FAILED` в `metrics.warnings` (ошибочный json набора, P5-10)."""
return {"code": "JSON_FAILED", "severity": "error", "message": message, "locator": f"file:{name}"}
def _json(obj: object) -> bytes: def _json(obj: object) -> bytes:
return json.dumps(obj, ensure_ascii=False).encode("utf-8") return json.dumps(obj, ensure_ascii=False).encode("utf-8")
@ -228,7 +234,7 @@ async def test_real_fixture_error_is_passed_through(make_env: EnvFactory, tmp_pa
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert body["error"]["message"] == expected["message"] assert body["error"]["message"] == expected["message"]
job_id = request["job_id"] job_id = request["job_id"]
assert body["metrics"] == {"warnings": [f"{job_id}.json: INVALID_DOCUMENT: {expected['message']}"]} assert body["metrics"] == {"warnings": [_failed(f"{job_id}.json", f"INVALID_DOCUMENT: {expected['message']}")]}
assert _raw(env, job_id)["files"][0]["response"]["error"] == expected assert _raw(env, job_id)["files"][0]["response"]["error"] == expected
@ -244,7 +250,7 @@ async def test_unknown_name_gets_emulator_invalid_document(make_env: EnvFactory)
@pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) @pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK])
async def test_successful_outcome_writes_result_file(make_env: EnvFactory, status: ResultStatus) -> None: async def test_successful_outcome_writes_result_file(make_env: EnvFactory, status: ResultStatus) -> None:
document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0])
normalizer = StubNormalizer(NormalizeOutcome(status=status, documents=[document])) normalizer = StubNormalizer(NormalizeOutcome(status=status, documents=[document], statements_version="abc1234"))
env = await make_env(normalizer=normalizer) env = await make_env(normalizer=normalizer)
request = env.request() request = env.request()
async with emulator(env): async with emulator(env):
@ -253,7 +259,8 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu
key = f"results/{file_id}/{job_id}/result.json" key = f"results/{file_id}/{job_id}/result.json"
assert body["status"] == status.value assert body["status"] == status.value
assert body["result_s3"] == {"bucket": BUCKET, "key": key} assert body["result_s3"] == {"bucket": BUCKET, "key": key}
assert body["result_schema_version"] == "1.0" assert body["result_schema_version"] == "2.0"
assert "statements_version" not in body.get("service", {}) # в конверте — только {name, version} (P5-11)
assert body["raw_s3_key"] == f"raw/{job_id}.json" assert body["raw_s3_key"] == f"raw/{job_id}.json"
assert "error" not in body assert "error" not in body
result = json.loads(env.storage.objects[(BUCKET, key)].decode("utf-8")) result = json.loads(env.storage.objects[(BUCKET, key)].decode("utf-8"))
@ -264,6 +271,7 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu
assert result["service"]["name"] == "scan-gateway" assert result["service"]["name"] == "scan-gateway"
assert result["raw_s3_key"] == f"raw/{job_id}.json" assert result["raw_s3_key"] == f"raw/{job_id}.json"
assert result["documents"] == STATEMENT_EXAMPLE["documents"] assert result["documents"] == STATEMENT_EXAMPLE["documents"]
assert result["schema_version"] == "2.0" and result["service"]["statements_version"] == "abc1234"
assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE
# нормализатор получает склеенный ответ — тот же объект, что записан в raw/ # нормализатор получает склеенный ответ — тот же объект, что записан в raw/
assert normalizer.calls == [merged_raw(job_id, (f"{job_id}.json", {**SUCCESS_ANSWER, "file_uid": job_id}, None))] assert normalizer.calls == [merged_raw(job_id, (f"{job_id}.json", {**SUCCESS_ANSWER, "file_uid": job_id}, None))]
@ -273,7 +281,8 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu
async def test_result_file_write_failure_is_unknown_error_with_raw_key(make_env: EnvFactory) -> None: async def test_result_file_write_failure_is_unknown_error_with_raw_key(make_env: EnvFactory) -> None:
document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0])
env = await make_env(normalizer=StubNormalizer(NormalizeOutcome(status=ResultStatus.DONE, documents=[document]))) outcome = NormalizeOutcome(status=ResultStatus.DONE, documents=[document], statements_version="abc1234")
env = await make_env(normalizer=StubNormalizer(outcome))
env.storage.fail_put_prefixes = ("results/",) env.storage.fail_put_prefixes = ("results/",)
request = env.request() request = env.request()
async with emulator(env): async with emulator(env):
@ -323,8 +332,9 @@ async def test_answer_not_json_is_parse_error_and_invalid_format(make_env: EnvFa
assert entry["response"] is None assert entry["response"] is None
assert entry["parse_error"] assert entry["parse_error"]
[warning] = body["metrics"]["warnings"] [warning] = body["metrics"]["warnings"]
assert warning.startswith(f"{job_id}.json: INVALID_FORMAT: ") assert (warning["code"], warning["severity"], warning["locator"]) == ("JSON_FAILED", "error", f"file:{job_id}.json")
assert entry["parse_error"] in warning assert warning["message"].startswith("INVALID_FORMAT: ")
assert entry["parse_error"] in warning["message"]
async def test_invalid_json_after_settle_is_not_reread(make_env: EnvFactory) -> None: async def test_invalid_json_after_settle_is_not_reread(make_env: EnvFactory) -> None:
@ -374,7 +384,7 @@ async def test_one_erroneous_file_does_not_fail_the_job(make_env: EnvFactory) ->
body = await env.run(request) body = await env.run(request)
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE
assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} assert body["metrics"] == {"warnings": [_failed(f"{job_id}_ofr.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}")]}
async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) -> None: async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) -> None:
@ -391,8 +401,8 @@ async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) ->
body = await env.run(request) body = await env.run(request)
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
bad, listed = body["metrics"]["warnings"] bad, listed = body["metrics"]["warnings"]
assert bad.startswith(f"{job_id}_bad.json: INVALID_FORMAT: ") assert bad["locator"] == f"file:{job_id}_bad.json" and bad["message"].startswith("INVALID_FORMAT: ")
assert listed == f"{job_id}_list.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}" assert listed == _failed(f"{job_id}_list.json", f"INVALID_FORMAT: {NOT_OBJECT_MESSAGE}")
files = _raw(env, job_id)["files"] files = _raw(env, job_id)["files"]
assert [f["name"] for f in files] == [f"{job_id}.json", f"{job_id}_bad.json", f"{job_id}_list.json"] assert [f["name"] for f in files] == [f"{job_id}.json", f"{job_id}_bad.json", f"{job_id}_list.json"]
assert files[1]["response"] is None and files[1]["parse_error"] assert files[1]["response"] is None and files[1]["parse_error"]
@ -411,8 +421,8 @@ async def test_all_files_erroneous_take_code_of_first(make_env: EnvFactory) -> N
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert body["error"]["message"] == REAL_MESSAGE assert body["error"]["message"] == REAL_MESSAGE
first, second = body["metrics"]["warnings"] first, second = body["metrics"]["warnings"]
assert first == f"{job_id}.json: INVALID_DOCUMENT: {REAL_MESSAGE}" assert first == _failed(f"{job_id}.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}")
assert second.startswith(f"{job_id}_ofr.json: INVALID_FORMAT: ") assert second["locator"] == f"file:{job_id}_ofr.json" and second["message"].startswith("INVALID_FORMAT: ")
async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env: EnvFactory) -> None: async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env: EnvFactory) -> None:
@ -424,7 +434,7 @@ async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env:
body = await env.run(request) body = await env.run(request)
assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True)
assert "SOME_NEW_CODE" in body["error"]["message"] assert "SOME_NEW_CODE" in body["error"]["message"]
assert body["metrics"] == {"warnings": [f"{job_id}.json: SOME_NEW_CODE: новое"]} assert body["metrics"] == {"warnings": [_failed(f"{job_id}.json", "SOME_NEW_CODE: новое")]}
async def _unserialisable_depth() -> bytes: async def _unserialisable_depth() -> bytes:
@ -484,7 +494,7 @@ async def test_real_aep_3_with_ofr_is_not_implemented_with_one_warning(make_env:
env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", main), AnswerFile(f"{job_id}_ofr.json", ofr)]] env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", main), AnswerFile(f"{job_id}_ofr.json", ofr)]]
body = await env.run(request) body = await env.run(request)
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} assert body["metrics"] == {"warnings": [_failed(f"{job_id}_ofr.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}")]}
assert _raw(env, job_id) == merged_raw( assert _raw(env, job_id) == merged_raw(
job_id, (f"{job_id}.json", json.loads(main), None), (f"{job_id}_ofr.json", json.loads(ofr), None) job_id, (f"{job_id}.json", json.loads(main), None), (f"{job_id}_ofr.json", json.loads(ofr), None)
) )
@ -493,14 +503,20 @@ async def test_real_aep_3_with_ofr_is_not_implemented_with_one_warning(make_env:
@pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) @pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK])
async def test_outcome_warnings_join_metrics_in_envelope_and_result(make_env: EnvFactory, status: ResultStatus) -> None: async def test_outcome_warnings_join_metrics_in_envelope_and_result(make_env: EnvFactory, status: ResultStatus) -> None:
document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0])
own = FileWarning(code="OWN", severity=Severity.INFO, message="свои")
joined = FileWarning(code="JSON_FAILED", severity=Severity.ERROR, message="X: y", locator="file:a.json")
outcome = NormalizeOutcome( outcome = NormalizeOutcome(
status=status, documents=[document], metrics=Metrics(pages=2, warnings=["свои"]), warnings=["a.json: X: y"] status=status,
documents=[document],
metrics=Metrics(pages=2, warnings=[own]),
warnings=[joined],
statements_version="abc1234",
) )
env = await make_env(normalizer=StubNormalizer(outcome)) env = await make_env(normalizer=StubNormalizer(outcome))
request = env.request() request = env.request()
async with emulator(env): async with emulator(env):
body = await env.run(request) body = await env.run(request)
assert body["metrics"] == {"pages": 2, "warnings": ["свои", "a.json: X: y"]} assert body["metrics"] == {"pages": 2, "warnings": [own.to_wire(), joined.to_wire()]}
result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")]) result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")])
RESULT_VALIDATOR.validate(result) RESULT_VALIDATOR.validate(result)
assert result["metrics"] == body["metrics"] assert result["metrics"] == body["metrics"]
@ -517,22 +533,55 @@ async def test_error_outcome_without_warnings_keeps_its_metrics(make_env: EnvFac
async def test_huge_warnings_are_bounded(make_env: EnvFactory) -> None: async def test_huge_warnings_are_bounded(make_env: EnvFactory) -> None:
"""R-30: число строк warnings и длина каждой ограничены — конверт остаётся меньше лимита NATS.""" """P5-10: 300 предупреждений (100 error и 200 info вперемешку) — в конверте ровно 100, все error среди них, порядок
lines = [f"f{i}.json: INVALID_DOCUMENT: " + "я" * 50_000 for i in range(MAX_WARNINGS * 3)] исходный, `message` не длиннее MAX_WARNING_CHARS; файл результата несёт тот же список."""
warnings = [
FileWarning(
code="JSON_FAILED" if i % 3 == 0 else "ROW_SPLIT",
severity=Severity.ERROR if i % 3 == 0 else Severity.INFO,
message=f"w{i}: " + "я" * 50_000,
)
for i in range(MAX_WARNINGS * 3)
]
document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0])
outcome = NormalizeOutcome( outcome = NormalizeOutcome(
status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message="m"), warnings=lines status=ResultStatus.NEEDS_CHECK, documents=[document], warnings=warnings, statements_version="abc1234"
)
env = await make_env(normalizer=StubNormalizer(outcome))
request = env.request()
async with emulator(env):
body = await env.run(request)
sent = body["metrics"]["warnings"]
assert len(sent) == MAX_WARNINGS
assert all(w["severity"] == "error" for w in sent) # все 100 error — ровно предел, info не остаётся
assert all(len(w["message"]) <= MAX_WARNING_CHARS for w in sent)
indexes = [int(w["message"].split(":")[0][1:]) for w in sent]
assert indexes == sorted(indexes)
result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")])
assert result["metrics"]["warnings"] == sent
assert all(len(payload) < 900_000 for _, payload, _ in env.client.published)
async def test_all_errors_survive_when_warnings_overflow(make_env: EnvFactory) -> None:
"""P5-10: error — 40 штук среди 150 — остаются все, остальное место занимают первые info."""
warnings = [
FileWarning(
code="JSON_FAILED" if i % 15 < 4 else "ROW_SPLIT",
severity=Severity.ERROR if i % 15 < 4 else Severity.INFO,
message=f"w{i}",
)
for i in range(MAX_WARNINGS + 50 + 5)
]
errors = [w.message for w in warnings if w.severity is Severity.ERROR]
outcome = NormalizeOutcome(
status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message="m"), warnings=warnings
) )
env = await make_env(normalizer=StubNormalizer(outcome)) env = await make_env(normalizer=StubNormalizer(outcome))
async with emulator(env): async with emulator(env):
body = await env.run(env.request()) body = await env.run(env.request())
warnings = body["metrics"]["warnings"] sent = body["metrics"]["warnings"]
assert len(warnings) == MAX_WARNINGS assert len(sent) == MAX_WARNINGS
assert all(len(line) <= MAX_WARNING_CHARS for line in warnings) assert [w["message"] for w in sent if w["severity"] == "error"] == errors
assert warnings[0].startswith("f0.json: INVALID_DOCUMENT: я")
assert warnings[-2].startswith(f"f{MAX_WARNINGS - 2}.json")
assert str(MAX_WARNINGS * 3 - (MAX_WARNINGS - 1)) in warnings[-1]
assert f"raw/{body['job_id']}.json" in warnings[-1]
assert all(len(payload) < 900_000 for _, payload, _ in env.client.published)
async def test_answer_ready_only_after_settle(make_env: EnvFactory) -> None: async def test_answer_ready_only_after_settle(make_env: EnvFactory) -> None:
@ -842,8 +891,8 @@ async def test_lone_surrogate_in_is_error_message_does_not_break_publishing(make
assert body["error"]["message"].startswith("bad ") assert body["error"]["message"].startswith("bad ")
assert body["error"]["message"].endswith(" tail") assert body["error"]["message"].endswith(" tail")
[warning] = body["metrics"]["warnings"] [warning] = body["metrics"]["warnings"]
assert warning.startswith(f"{job_id}.json: INVALID_DOCUMENT: bad ") assert warning["locator"] == f"file:{job_id}.json"
assert warning.endswith(" tail") assert warning["message"].startswith("INVALID_DOCUMENT: bad ") and warning["message"].endswith(" tail")
# сырой ответ записан валидным JSON, суррогат сохранён экранированным # сырой ответ записан валидным JSON, суррогат сохранён экранированным
assert _raw(env, job_id)["files"][0]["response"]["error"]["message"] == "bad \ud800 tail" assert _raw(env, job_id)["files"][0]["response"]["error"]["message"] == "bad \ud800 tail"
@ -964,7 +1013,7 @@ class TestSlice3a:
assert env.inbox() == [f"{body['job_id']}.pdf"] assert env.inbox() == [f"{body['job_id']}.pdf"]
async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None: async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:
env = await make_env(normalizer=OcrNormalizer(load_rules(API_DIR / "rules"))) env = await make_env(normalizer=OcrNormalizer(version="abc1234"))
body = env.request() body = env.request()
async with emulator(env): async with emulator(env):
result = await env.run(body) result = await env.run(body)
@ -972,5 +1021,9 @@ class TestSlice3a:
key = result["result_s3"]["key"] key = result["result_s3"]["key"]
stored = json.loads(env.storage.objects[(BUCKET, key)]) stored = json.loads(env.storage.objects[(BUCKET, key)])
assert stored["documents"][0]["document_type"] == "BALANCE" assert stored["documents"][0]["document_type"] == "BALANCE"
assert stored["documents"][0]["lines"][0]["line_code"] == "1600" # в SUCCESS_ANSWER одна строка (без шапки таблицы): столбец кодов не найден, строк у документа нет
assert stored["documents"][0]["lines"] == []
assert "CODE_COLUMN_NOT_FOUND" in [w["code"] for w in stored["documents"][0]["warnings"]]
assert stored["schema_version"] == "2.0" and stored["service"]["statements_version"] == "abc1234"
RESULT_VALIDATOR.validate(stored)
assert result["raw_s3_key"] == f"raw/{body['job_id']}.json" assert result["raw_s3_key"] == f"raw/{body['job_id']}.json"

View File

@ -253,7 +253,7 @@ class Env:
body = json.loads(payload.decode("utf-8")) body = json.loads(payload.decode("utf-8"))
MESSAGE_VALIDATOR.validate(body) MESSAGE_VALIDATOR.validate(body)
assert body["service"]["name"] == "scan-gateway" assert body["service"]["name"] == "scan-gateway"
assert body["schema_version"] == "1.0" assert body["schema_version"] == "2.0"
out.append((headers[NATS_MSG_ID_HEADER], body)) out.append((headers[NATS_MSG_ID_HEADER], body))
return out return out