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:
parent
3b9e1f6e19
commit
ae2922eb30
@ -1,8 +1,11 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
|
||||
"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",
|
||||
"error": {
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
|
||||
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
|
||||
"status": "done",
|
||||
"service": { "name": "scan-gateway", "version": "0.1.0" },
|
||||
"service": {
|
||||
"name": "scan-gateway",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"result_s3": {
|
||||
"bucket": "arm-ka",
|
||||
"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",
|
||||
"metrics": {
|
||||
"duration_ms": 18400,
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
|
||||
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
|
||||
"status": "error",
|
||||
"service": { "name": "tabular-parser", "version": "0.1.0" },
|
||||
"service": {
|
||||
"name": "tabular-parser",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"error": {
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
"message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet"
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
|
||||
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
|
||||
"status": "processing",
|
||||
"service": { "name": "tabular-parser", "version": "0.1.0" }
|
||||
"service": {
|
||||
"name": "tabular-parser",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
{
|
||||
"schema_version": "1.2",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
|
||||
"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",
|
||||
"documents": [
|
||||
{
|
||||
@ -24,7 +27,11 @@
|
||||
"header_variant": 1
|
||||
},
|
||||
"group_schema": [
|
||||
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
|
||||
{
|
||||
"type": "COUNTERPARTY",
|
||||
"name": "Контрагенты",
|
||||
"has_inn": true
|
||||
}
|
||||
],
|
||||
"columns": {
|
||||
"opening_debit": "C",
|
||||
@ -70,14 +77,18 @@
|
||||
"turnover_credit": "200.50",
|
||||
"closing_debit": "0.00",
|
||||
"closing_credit": "700.50",
|
||||
"extra": { "КПП": "352801001" }
|
||||
"extra": {
|
||||
"КПП": "352801001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"total_row_matches_root": true,
|
||||
"balance_identity_violations": [],
|
||||
"parent_sum_violations": []
|
||||
}
|
||||
},
|
||||
"status": "done",
|
||||
"warnings": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.1",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
|
||||
"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",
|
||||
"metrics": {
|
||||
"rows": 3,
|
||||
"warnings": [
|
||||
"sheet=Лист_1 hierarchy=outline",
|
||||
"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3"
|
||||
]
|
||||
"warnings": []
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
@ -28,8 +28,15 @@
|
||||
"header_variant": 1
|
||||
},
|
||||
"group_schema": [
|
||||
{ "type": "ACCOUNT", "name": "Счет" },
|
||||
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
|
||||
{
|
||||
"type": "ACCOUNT",
|
||||
"name": "Счет"
|
||||
},
|
||||
{
|
||||
"type": "COUNTERPARTY",
|
||||
"name": "Контрагенты",
|
||||
"has_inn": true
|
||||
}
|
||||
],
|
||||
"columns": {
|
||||
"opening_debit": "D",
|
||||
@ -76,7 +83,9 @@
|
||||
"turnover_credit": "200.50",
|
||||
"closing_debit": "0.00",
|
||||
"closing_credit": "700.50",
|
||||
"extra": { "КПП": "352801001" }
|
||||
"extra": {
|
||||
"КПП": "352801001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"row_number": 3,
|
||||
@ -117,9 +126,24 @@
|
||||
],
|
||||
"checks": {
|
||||
"total_row_matches_root": true,
|
||||
"balance_identity_violations": [3],
|
||||
"balance_identity_violations": [
|
||||
3
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,44 +1,99 @@
|
||||
{
|
||||
"schema_version": "1.2",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30",
|
||||
"file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
"service": { "name": "tabular-parser", "version": "0.3.0" },
|
||||
"status": "done",
|
||||
"metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] },
|
||||
"service": { "name": "tabular-parser", "version": "0.4.0", "statements_version": "abc1234" },
|
||||
"status": "needs_check",
|
||||
"metrics": { "duration_ms": 412, "rows": 12, "warnings": [] },
|
||||
"documents": [
|
||||
{
|
||||
"document_type": "BALANCE",
|
||||
"locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2",
|
||||
"header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" },
|
||||
"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": [
|
||||
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" },
|
||||
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" }
|
||||
{ "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" },
|
||||
{ "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" }
|
||||
],
|
||||
"lines": [
|
||||
{ "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] },
|
||||
{ "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] },
|
||||
{ "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] },
|
||||
{ "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] },
|
||||
{ "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }
|
||||
{ "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,
|
||||
"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": 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,
|
||||
"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 } ] },
|
||||
{ "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",
|
||||
"locator": "sheet:Отчет о финансовых результатах",
|
||||
"header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" },
|
||||
"columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ],
|
||||
"lines": [
|
||||
{ "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] },
|
||||
{ "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] },
|
||||
{ "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] }
|
||||
"status": "needs_check",
|
||||
"form_variant": "full",
|
||||
"warnings": [
|
||||
{ "code": "INN_NOT_FOUND", "severity": "info", "message": "в шапке не найден ИНН", "field": "inn" }
|
||||
],
|
||||
"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",
|
||||
"locator": "sheet:Пояснения",
|
||||
"header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" },
|
||||
"status": "done",
|
||||
"form_variant": null,
|
||||
"warnings": [],
|
||||
"header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах", "needs_review_fields": [] },
|
||||
"columns": [],
|
||||
"lines": []
|
||||
}
|
||||
|
||||
@ -1,50 +1,51 @@
|
||||
{
|
||||
"schema_version": "1.2",
|
||||
"schema_version": "2.0",
|
||||
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
|
||||
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
|
||||
"service": { "name": "scan-gateway", "version": "0.1.0" },
|
||||
"status": "done",
|
||||
"service": { "name": "scan-gateway", "version": "0.2.0", "statements_version": "abc1234" },
|
||||
"status": "needs_check",
|
||||
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
|
||||
"metrics": { "duration_ms": 18400, "pages": 2, "warnings": [] },
|
||||
"documents": [
|
||||
{
|
||||
"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": {
|
||||
"form_name": "Бухгалтерский баланс",
|
||||
"organization_name": "ООО Ромашка",
|
||||
"organization_name_confidence": 97.5,
|
||||
"inn": "7700000000",
|
||||
"inn_confidence": 99,
|
||||
"report_date_text": "30 сентября 2025 г.",
|
||||
"report_date": "2025-09-30",
|
||||
"units": "тыс. руб."
|
||||
"organization_name": "ООО Ромашка", "organization_name_text": "ООО Ромашка", "organization_name_confidence": 97.5,
|
||||
"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,
|
||||
"needs_review_fields": []
|
||||
},
|
||||
"columns": [
|
||||
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" },
|
||||
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" }
|
||||
{ "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }
|
||||
],
|
||||
"lines": [
|
||||
{
|
||||
"line_code": "1100",
|
||||
"line_code_confidence": 99,
|
||||
"line_name": "Итого по разделу I",
|
||||
"line_name_confidence": 96,
|
||||
"values": [
|
||||
{ "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true },
|
||||
{ "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"line_code": "1600",
|
||||
"line_code_confidence": 99,
|
||||
"line_name": "БАЛАНС (актив)",
|
||||
"line_name_confidence": 98,
|
||||
"values": [
|
||||
{ "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true },
|
||||
{ "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
{ "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,
|
||||
"values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] },
|
||||
{ "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,
|
||||
"values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
|
||||
{ "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": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": 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,
|
||||
"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,
|
||||
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
|
||||
{ "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,
|
||||
"values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] },
|
||||
{ "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": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }
|
||||
],
|
||||
"checks": { "relations_failed": [], "soft_relations_failed": [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "arm-ka/recognition-result-message/v1",
|
||||
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
|
||||
"$id": "arm-ka/recognition-result-message/v2",
|
||||
"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).",
|
||||
"$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",
|
||||
"required": ["schema_version", "job_id", "file_id", "status", "service"],
|
||||
"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" },
|
||||
"file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" },
|
||||
"status": { "enum": ["processing", "done", "needs_check", "error"] },
|
||||
@ -47,7 +47,22 @@
|
||||
"fields_total": { "type": "integer" },
|
||||
"fields_low_confidence": { "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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "arm-ka/recognition-result/v1",
|
||||
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2",
|
||||
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.",
|
||||
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.",
|
||||
"$id": "arm-ka/recognition-result/v2",
|
||||
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0",
|
||||
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.",
|
||||
"$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",
|
||||
"required": ["schema_version", "job_id", "file_id", "service", "status", "documents"],
|
||||
"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" },
|
||||
"file_id": { "type": "string", "format": "uuid" },
|
||||
"service": {
|
||||
@ -15,10 +15,11 @@
|
||||
"required": ["name", "version"],
|
||||
"properties": {
|
||||
"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": {
|
||||
"type": ["object", "null"],
|
||||
"properties": {
|
||||
@ -35,7 +36,7 @@
|
||||
"fields_total": { "type": "integer" },
|
||||
"fields_low_confidence": { "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 — сырой ответ ИС Сканирование; в БД не регистрируется" },
|
||||
@ -44,31 +45,68 @@
|
||||
"items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] }
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"properties": { "status": { "enum": ["done", "needs_check"] } },
|
||||
"required": ["status"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "documents": { "minItems": 1 } }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "status": { "enum": ["done", "needs_check"] } },
|
||||
"required": ["status"]
|
||||
},
|
||||
"then": {
|
||||
"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": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"required": ["column_index", "relation", "expected", "actual"],
|
||||
"required": ["column_index", "relation", "codes", "expected", "actual"],
|
||||
"properties": {
|
||||
"column_index": { "type": "integer" },
|
||||
"relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] },
|
||||
"expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" },
|
||||
"actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" }
|
||||
"codes": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^\\d{4,5}$" }, "description": "коды цели и членов соотношения: нарушение подсвечивает все строки этих кодов в колонке" },
|
||||
"expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения (цель) по строкам ok; null — цели нет" },
|
||||
"actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам ok" }
|
||||
}
|
||||
},
|
||||
"osvDocument": {
|
||||
"type": "object",
|
||||
"required": ["document_type", "locator", "header", "group_schema", "rows"],
|
||||
"required": ["document_type", "locator", "header", "group_schema", "rows", "status", "warnings"],
|
||||
"properties": {
|
||||
"document_type": { "const": "OSV" },
|
||||
"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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"type": "object",
|
||||
"required": ["document_type", "locator", "header", "columns", "lines"],
|
||||
"required": ["document_type", "form_variant", "locator", "header", "columns", "lines", "status", "warnings"],
|
||||
"properties": {
|
||||
"document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] },
|
||||
"locator": { "type": "string", "examples": ["pages:1-2"] },
|
||||
"document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"], "description": "OTHER в v2.0 не порождается: лист «прочее» — предупреждение файла SHEET_OTHER, документа нет (значение OTHER — только в схеме 1.x)" },
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"form_name": { "type": "string" },
|
||||
"form_name": { "type": ["string", "null"], "description": "заголовок формы как в источнике" },
|
||||
"organization_name": { "type": ["string", "null"] },
|
||||
"organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
|
||||
"inn": { "type": ["string", "null"] },
|
||||
"inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
|
||||
"report_date_text": { "type": ["string", "null"] },
|
||||
"organization_name_text": { "type": ["string", "null"] },
|
||||
"organization_name_confidence": { "$ref": "#/$defs/confidence" },
|
||||
"inn": { "type": ["string", "null"], "pattern": "^(\\d{10}|\\d{12})$" },
|
||||
"inn_text": { "type": ["string", "null"] },
|
||||
"inn_confidence": { "$ref": "#/$defs/confidence" },
|
||||
"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_text": { "type": ["string", "null"] },
|
||||
"units_confidence": { "$ref": "#/$defs/confidence" },
|
||||
"okud": { "type": ["string", "null"] },
|
||||
"okud_text": { "type": ["string", "null"] },
|
||||
"okud_confidence": { "$ref": "#/$defs/confidence" },
|
||||
"okpo": { "type": ["string", "null"] },
|
||||
"okpo_text": { "type": ["string", "null"] },
|
||||
"okpo_confidence": { "$ref": "#/$defs/confidence" },
|
||||
"okopf": { "type": ["string", "null"] },
|
||||
"okopf_text": { "type": ["string", "null"] },
|
||||
"okopf_confidence": { "$ref": "#/$defs/confidence" },
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["column_index", "label"],
|
||||
"required": ["column_index", "label", "period_kind"],
|
||||
"properties": {
|
||||
"column_index": { "type": "integer" },
|
||||
"label": { "type": "string", "examples": ["На 30 сентября 2025 г."] },
|
||||
"period_date": { "type": ["string", "null"], "format": "date" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" },
|
||||
"label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] },
|
||||
"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": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" },
|
||||
"checks": {
|
||||
"type": "object",
|
||||
"description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».",
|
||||
"required": ["relations_failed", "soft_relations_failed"],
|
||||
"description": "Самопроверка формы по соотношениям §7 общим кодом (оба конвейера). Есть у BALANCE/INCOME со строками, нет у документа без строк. Пустые массивы — «проверено, сошлось».",
|
||||
"properties": {
|
||||
"relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" },
|
||||
"soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" },
|
||||
"duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" }
|
||||
"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): info" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"] } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
`GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`),
|
||||
`subjects` (`image_request`, `result` — subject'ы NATS из настроек).
|
||||
- lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер
|
||||
(`Worker` с `OcrNormalizer`). Справочники нормализатора загружаются из `SCAN_RULES_DIR` в `create_app`: битый
|
||||
справочник — `ValueError`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт
|
||||
(`Worker` с `OcrNormalizer`). Словари нормализатора — `default_rules()` пакета `arm_statements`; копия без `VERSION` —
|
||||
`ValueError` в `create_app`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт
|
||||
фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка на subject запроса `image` появляется после
|
||||
подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`,
|
||||
HTTP-сервер жив.
|
||||
@ -35,7 +35,6 @@ from src.core.nats import NatsGateway, used_subjects
|
||||
from src.core.worker import Worker
|
||||
from src.gateway import create_hotfolder
|
||||
from src.normalizer import OcrNormalizer
|
||||
from src.normalizer.rules import load_rules
|
||||
|
||||
|
||||
async def run_nats(gateway: NatsGateway, worker: Worker) -> None:
|
||||
@ -66,7 +65,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
version = settings.VERSION
|
||||
up_time = datetime.now(UTC)
|
||||
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)
|
||||
health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder, subjects=subjects)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`).
|
||||
"""Контракт обмена с сервисами распознавания v2.0 (запрос — v1.0; схемы — `api/contracts/`, копии `docs/contracts/`).
|
||||
|
||||
Публичный API: pydantic-модели запроса, конверта и файла результата, конструкторы конвертов, subject'ы NATS,
|
||||
идентификаторы сообщений (`Nats-Msg-Id`), проверка MAJOR версии.
|
||||
@ -8,11 +8,14 @@ from src.contract.common import (
|
||||
CONTRACT_VERSION,
|
||||
SUPPORTED_MAJOR,
|
||||
ContractModel,
|
||||
DocumentWarning,
|
||||
ErrorCode,
|
||||
FileWarning,
|
||||
Metrics,
|
||||
S3Ref,
|
||||
ServiceInfo,
|
||||
ServiceName,
|
||||
Severity,
|
||||
is_supported_major,
|
||||
)
|
||||
from src.contract.message import (
|
||||
@ -52,8 +55,10 @@ __all__ = [
|
||||
"SUPPORTED_MAJOR",
|
||||
"TABULAR_REQUEST_SUBJECT",
|
||||
"ContractModel",
|
||||
"DocumentWarning",
|
||||
"ErrorCode",
|
||||
"FileType",
|
||||
"FileWarning",
|
||||
"MessageError",
|
||||
"MessageStatus",
|
||||
"Metrics",
|
||||
@ -66,6 +71,7 @@ __all__ = [
|
||||
"S3Ref",
|
||||
"ServiceInfo",
|
||||
"ServiceName",
|
||||
"Severity",
|
||||
"StatementDocument",
|
||||
"ack_msg_id",
|
||||
"build_ack_message",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки.
|
||||
"""Общие основы моделей контракта v2.0 (запрос — v1.0): базовый класс, версия, типы, метрики и ошибки.
|
||||
|
||||
Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false`
|
||||
(кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) —
|
||||
@ -27,20 +27,21 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
|
||||
CONTRACT_VERSION = "1.0"
|
||||
SUPPORTED_MAJOR = 1 # сервис принимает запросы только этого MAJOR; любой MINOR
|
||||
CONTRACT_VERSION = "2.0" # файл результата и конверт (включая ack); запрос остаётся 1.x (P5-13)
|
||||
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
|
||||
|
||||
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:
|
||||
"""MAJOR версии `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`).
|
||||
"""MAJOR версии запроса `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`).
|
||||
|
||||
Принимает любой объект (версия берётся из ещё не проверенного тела): не строка и не `MAJOR.MINOR` — `False`.
|
||||
"""
|
||||
@ -188,6 +189,7 @@ HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)]
|
||||
|
||||
NonEmptyStr = Annotated[str, Field(min_length=1)]
|
||||
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)]
|
||||
# Уверенность распознавания, проценты (число 0–100)
|
||||
Confidence = Annotated[float, Field(ge=0, le=100)] | None
|
||||
@ -223,6 +225,7 @@ class ServiceInfo(ContractModel):
|
||||
|
||||
name: LaxServiceName
|
||||
version: str
|
||||
statements_version: OptionalNotNull[NonEmptyStr] = None # коммит arm-statements; только в файле результата
|
||||
|
||||
|
||||
class S3Ref(ContractModel):
|
||||
@ -232,6 +235,36 @@ class S3Ref(ContractModel):
|
||||
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):
|
||||
"""Метрики обработки; все поля необязательны."""
|
||||
|
||||
@ -241,7 +274,7 @@ class Metrics(ContractModel):
|
||||
fields_total: OptionalNotNull[IntLike] = None
|
||||
fields_low_confidence: 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"]
|
||||
|
||||
@ -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` — итог.
|
||||
Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и
|
||||
|
||||
@ -9,14 +9,22 @@ from typing import Annotated, Literal
|
||||
|
||||
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
|
||||
|
||||
FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"]
|
||||
|
||||
|
||||
class RecognitionRequest(ContractModel):
|
||||
schema_version: SchemaVersion
|
||||
schema_version: RequestSchemaVersion
|
||||
job_id: ContractUUID # recognition_job.id
|
||||
file_id: ContractUUID # stored_file.id
|
||||
pipeline: Pipeline
|
||||
|
||||
@ -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 — модели и валидация примеров;
|
||||
маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`).
|
||||
@ -8,7 +8,7 @@
|
||||
from enum import StrEnum
|
||||
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 (
|
||||
Confidence,
|
||||
@ -16,11 +16,14 @@ from src.contract.common import (
|
||||
ContractModel,
|
||||
ContractUUID,
|
||||
DataType,
|
||||
DocumentWarning,
|
||||
HeaderFieldName,
|
||||
HeaderVariant,
|
||||
IntLike,
|
||||
LaxErrorCode,
|
||||
Metrics,
|
||||
Money,
|
||||
NonEmptyStr,
|
||||
OptionalNotNull,
|
||||
SchemaVersion,
|
||||
ServiceInfo,
|
||||
@ -45,6 +48,9 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
DocumentStatus = Literal["done", "needs_check"]
|
||||
|
||||
|
||||
class ResultStatus(StrEnum):
|
||||
DONE = "done"
|
||||
NEEDS_CHECK = "needs_check"
|
||||
@ -117,6 +123,8 @@ class OsvChecks(ContractModel):
|
||||
class OsvDocument(ContractModel):
|
||||
document_type: Literal["OSV"]
|
||||
locator: str # «sheet:Лист_1»
|
||||
status: DocumentStatus # done — контрольные суммы §1.4 листа сошлись, иначе needs_check
|
||||
warnings: list[DocumentWarning] # все уровня info, код с префиксом OSV_
|
||||
header: OsvHeader
|
||||
group_schema: list[OsvGroup]
|
||||
columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН
|
||||
@ -124,75 +132,148 @@ class OsvDocument(ContractModel):
|
||||
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):
|
||||
form_name: OptionalNotNull[str] = None
|
||||
"""Шапка формы: значение, сырой текст и уверенность каждого поля (схема v2.0, все поля необязательны)."""
|
||||
|
||||
form_name: str | None = None
|
||||
organization_name: str | None = None
|
||||
organization_name_text: str | None = 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
|
||||
report_date_text: str | None = None
|
||||
report_date: ContractDate | None = None
|
||||
report_date_text: str | None = None
|
||||
report_date_confidence: Confidence = None
|
||||
units: str | None = None # «тыс. руб.»
|
||||
units_text: str | None = None
|
||||
units_confidence: Confidence = None
|
||||
okud: str | None = None
|
||||
okud_text: str | None = None
|
||||
okud_confidence: Confidence = None
|
||||
okpo: str | None = None
|
||||
okpo_text: str | None = None
|
||||
okpo_confidence: Confidence = None
|
||||
okopf: str | None = None
|
||||
okopf_text: str | None = None
|
||||
okopf_confidence: Confidence = 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_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):
|
||||
column_index: IntLike
|
||||
column_index: NonNegativeInt # номер столбца сетки кандидата шага 1; уникален в документе
|
||||
label: str # «На 30 сентября 2025 г.»
|
||||
label_confidence: Confidence = None
|
||||
period_date: ContractDate | None = None
|
||||
period_kind: PeriodKind
|
||||
|
||||
|
||||
class StatementValue(ContractModel):
|
||||
column_index: IntLike
|
||||
amount: Money = None
|
||||
amount: Money
|
||||
string_value: str | None = None
|
||||
confidence: Confidence = None
|
||||
is_recognized: OptionalNotNull[bool] = None
|
||||
text: str | None
|
||||
confidence: Confidence
|
||||
is_recognized: bool
|
||||
needs_review: bool
|
||||
|
||||
|
||||
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_name: str
|
||||
line_name: str | None
|
||||
line_name_confidence: Confidence = None
|
||||
parent_ref: str | None = None
|
||||
needs_review: bool
|
||||
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):
|
||||
"""Нарушенное соотношение §7 по одной колонке.
|
||||
|
||||
`expected` — левая часть как в файле, `actual` — правая по строкам.
|
||||
`expected` — левая часть (цель), `actual` — правая по строкам ok; `null` — цели нет или все члены пусты.
|
||||
"""
|
||||
|
||||
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
|
||||
actual: Money
|
||||
|
||||
|
||||
class StatementChecks(ContractModel):
|
||||
"""Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось»."""
|
||||
"""Самопроверка формы общим кодом: пустые списки — «проверено, сошлось»."""
|
||||
|
||||
relations_failed: list[FailedRelation] = Field(default_factory=list)
|
||||
soft_relations_failed: list[FailedRelation] = Field(default_factory=list)
|
||||
duplicate_line_codes: list[str] = Field(default_factory=list)
|
||||
relations_failed: list[FailedRelation]
|
||||
soft_relations_failed: list[FailedRelation]
|
||||
|
||||
|
||||
class StatementDocument(ContractModel):
|
||||
document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"]
|
||||
locator: str # «pages:1-2»
|
||||
document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]
|
||||
form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE
|
||||
locator: str # «file:aep_3.json;table:0»
|
||||
status: DocumentStatus
|
||||
warnings: list[DocumentWarning]
|
||||
header: StatementHeader
|
||||
columns: list[StatementColumn]
|
||||
lines: list[StatementLine]
|
||||
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")]
|
||||
|
||||
@ -214,3 +295,10 @@ class RecognitionResult(ContractModel):
|
||||
if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents:
|
||||
raise ValueError(f"при status = {self.status.value} список documents не может быть пустым")
|
||||
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
|
||||
|
||||
@ -38,6 +38,7 @@ from pydantic import ValidationError
|
||||
from src.contract import (
|
||||
CONTRACT_VERSION,
|
||||
ErrorCode,
|
||||
FileWarning,
|
||||
Metrics,
|
||||
RecognitionRequest,
|
||||
RecognitionResult,
|
||||
@ -45,6 +46,7 @@ from src.contract import (
|
||||
ResultStatus,
|
||||
S3Ref,
|
||||
ServiceInfo,
|
||||
Severity,
|
||||
build_ack_message,
|
||||
build_done_message,
|
||||
build_error_message,
|
||||
@ -65,6 +67,7 @@ __all__ = [
|
||||
"ObjectStorage",
|
||||
"ResultPublisher",
|
||||
"Worker",
|
||||
"build_result",
|
||||
"raw_key",
|
||||
"result_key",
|
||||
"utc_now",
|
||||
@ -73,8 +76,8 @@ __all__ = [
|
||||
# Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на
|
||||
# символ (T10.1). Длинное сообщение обрезается с многоточием.
|
||||
MAX_ERROR_MESSAGE_CHARS = 4000
|
||||
# Предел `metrics.warnings` конверта (R-30): не больше MAX_WARNINGS строк по MAX_WARNING_CHARS символов — конверт
|
||||
# остаётся далеко меньше лимита NATS (1 МБ) при любом числе ошибочных файлов; лишние строки сводятся в последнюю
|
||||
# Предел `metrics.warnings` конверта (R-30, P5-10): не больше MAX_WARNINGS структур, `message` не длиннее
|
||||
# MAX_WARNING_CHARS символов — конверт остаётся далеко меньше лимита NATS (1 МБ); при переполнении остаются все `error`
|
||||
MAX_WARNINGS = 100
|
||||
MAX_WARNING_CHARS = 1000
|
||||
# Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов
|
||||
@ -576,7 +579,7 @@ class Worker:
|
||||
return self._error(
|
||||
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:
|
||||
error = outcome.error
|
||||
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)
|
||||
|
||||
# 6. Успех: файл результата в S3, затем конверт с result_s3
|
||||
result = RecognitionResult.model_validate(
|
||||
{
|
||||
"schema_version": CONTRACT_VERSION,
|
||||
"job_id": request.job_id,
|
||||
"file_id": request.file_id,
|
||||
"service": self.service,
|
||||
"status": outcome.status,
|
||||
"raw_s3_key": key,
|
||||
"documents": list(outcome.documents),
|
||||
**({"metrics": metrics} if metrics is not None else {}),
|
||||
}
|
||||
result = build_result(
|
||||
outcome,
|
||||
job_id=request.job_id,
|
||||
file_id=request.file_id,
|
||||
service=self.service,
|
||||
raw_s3_key=key,
|
||||
metrics=metrics,
|
||||
)
|
||||
target = result_key(request.file_id, request.job_id)
|
||||
try:
|
||||
@ -667,22 +666,61 @@ class Worker:
|
||||
await self._sleep(min(self._settings.SCAN_POLL_SECONDS, (deadline - now).total_seconds()))
|
||||
|
||||
|
||||
def _outcome_metrics(outcome: NormalizeOutcome, raw_s3_key: str) -> Metrics | None:
|
||||
"""`metrics` конверта: метрики исхода + `warnings` исхода (R-25) в конце `metrics.warnings`.
|
||||
def build_result( # noqa: PLR0913 - идентификаторы + исход + сервис; всё именованное
|
||||
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
|
||||
lines = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings]
|
||||
if not lines:
|
||||
warnings = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings]
|
||||
if not warnings:
|
||||
return metrics
|
||||
if len(lines) > MAX_WARNINGS:
|
||||
hidden = len(lines) - (MAX_WARNINGS - 1)
|
||||
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) if metrics is not None else {}
|
||||
return Metrics.model_validate({**base, "warnings": warnings})
|
||||
cleaned = [
|
||||
w.model_copy(update={"message": clean_text(w.message, MAX_WARNING_CHARS)}) for w in _limit_warnings(warnings)
|
||||
]
|
||||
base = metrics.model_dump(exclude_none=True, exclude={"warnings"}) if metrics is not None else {}
|
||||
return Metrics.model_validate({**base, "warnings": cleaned})
|
||||
|
||||
|
||||
def _is_uuid(value: object) -> bool:
|
||||
|
||||
@ -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",
|
||||
"parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой
|
||||
(`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/`, тесты —
|
||||
`tests/unit/normalizer/test_ocr_normalizer.py`); `NullNormalizer` остаётся для тестов воркера Среза 0.
|
||||
|
||||
В Срезе 0 единственная реализация — `NullNormalizer`: сводит ошибки по файлам (успех файла — `error.code == ""`),
|
||||
при хотя бы одном успешном файле отвечает `NOT_IMPLEMENTED`. Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`)
|
||||
появится в Срезе 3 новой реализацией протокола `Normalizer`.
|
||||
Настоящая нормализация — `OcrNormalizer` (`src/normalizer/ocr/`, общий код `arm_statements`); `NullNormalizer`
|
||||
остаётся для тестов воркера (P5-19): сводит ошибки по файлам (успех файла — `error.code == ""`), при хотя бы одном
|
||||
успешном файле отвечает `NOT_IMPLEMENTED`; ошибочные файлы — предупреждения `JSON_FAILED` файла.
|
||||
"""
|
||||
|
||||
from typing import Protocol, Self, runtime_checkable
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument
|
||||
from arm_statements import default_rules, warning_to_contract
|
||||
from src.contract import ErrorCode, FileWarning, MessageError, ResultStatus
|
||||
from src.normalizer.files import (
|
||||
BAD_ENTRY_MESSAGE,
|
||||
NO_FILES_MESSAGE,
|
||||
@ -27,6 +24,9 @@ from src.normalizer.files import (
|
||||
FileError,
|
||||
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__ = [
|
||||
"BAD_ENTRY_MESSAGE",
|
||||
@ -45,38 +45,6 @@ __all__ = [
|
||||
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
|
||||
class Normalizer(Protocol):
|
||||
"""Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`.
|
||||
@ -90,11 +58,12 @@ class Normalizer(Protocol):
|
||||
|
||||
|
||||
class NullNormalizer:
|
||||
"""Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет.
|
||||
"""Заглушка (D-5, P5-19): сводка ошибок по файлам склейки, нормализации нет.
|
||||
|
||||
Все файлы ошибочны — `status = error` с кодом и сообщением первого ошибочного файла (порядок `files` — по
|
||||
возрастанию имени, D-4); есть хотя бы один успешный — `NOT_IMPLEMENTED`. В обоих случаях каждый ошибочный файл —
|
||||
строка `warnings` `<имя>: <код ИС>: <сообщение>`. Структура склейки нарушена — `INVALID_FORMAT`.
|
||||
предупреждение `JSON_FAILED` (`message` — `<код ИС>: <сообщение>`, `locator` — `file:<имя>`). Структура склейки
|
||||
нарушена — `INVALID_FORMAT`.
|
||||
"""
|
||||
|
||||
def normalize(self, raw: object) -> NormalizeOutcome:
|
||||
@ -111,21 +80,18 @@ class NullNormalizer:
|
||||
succeeded += 1
|
||||
else:
|
||||
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:
|
||||
return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE, warnings)
|
||||
first = errors[0]
|
||||
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(
|
||||
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
|
||||
|
||||
@ -14,19 +14,14 @@ BAD_ENTRY_MESSAGE = "элемент files не соответствует стр
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileError:
|
||||
"""Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`."""
|
||||
"""Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для предупреждения `JSON_FAILED`."""
|
||||
|
||||
name: str
|
||||
code: ErrorCode # error.code сводки, если этот файл — первый ошибочный
|
||||
source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT)
|
||||
message: str # сообщение файла: строка warnings
|
||||
message: str # сообщение файла: часть 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:
|
||||
"""Предупреждение `JSON_FAILED` пакета: уровень задаёт словарь правил."""
|
||||
return make_warning(
|
||||
|
||||
@ -1,103 +1,60 @@
|
||||
"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document`
|
||||
(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены
|
||||
дословно; DB-обвязка не переносится."""
|
||||
"""Нормализатор ответа ИС Сканирование на общем коде `arm_statements` (спека scan-gateway, контракт 2.0).
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
Обход набора json — `answer_set.normalize_answer_set` (адаптер шага 1, `normalize_candidate` пакета на каждую таблицу);
|
||||
здесь результат пакета переводится в модели контракта функциями пакета `to_contract` / `warning_to_contract` (P5-15):
|
||||
своего уплощения шапки и отбора ключей шлюз не пишет.
|
||||
"""
|
||||
|
||||
import src.normalizer as normalizer_package # цикл: `src.normalizer` импортирует этот модуль в конце своего `__init__`
|
||||
from src.contract import ErrorCode, Metrics, ResultStatus, StatementDocument
|
||||
from src.normalizer.ocr.document import build_document
|
||||
from src.normalizer.ocr.header import parse_file_header
|
||||
from src.normalizer.rules import OcrRules
|
||||
from arm_statements import (
|
||||
EnvelopeStatus,
|
||||
Rules,
|
||||
default_rules,
|
||||
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:
|
||||
from src.normalizer import NormalizeOutcome
|
||||
|
||||
__all__ = ["NO_TABLES_MESSAGE", "OcrNormalizer"]
|
||||
|
||||
NO_TABLES_MESSAGE = "ни одной таблицы не разобрано"
|
||||
NO_CODE_COLUMN_MESSAGE = "нет шапки и столбца кода"
|
||||
FULL_CONFIDENCE = 100.0
|
||||
__all__ = ["OcrNormalizer"]
|
||||
|
||||
|
||||
class OcrNormalizer:
|
||||
"""Нормализатор ответа ИС Сканирование: ошибочные файлы — как в Срезе 0, из успешных собираются документы по
|
||||
одному на таблицу; статус — `done` только когда всё распознано и ни один файл набора не ошибочен (D-6, №28)."""
|
||||
"""Нормализатор ответа ИС: статус и документы определяет пакет, шлюз переводит их в контракт 2.0.
|
||||
|
||||
def __init__(self, rules: OcrRules) -> None:
|
||||
self._rules = rules
|
||||
`version` — версия копии `arm_statements` (по умолчанию — из файла `VERSION` копии, P5-11); нет версии —
|
||||
сервис не стартует.
|
||||
"""
|
||||
|
||||
def normalize(self, raw: object) -> "NormalizeOutcome":
|
||||
# имена пакета берутся при вызове: при импорте этого модуля `src.normalizer` ещё не дочитан до конца
|
||||
outcome_cls = normalizer_package.NormalizeOutcome
|
||||
error_outcome = normalizer_package._error_outcome
|
||||
file_error = normalizer_package._file_error
|
||||
def __init__(self, rules: Rules | None = None, *, version: str | None = None) -> None:
|
||||
self._rules = rules if rules is not None else default_rules()
|
||||
resolved = version if version is not None else statements_version()
|
||||
if resolved is None:
|
||||
raise ValueError("копия arm_statements без VERSION: разложите её sync-statements")
|
||||
self._version = resolved
|
||||
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("files"), list):
|
||||
return error_outcome(ErrorCode.INVALID_FORMAT, normalizer_package.NOT_MERGED_MESSAGE)
|
||||
files: list[object] = raw["files"]
|
||||
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)
|
||||
@property
|
||||
def statements_version(self) -> str:
|
||||
return self._version
|
||||
|
||||
def _normalize_file(self, entry: dict[str, Any]) -> tuple[list[StatementDocument], list[str], bool]:
|
||||
"""Документы одного успешного файла, предупреждения и признак «всё распознано»."""
|
||||
name = str(entry["name"])
|
||||
response = entry["response"]
|
||||
data_ocr = response.get("data_ocr") if isinstance(response, dict) else None
|
||||
if not isinstance(data_ocr, Mapping):
|
||||
return [], [f"{name}: data_ocr отсутствует или не объект"], False
|
||||
header = parse_file_header(data_ocr, self._rules.lookalikes)
|
||||
warnings: list[str] = []
|
||||
if header.inn is None:
|
||||
warnings.append(f"{name}: ИНН не распознан")
|
||||
elif header.inn_confidence != FULL_CONFIDENCE:
|
||||
warnings.append(f"{name}: ИНН распознан неуверенно")
|
||||
if header.report_date is None:
|
||||
warnings.append(f"{name}: дата отчёта не разобрана")
|
||||
tables = data_ocr.get("table_structure")
|
||||
documents: list[StatementDocument] = []
|
||||
recognized = True
|
||||
for index, table in enumerate(tables if isinstance(tables, list) else []):
|
||||
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
|
||||
def normalize(self, raw: object) -> NormalizeOutcome:
|
||||
result = normalize_answer_set(raw, self._rules)
|
||||
warnings = [FileWarning.model_validate(warning_to_contract(w, file_level=True)) for w in result.file_warnings]
|
||||
if result.status is EnvelopeStatus.ERROR:
|
||||
error = result.error
|
||||
if error is None: # исключено инвариантом AnswerSetResult
|
||||
raise ValueError("AnswerSetResult со status=error без error")
|
||||
return NormalizeOutcome(
|
||||
status=ResultStatus.ERROR,
|
||||
error=MessageError(code=error.code, message=error.message),
|
||||
warnings=warnings,
|
||||
)
|
||||
documents = [StatementDocument.model_validate(to_contract(d)) for d in result.documents]
|
||||
return NormalizeOutcome(
|
||||
status=ResultStatus(result.status.value),
|
||||
documents=documents,
|
||||
metrics=Metrics(rows=sum(len(d.lines) for d in documents)),
|
||||
warnings=warnings,
|
||||
statements_version=self._version,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
44
api/src/normalizer/outcome.py
Normal file
44
api/src/normalizer/outcome.py
Normal 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
|
||||
@ -9,15 +9,16 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from arm_statements import default_rules
|
||||
from src.gateway.hotfolder import AnswerFile
|
||||
from src.gateway.merge import merge_answers
|
||||
from src.normalizer import NormalizeOutcome, OcrNormalizer
|
||||
from src.normalizer.rules import load_rules
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[1]
|
||||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||||
GOLDEN_DIR = REAL_FIXTURES / "expected_normalize"
|
||||
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
|
||||
GOLDEN_VERSION = "0" * 40 # фиксированная версия копии: golden от раскладки копии не зависит
|
||||
|
||||
|
||||
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]:
|
||||
return {
|
||||
"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,
|
||||
"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],
|
||||
@ -55,10 +56,10 @@ def main() -> int:
|
||||
if not REAL_FIXTURES.is_dir():
|
||||
print(f"нет каталога {REAL_FIXTURES}", file=sys.stderr)
|
||||
return 2
|
||||
rules = load_rules(API_DIR / "rules")
|
||||
rules = default_rules()
|
||||
GOLDEN_DIR.mkdir(exist_ok=True)
|
||||
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(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
88
api/tests/unit/normalizer/test_contract_boundary.py
Normal file
88
api/tests/unit/normalizer/test_contract_boundary.py
Normal 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())
|
||||
@ -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)
|
||||
@ -8,12 +8,12 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arm_statements import default_rules
|
||||
from src.normalizer import OcrNormalizer
|
||||
from src.normalizer.rules import load_rules
|
||||
from tests.golden_update import GOLDEN_DIR, REAL_FIXTURES, golden_cases, outcome_to_json, real_set
|
||||
from tests.golden_update import GOLDEN_DIR, GOLDEN_VERSION, REAL_FIXTURES, golden_cases, outcome_to_json, real_set
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[3]
|
||||
RULES = load_rules(API_DIR / "rules")
|
||||
RULES = default_rules()
|
||||
pytestmark = pytest.mark.fixtures
|
||||
|
||||
|
||||
@ -25,8 +25,9 @@ def _cases() -> list[str]:
|
||||
def test_matches_golden(name: str) -> None:
|
||||
if not REAL_FIXTURES.is_dir():
|
||||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||||
pytest.skip("golden v2 пересобирается в задаче 6 (эталоны в fixtures пока прежнего формата)")
|
||||
expected_path = GOLDEN_DIR / f"{name}.json"
|
||||
assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff"
|
||||
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"
|
||||
|
||||
@ -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
|
||||
|
||||
import pytest
|
||||
|
||||
import src.normalizer.ocr as ocr_module
|
||||
from src.contract import ErrorCode, ResultStatus
|
||||
from src.gateway.hotfolder import AnswerFile
|
||||
from src.gateway.merge import merge_answers
|
||||
from src.normalizer import NO_TABLES_MESSAGE, OcrNormalizer
|
||||
from src.normalizer.rules import load_rules
|
||||
import src.normalizer.ocr.answer_set as answer_set_module
|
||||
from arm_statements import default_rules
|
||||
from src.contract import ErrorCode, FileWarning, ResultStatus, Severity, StatementDocument
|
||||
from src.normalizer import OcrNormalizer
|
||||
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
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[3]
|
||||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||||
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
|
||||
RULES = load_rules(API_DIR / "rules")
|
||||
VERSION = "abc1234"
|
||||
|
||||
|
||||
def _merged(*files: tuple[str, Any]) -> dict[str, Any]:
|
||||
answers = [AnswerFile(name, json.dumps(body, ensure_ascii=False).encode()) for name, body in files]
|
||||
return dict(merge_answers(JOB, answers))
|
||||
def normalizer() -> OcrNormalizer:
|
||||
return OcrNormalizer(default_rules(), version=VERSION)
|
||||
|
||||
|
||||
def _real_set(*names: str) -> dict[str, Any]:
|
||||
if not REAL_FIXTURES.is_dir():
|
||||
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))
|
||||
def two_forms() -> dict[str, Any]:
|
||||
return response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME))
|
||||
|
||||
|
||||
CLEAN: dict[str, Any] = {
|
||||
"file_uid": JOB,
|
||||
"error": {"code": "", "message": ""},
|
||||
"data_ocr": {
|
||||
"label": {"form_name": "Бухгалтерская отчетность", "confidence": 100},
|
||||
"organization": {"form_name": "ООО Ромашка", "confidence": 100},
|
||||
"inn": {"form_name": "7700000000", "confidence": 100},
|
||||
"units": {"form_name": "тыс. руб.", "confidence": 100},
|
||||
"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},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
def test_done_with_documents_metrics_and_version() -> None:
|
||||
outcome = normalizer().normalize(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE)))))
|
||||
assert outcome.status is ResultStatus.DONE and outcome.error is None and outcome.warnings == []
|
||||
assert [d.document_type for d in outcome.documents] == ["BALANCE"]
|
||||
assert all(isinstance(d, StatementDocument) for d in outcome.documents)
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.rows == sum(len(d.lines) for d in outcome.documents) > 0
|
||||
assert outcome.statements_version == VERSION
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_done_when_everything_recognized(self) -> None:
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN)))
|
||||
assert outcome.status is ResultStatus.DONE and outcome.warnings == []
|
||||
assert len(outcome.documents) == 1 and outcome.documents[0].document_type == "BALANCE"
|
||||
assert outcome.metrics is not None and outcome.metrics.rows == 1
|
||||
|
||||
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:
|
||||
empty = copy.deepcopy(CLEAN)
|
||||
empty["data_ocr"]["table_structure"] = []
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", empty)))
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error is not None and outcome.error.code is ErrorCode.RECOGNITION_FAILED
|
||||
assert outcome.error.message == NO_TABLES_MESSAGE
|
||||
|
||||
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:
|
||||
real = ocr_module.build_document
|
||||
|
||||
def boom(table: Any, index: int, *args: Any, **kwargs: Any) -> Any:
|
||||
if index == 1:
|
||||
raise RuntimeError("сломалось")
|
||||
return real(table, index, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ocr_module, "build_document", boom)
|
||||
two = copy.deepcopy(CLEAN)
|
||||
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 f"{JOB}.json: table:1: BACKEND_PARSING_ERROR: RuntimeError: сломалось" in outcome.warnings
|
||||
|
||||
def test_file_level_warnings(self) -> None:
|
||||
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: ИНН не распознан"]
|
||||
def test_one_document_needs_check_makes_set_needs_check() -> None:
|
||||
"""Синтетический ОФР без строки 2300 не сходится по §7: документ needs_check, набор — тоже."""
|
||||
outcome = normalizer().normalize(merged(("j.json", two_forms())))
|
||||
assert [d.document_type for d in outcome.documents] == ["BALANCE", "INCOME"]
|
||||
assert [d.status for d in outcome.documents] == ["done", "needs_check"]
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK and outcome.warnings == []
|
||||
|
||||
|
||||
class TestErrorFilesStillHandled:
|
||||
def test_all_files_failed_keeps_first_code(self) -> None:
|
||||
failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", failed)))
|
||||
assert outcome.status is ResultStatus.ERROR and outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.INVALID_DOCUMENT
|
||||
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 у шапки
|
||||
def test_lost_json_keeps_documents_and_gives_needs_check() -> None:
|
||||
outcome = normalizer().normalize(
|
||||
merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD)))
|
||||
)
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
|
||||
[warning] = outcome.warnings
|
||||
assert isinstance(warning, FileWarning)
|
||||
assert (warning.code, warning.severity, warning.locator) == ("JSON_FAILED", Severity.ERROR, "file:j_ofr.json")
|
||||
assert warning.message == "INVALID_DOCUMENT: Не удалось определить тип документа."
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
class TestRealFixtures:
|
||||
def test_aep6(self) -> None:
|
||||
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_all_json_failed_takes_first_code() -> None:
|
||||
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 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:
|
||||
outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json", "aep_3_ofr.json"))
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
assert any(w.startswith(f"{JOB}_ofr.json: INVALID_DOCUMENT:") for w in outcome.warnings)
|
||||
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("сломалось")
|
||||
return real(candidate, rules)
|
||||
|
||||
monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky)
|
||||
outcome = normalizer().normalize(merged(("j.json", two_forms())))
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
|
||||
[warning] = outcome.warnings
|
||||
assert (warning.code, warning.locator) == ("TABLE_PARSE_ERROR", "file:j.json;table:1")
|
||||
assert warning.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, {"job_id": "j"}, {"job_id": "j", "files": []}])
|
||||
def test_not_merged_structure_is_invalid_format(raw: object) -> None:
|
||||
outcome = normalizer().normalize(raw)
|
||||
assert outcome.status is ResultStatus.ERROR and outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.INVALID_FORMAT and outcome.warnings == []
|
||||
|
||||
|
||||
def test_success_answer_helper_from_worker_tests() -> None:
|
||||
outcome = normalizer().normalize(merged(("j.json", SUCCESS_ANSWER)))
|
||||
assert outcome.status is not ResultStatus.ERROR and len(outcome.documents) == 1
|
||||
|
||||
|
||||
def test_default_rules_and_version_from_package() -> None:
|
||||
created = OcrNormalizer()
|
||||
assert created.statements_version == ocr_module.statements_version()
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@ -278,11 +278,10 @@ async def test_unhandled_error_has_json_500(local_env: Path) -> None:
|
||||
assert response.json() == {"detail": "внутренняя ошибка сервера"}
|
||||
|
||||
|
||||
def test_create_app_fails_on_broken_rules(tmp_path: Path) -> None:
|
||||
(tmp_path / "statement_lines.yaml").write_text("editions: []\n", encoding="utf-8")
|
||||
settings = get_settings().model_copy(update={"SCAN_RULES_DIR": str(tmp_path), "SCAN_HOTFOLDER_PATH": str(tmp_path)})
|
||||
with pytest.raises(ValueError, match="statement_lines.yaml"):
|
||||
create_app(settings)
|
||||
def test_create_app_uses_package_rules() -> None:
|
||||
app = create_app(get_settings().model_copy(update={"SCAN_HOTFOLDER_PATH": "."}))
|
||||
expected = (Path(__file__).resolve().parents[2] / "arm_statements" / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert app.state.normalizer.statements_version == expected
|
||||
|
||||
|
||||
def test_create_app_uses_ocr_normalizer() -> None:
|
||||
|
||||
@ -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);
|
||||
каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же
|
||||
@ -28,6 +28,7 @@ from src.contract import (
|
||||
SUPPORTED_MAJOR,
|
||||
ContractModel,
|
||||
ErrorCode,
|
||||
FileWarning,
|
||||
MessageStatus,
|
||||
Metrics,
|
||||
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:
|
||||
result = RecognitionResult.model_validate(_example("result-statement-minimal.json"))
|
||||
values = result.documents[0].lines[1].values # type: ignore[union-attr]
|
||||
assert values[0].amount == Decimal("5678.00")
|
||||
assert values[1].amount == Decimal("-12.30")
|
||||
assert isinstance(values[1].amount, Decimal)
|
||||
assert values[0].amount == Decimal("428311")
|
||||
assert isinstance(values[0].amount, Decimal)
|
||||
wire = result.to_wire()
|
||||
assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30"
|
||||
assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло
|
||||
assert wire["documents"][0]["lines"][1]["values"][0]["amount"] == "428311" # без «.00» — как пришло
|
||||
assert wire["documents"][0]["lines"][0]["values"][0]["amount"] == "100"
|
||||
|
||||
|
||||
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/no-service", "result-message-processing.json", _drop("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-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True),
|
||||
("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), 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_schema_version", "result-message-done.json", _drop("result_schema_version"), False),
|
||||
("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,
|
||||
),
|
||||
("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-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),
|
||||
@ -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-empty-object", "result-error.json", _set("error", {}), True),
|
||||
("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-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True),
|
||||
("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), 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-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),
|
||||
@ -550,6 +555,21 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
|
||||
False,
|
||||
),
|
||||
("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:
|
||||
assert CONTRACT_VERSION == "1.0"
|
||||
assert CONTRACT_VERSION == "2.0"
|
||||
|
||||
|
||||
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)
|
||||
wire = message.to_wire()
|
||||
assert wire == {
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "2.0",
|
||||
"job_id": str(JOB_ID),
|
||||
"file_id": str(FILE_ID),
|
||||
"status": "processing",
|
||||
@ -769,13 +789,18 @@ def test_build_error_message_with_raw_s3_key_and_metrics() -> None:
|
||||
ErrorCode.TIMEOUT,
|
||||
"ИС Сканирование не ответила за 300 с",
|
||||
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()
|
||||
assert wire["status"] == "error"
|
||||
assert wire["error"] == {"code": "TIMEOUT", "message": "ИС Сканирование не ответила за 300 с"}
|
||||
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
|
||||
VALIDATORS["message"].validate(wire)
|
||||
assert RecognitionResultMessage.model_validate(wire) == message
|
||||
@ -805,7 +830,7 @@ def test_build_done_message() -> None:
|
||||
wire = message.to_wire()
|
||||
assert wire["status"] == "done"
|
||||
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"
|
||||
VALIDATORS["message"].validate(wire)
|
||||
assert RecognitionResultMessage.model_validate(wire) == message
|
||||
|
||||
@ -608,7 +608,8 @@ class TestAnswerSet:
|
||||
assert outcome.error.code == "NOT_IMPLEMENTED"
|
||||
assert outcome.warnings is not None
|
||||
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:
|
||||
|
||||
@ -16,7 +16,7 @@ from typing import Any
|
||||
import pytest
|
||||
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.merge import merge_answers
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
|
||||
assert outcome.warnings == warnings
|
||||
assert _lines(outcome) == warnings
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
@ -103,7 +111,7 @@ class TestRealFixtures:
|
||||
[entry] = merged["files"]
|
||||
outcome = NullNormalizer().normalize(merged)
|
||||
_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:
|
||||
@ -133,7 +141,7 @@ class TestFileError:
|
||||
def test_known_code_and_message_verbatim(self) -> None:
|
||||
outcome = NullNormalizer().normalize(_one(_failure("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])
|
||||
def test_every_contract_code_passes(self, code: str) -> None:
|
||||
@ -145,14 +153,14 @@ class TestFileError:
|
||||
assert outcome.error is not None
|
||||
assert "SOME_NEW_CODE" 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:
|
||||
outcome = NullNormalizer().normalize(_one(_failure("invalid_document", "m")))
|
||||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||||
assert outcome.error is not None
|
||||
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])
|
||||
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 repr(code) 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, ""])
|
||||
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 outcome.error is not None
|
||||
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:
|
||||
outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}}))
|
||||
@ -201,7 +209,7 @@ class TestFileError:
|
||||
_assert_error(outcome, ErrorCode.INVALID_FORMAT)
|
||||
assert outcome.error is not None
|
||||
assert "Expecting value" in outcome.error.message
|
||||
[warning] = outcome.warnings
|
||||
[warning] = _lines(outcome)
|
||||
assert warning.startswith(f"{JOB}.json: INVALID_FORMAT: ")
|
||||
assert "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" in warning
|
||||
|
||||
@ -210,7 +218,7 @@ class TestFileError:
|
||||
"""Сценарий «Корень ответа не объект»."""
|
||||
outcome = NullNormalizer().normalize(_one(response))
|
||||
_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:
|
||||
@ -223,8 +231,9 @@ class TestSummary:
|
||||
outcome = NullNormalizer().normalize(merged)
|
||||
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT, "нет типа")
|
||||
assert len(outcome.warnings) == 2
|
||||
assert outcome.warnings[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа"
|
||||
assert outcome.warnings[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ")
|
||||
lines = _lines(outcome)
|
||||
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:
|
||||
merged = _merged(
|
||||
@ -279,7 +288,7 @@ class TestMergedStructure:
|
||||
merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]}
|
||||
outcome = NullNormalizer().normalize(merged)
|
||||
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED)
|
||||
[warning] = outcome.warnings
|
||||
[warning] = _lines(outcome)
|
||||
assert warning.startswith("files[0]: INVALID_FORMAT: ")
|
||||
|
||||
def test_real_merge_output_is_accepted(self) -> None:
|
||||
@ -293,13 +302,15 @@ class TestNormalizeOutcome:
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.documents == []
|
||||
assert outcome.metrics is None
|
||||
assert outcome.warnings == []
|
||||
assert _lines(outcome) == []
|
||||
|
||||
def test_warnings_are_kept(self) -> None:
|
||||
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:
|
||||
with pytest.raises(ValidationError):
|
||||
@ -311,6 +322,7 @@ class TestNormalizeOutcome:
|
||||
NormalizeOutcome(
|
||||
status=status,
|
||||
documents=[_statement()],
|
||||
statements_version="abc1234",
|
||||
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
|
||||
)
|
||||
|
||||
@ -319,10 +331,17 @@ class TestNormalizeOutcome:
|
||||
with pytest.raises(ValidationError):
|
||||
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"])
|
||||
def test_success_outcome(self, status: str) -> None:
|
||||
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.error is None
|
||||
assert outcome.metrics == metrics
|
||||
@ -333,6 +352,7 @@ class TestNormalizeOutcome:
|
||||
NormalizeOutcome(
|
||||
status="error",
|
||||
documents=[_statement()],
|
||||
statements_version="abc1234",
|
||||
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
|
||||
)
|
||||
|
||||
@ -373,7 +393,7 @@ class TestProtocol:
|
||||
def test_custom_implementation_is_accepted(self) -> None:
|
||||
class Custom:
|
||||
def normalize(self, raw: object) -> NormalizeOutcome:
|
||||
return NormalizeOutcome(status="done", documents=[_statement()])
|
||||
return NormalizeOutcome(status="done", documents=[_statement()], statements_version="abc1234")
|
||||
|
||||
normalizer: Normalizer = Custom()
|
||||
assert normalizer.normalize({}).status is ResultStatus.DONE
|
||||
@ -383,7 +403,10 @@ def _statement() -> StatementDocument:
|
||||
return StatementDocument.model_validate(
|
||||
{
|
||||
"document_type": "BALANCE",
|
||||
"locator": "pages:1-2",
|
||||
"form_variant": "full",
|
||||
"locator": "file:a.json;table:0",
|
||||
"status": "done",
|
||||
"warnings": [],
|
||||
"header": {},
|
||||
"columns": [],
|
||||
"lines": [],
|
||||
|
||||
@ -18,9 +18,11 @@ from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from src.contract import (
|
||||
ErrorCode,
|
||||
FileWarning,
|
||||
MessageError,
|
||||
Metrics,
|
||||
ResultStatus,
|
||||
Severity,
|
||||
StatementDocument,
|
||||
)
|
||||
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.merge import dump_merged, merge_answers
|
||||
from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, OcrNormalizer
|
||||
from src.normalizer.rules import load_rules
|
||||
from tests.worker_helpers import (
|
||||
API_DIR,
|
||||
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:
|
||||
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 body["error"]["message"] == expected["message"]
|
||||
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
|
||||
|
||||
|
||||
@ -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])
|
||||
async def test_successful_outcome_writes_result_file(make_env: EnvFactory, status: ResultStatus) -> None:
|
||||
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)
|
||||
request = env.request()
|
||||
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"
|
||||
assert body["status"] == status.value
|
||||
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 "error" not in body
|
||||
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["raw_s3_key"] == f"raw/{job_id}.json"
|
||||
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
|
||||
# нормализатор получает склеенный ответ — тот же объект, что записан в raw/
|
||||
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:
|
||||
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/",)
|
||||
request = env.request()
|
||||
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["parse_error"]
|
||||
[warning] = body["metrics"]["warnings"]
|
||||
assert warning.startswith(f"{job_id}.json: INVALID_FORMAT: ")
|
||||
assert entry["parse_error"] in warning
|
||||
assert (warning["code"], warning["severity"], warning["locator"]) == ("JSON_FAILED", "error", f"file:{job_id}.json")
|
||||
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:
|
||||
@ -374,7 +384,7 @@ async def test_one_erroneous_file_does_not_fail_the_job(make_env: EnvFactory) ->
|
||||
body = await env.run(request)
|
||||
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
|
||||
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:
|
||||
@ -391,8 +401,8 @@ async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) ->
|
||||
body = await env.run(request)
|
||||
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True)
|
||||
bad, listed = body["metrics"]["warnings"]
|
||||
assert bad.startswith(f"{job_id}_bad.json: INVALID_FORMAT: ")
|
||||
assert listed == f"{job_id}_list.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"
|
||||
assert bad["locator"] == f"file:{job_id}_bad.json" and bad["message"].startswith("INVALID_FORMAT: ")
|
||||
assert listed == _failed(f"{job_id}_list.json", f"INVALID_FORMAT: {NOT_OBJECT_MESSAGE}")
|
||||
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 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 body["error"]["message"] == REAL_MESSAGE
|
||||
first, second = body["metrics"]["warnings"]
|
||||
assert first == f"{job_id}.json: INVALID_DOCUMENT: {REAL_MESSAGE}"
|
||||
assert second.startswith(f"{job_id}_ofr.json: INVALID_FORMAT: ")
|
||||
assert first == _failed(f"{job_id}.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}")
|
||||
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:
|
||||
@ -424,7 +434,7 @@ async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env:
|
||||
body = await env.run(request)
|
||||
assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True)
|
||||
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:
|
||||
@ -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)]]
|
||||
body = await env.run(request)
|
||||
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(
|
||||
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])
|
||||
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])
|
||||
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(
|
||||
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))
|
||||
request = env.request()
|
||||
async with emulator(env):
|
||||
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_VALIDATOR.validate(result)
|
||||
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:
|
||||
"""R-30: число строк warnings и длина каждой ограничены — конверт остаётся меньше лимита NATS."""
|
||||
lines = [f"f{i}.json: INVALID_DOCUMENT: " + "я" * 50_000 for i in range(MAX_WARNINGS * 3)]
|
||||
"""P5-10: 300 предупреждений (100 error и 200 info вперемешку) — в конверте ровно 100, все error среди них, порядок
|
||||
исходный, `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(
|
||||
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))
|
||||
async with emulator(env):
|
||||
body = await env.run(env.request())
|
||||
warnings = body["metrics"]["warnings"]
|
||||
assert len(warnings) == MAX_WARNINGS
|
||||
assert all(len(line) <= MAX_WARNING_CHARS for line in warnings)
|
||||
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)
|
||||
sent = body["metrics"]["warnings"]
|
||||
assert len(sent) == MAX_WARNINGS
|
||||
assert [w["message"] for w in sent if w["severity"] == "error"] == errors
|
||||
|
||||
|
||||
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"].endswith(" tail")
|
||||
[warning] = body["metrics"]["warnings"]
|
||||
assert warning.startswith(f"{job_id}.json: INVALID_DOCUMENT: bad ")
|
||||
assert warning.endswith(" tail")
|
||||
assert warning["locator"] == f"file:{job_id}.json"
|
||||
assert warning["message"].startswith("INVALID_DOCUMENT: bad ") and warning["message"].endswith(" tail")
|
||||
# сырой ответ записан валидным JSON, суррогат сохранён экранированным
|
||||
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"]
|
||||
|
||||
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()
|
||||
async with emulator(env):
|
||||
result = await env.run(body)
|
||||
@ -972,5 +1021,9 @@ class TestSlice3a:
|
||||
key = result["result_s3"]["key"]
|
||||
stored = json.loads(env.storage.objects[(BUCKET, key)])
|
||||
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"
|
||||
|
||||
@ -253,7 +253,7 @@ class Env:
|
||||
body = json.loads(payload.decode("utf-8"))
|
||||
MESSAGE_VALIDATOR.validate(body)
|
||||
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))
|
||||
return out
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user