From d1b0cd7945597f33378328deb3c9e3b720efbb52 Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Wed, 27 May 2026 23:13:40 +0200 Subject: [PATCH] feat: import mostovik exchange sections --- src/apps/core/excel.py | 2 +- src/apps/core/reporting.py | 27 +- src/apps/core/upload_contracts.py | 37 +- src/apps/exchange/admin.py | 6 + src/apps/exchange/services.py | 386 +++++++++++++++++- src/apps/external_data/api.py | 67 +++ .../migrations/0004_auto_20260527_1928.py | 362 ++++++++++++++++ src/apps/external_data/models.py | 117 ++++++ src/apps/external_data/serializers.py | 71 ++++ src/apps/external_data/urls.py | 18 + src/apps/form_1/admin.py | 10 +- src/apps/form_1/api.py | 70 +++- .../migrations/0003_auto_20260527_1954.py | 31 ++ src/apps/form_1/models.py | 59 +++ src/apps/form_1/serializers.py | 30 +- src/apps/form_1/services.py | 23 +- src/apps/form_1/tasks.py | 5 +- src/apps/form_2/api.py | 11 + src/apps/form_2/services.py | 10 +- src/apps/form_3/api.py | 11 + src/apps/form_3/services.py | 10 +- src/apps/form_4/admin.py | 10 +- src/apps/form_4/api.py | 21 +- .../migrations/0003_auto_20260527_1954.py | 55 +++ src/apps/form_4/models.py | 54 +++ src/apps/form_4/serializers.py | 1 + src/apps/form_4/services.py | 25 +- src/apps/form_4/tasks.py | 6 +- src/apps/form_5/api.py | 11 + src/apps/form_5/services.py | 10 +- src/apps/form_6/api.py | 11 + src/apps/form_6/services.py | 10 +- src/apps/organization/services.py | 80 ++-- src/apps/registers/services.py | 92 ++--- tests/apps/exchange/test_api.py | 91 +++++ tests/apps/external_data/factories.py | 57 +++ tests/apps/external_data/test_api.py | 48 +++ tests/apps/form_1/factories.py | 3 +- tests/apps/form_1/test_services.py | 23 +- tests/apps/form_2/test_services.py | 7 +- tests/apps/form_3/test_services.py | 7 +- tests/apps/form_4/factories.py | 3 +- tests/apps/form_4/test_services.py | 14 +- tests/apps/form_5/test_services.py | 7 +- tests/apps/form_6/test_services.py | 7 +- tests/apps/forms/test_upload_contracts_api.py | 15 +- tests/apps/organization/test_services.py | 21 +- tests/apps/registers/test_backup_import.py | 41 +- tests/apps/registers/test_services.py | 57 ++- 49 files changed, 1831 insertions(+), 319 deletions(-) create mode 100644 src/apps/external_data/migrations/0004_auto_20260527_1928.py create mode 100644 src/apps/form_1/migrations/0003_auto_20260527_1954.py create mode 100644 src/apps/form_4/migrations/0003_auto_20260527_1954.py diff --git a/src/apps/core/excel.py b/src/apps/core/excel.py index 04b5347..940377d 100644 --- a/src/apps/core/excel.py +++ b/src/apps/core/excel.py @@ -253,7 +253,7 @@ class BaseExcelParser(ABC, Generic[T]): ] def create_record(self, row_data: RowData) -> FormF1Record: - org = OrganizationService.get_or_create_by_inn(...) + org = OrganizationService.get_required_by_inn(...) return FormF1Record.objects.create(organization=org, ...) """ diff --git a/src/apps/core/reporting.py b/src/apps/core/reporting.py index cc2421c..e37dfec 100644 --- a/src/apps/core/reporting.py +++ b/src/apps/core/reporting.py @@ -15,6 +15,7 @@ def build_report_period_filters( organization, report_year: int, report_quarter: int | None, + extra_period_fields: dict[str, object] | None = None, ) -> dict[str, object]: """Build queryset filters for a report period.""" filters: dict[str, object] = { @@ -25,6 +26,13 @@ def build_report_period_filters( filters["report_quarter__isnull"] = True else: filters["report_quarter"] = report_quarter + + for field_name, value in (extra_period_fields or {}).items(): + if value is None: + filters[f"{field_name}__isnull"] = True + else: + filters[field_name] = value + return filters @@ -42,6 +50,7 @@ class VersionedReportServiceMixin(Generic[M]): load_batch: int, report_year: int, report_quarter: int | None, + extra_period_fields: dict[str, object] | None = None, **fields, ) -> M: """ @@ -54,6 +63,7 @@ class VersionedReportServiceMixin(Generic[M]): organization=organization, report_year=report_year, report_quarter=report_quarter, + extra_period_fields=extra_period_fields, replacing_batch=load_batch, ) return cls.model.objects.create( @@ -62,6 +72,7 @@ class VersionedReportServiceMixin(Generic[M]): report_year=report_year, report_quarter=report_quarter, is_active_version=True, + **(extra_period_fields or {}), **fields, ) @@ -72,6 +83,7 @@ class VersionedReportServiceMixin(Generic[M]): organization, report_year: int, report_quarter: int | None, + extra_period_fields: dict[str, object] | None = None, replacing_batch: int, ) -> int: """Archive active records of the same period before creating a new one.""" @@ -84,6 +96,7 @@ class VersionedReportServiceMixin(Generic[M]): organization=organization, report_year=report_year, report_quarter=report_quarter, + extra_period_fields=extra_period_fields, ), ) .exclude(load_batch=replacing_batch) @@ -100,12 +113,24 @@ class ReportingPeriodParserMixin: """Stores validated report period metadata for Excel parsers.""" def __init__( - self, *, report_year: int, report_quarter: int | None = None, **kwargs + self, + *, + report_year: int, + report_month: int | None = None, + report_quarter: int | None = None, + report_half_year: int | None = None, + **kwargs, ): super().__init__(**kwargs) if report_year < 2000: raise ValueError("Отчетный год должен быть не меньше 2000") + if report_month is not None and report_month not in set(range(1, 13)): + raise ValueError("Отчетный месяц должен быть в диапазоне 1-12") if report_quarter is not None and report_quarter not in {1, 2, 3, 4}: raise ValueError("Отчетный квартал должен быть в диапазоне 1-4") + if report_half_year is not None and report_half_year not in {1, 2}: + raise ValueError("Отчетное полугодие должно быть в диапазоне 1-2") self.report_year = report_year + self.report_month = report_month self.report_quarter = report_quarter + self.report_half_year = report_half_year diff --git a/src/apps/core/upload_contracts.py b/src/apps/core/upload_contracts.py index 933d783..3a9a1ec 100644 --- a/src/apps/core/upload_contracts.py +++ b/src/apps/core/upload_contracts.py @@ -20,6 +20,21 @@ _ROMAN_NUMBERS = { 4: "IV", } +_MONTH_NAMES = { + 1: "Январь", + 2: "Февраль", + 3: "Март", + 4: "Апрель", + 5: "Май", + 6: "Июнь", + 7: "Июль", + 8: "Август", + 9: "Сентябрь", + 10: "Октябрь", + 11: "Ноябрь", + 12: "Декабрь", +} + def _normalize_extension(name: str) -> str: return name.rsplit(".", 1)[-1].lower() if "." in name else "" @@ -41,6 +56,10 @@ def report_quarter_display(report_year: int, report_quarter: int) -> str: return f"{_ROMAN_NUMBERS.get(report_quarter, report_quarter)} квартал {report_year}" +def report_month_display(report_year: int, report_month: int) -> str: + return f"{_MONTH_NAMES.get(report_month, report_month)} {report_year}" + + def report_half_year_display(report_year: int, report_half_year: int) -> str: return f"{_ROMAN_NUMBERS.get(report_half_year, report_half_year)} полугодие {report_year}" @@ -54,6 +73,7 @@ def build_upload_success_payload( form: str, report_year: int, status: str, + report_month: int | None = None, report_quarter: int | None = None, report_half_year: int | None = None, result: dict[str, Any] | None = None, @@ -69,7 +89,13 @@ def build_upload_success_payload( "created_at": created.isoformat(), } - if report_half_year is not None: + if report_month is not None: + payload["report_month"] = report_month + payload["report_period_display"] = report_month_display( + report_year=report_year, + report_month=report_month, + ) + elif report_half_year is not None: payload["report_half_year"] = report_half_year payload["report_period_display"] = report_half_year_display( report_year=report_year, @@ -185,6 +211,15 @@ class UploadQuarterSerializer(BaseUploadSerializer): ) +class UploadMonthSerializer(BaseUploadSerializer): + report_month = serializers.IntegerField( + min_value=1, + max_value=12, + required=True, + help_text="Отчетный месяц от 1 до 12", + ) + + class UploadHalfYearSerializer(BaseUploadSerializer): report_half_year = serializers.IntegerField( min_value=1, diff --git a/src/apps/exchange/admin.py b/src/apps/exchange/admin.py index 7053d86..93e882a 100644 --- a/src/apps/exchange/admin.py +++ b/src/apps/exchange/admin.py @@ -159,9 +159,12 @@ class ExchangePackageImportAdmin(admin.ModelAdmin): return organizations = result.get("organizations", {}) + industrial_certificates = result.get("industrial_certificates", {}) + manufacturers = result.get("manufacturers", {}) industrial_products = result.get("industrial_products", {}) prosecutor_checks = result.get("prosecutor_checks", {}) public_procurements = result.get("public_procurements", {}) + financial_reports = result.get("financial_reports", {}) arbitration_cases = result.get("arbitration_cases", {}) bankruptcy_procedures = result.get("bankruptcy_procedures", {}) defense_unreliable_suppliers = result.get("defense_unreliable_suppliers", {}) @@ -175,9 +178,12 @@ class ExchangePackageImportAdmin(admin.ModelAdmin): "Импорт пакета обмена завершён: " f"орг. создано {organizations.get('created', 0)}, " f"орг. обновлено {organizations.get('updated', 0)}, " + f"сертификаты {industrial_certificates.get('created', 0)}, " + f"производители {manufacturers.get('created', 0)}, " f"продукция {industrial_products.get('created', 0)}, " f"проверки {prosecutor_checks.get('created', 0)}, " f"закупки {public_procurements.get('created', 0)}, " + f"ФНС-отчеты {financial_reports.get('created', 0)}, " f"арбитраж {arbitration_cases.get('created', 0)}, " f"банкротства {bankruptcy_procedures.get('created', 0)}, " f"РНП/ГОЗ {defense_unreliable_suppliers.get('created', 0)}, " diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index 8089523..537ce47 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -23,9 +23,13 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, InformationSecurityRegistryEntry, LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -93,9 +97,12 @@ class ExchangePackageImportService: } SECTION_KEYS = ( "organizations", + "industrial_certificates", + "manufacturers", "industrial_products", "prosecutor_checks", "public_procurements", + "financial_reports", "arbitration_cases", "bankruptcy_procedures", "defense_unreliable_suppliers", @@ -451,39 +458,65 @@ class ExchangePackageImportService: if not isinstance(data, dict): raise ExchangeImportError("Раздел data в пакете поврежден") - organization_summary = cls._upsert_organizations( - cls._extract_rows(data, "organizations"), + organization_rows = cls._extract_rows(data, "organizations") + allowed_organization_inns = cls._collect_package_organization_inns( + organization_rows + ) + + organization_summary = cls._upsert_organizations(organization_rows) + industrial_certificate_summary = cls._upsert_industrial_certificates( + cls._extract_rows(data, "industrial_certificates"), + allowed_organization_inns=allowed_organization_inns, + ) + manufacturer_summary = cls._upsert_manufacturers( + cls._extract_rows(data, "manufacturers"), + allowed_organization_inns=allowed_organization_inns, ) industrial_summary = cls._upsert_industrial_products( cls._extract_rows(data, "industrial_products"), + allowed_organization_inns=allowed_organization_inns, ) prosecutor_summary = cls._upsert_prosecutor_checks( cls._extract_rows(data, "prosecutor_checks"), + allowed_organization_inns=allowed_organization_inns, ) procurement_summary = cls._upsert_public_procurements( cls._extract_rows(data, "public_procurements"), + allowed_organization_inns=allowed_organization_inns, + ) + financial_report_summary = cls._upsert_financial_reports( + cls._extract_rows(data, "financial_reports"), + allowed_organization_inns=allowed_organization_inns, ) arbitration_summary = cls._upsert_arbitration_cases( cls._extract_rows(data, "arbitration_cases"), + allowed_organization_inns=allowed_organization_inns, ) bankruptcy_summary = cls._upsert_bankruptcy_procedures( cls._extract_rows(data, "bankruptcy_procedures"), + allowed_organization_inns=allowed_organization_inns, ) defense_supplier_summary = cls._upsert_defense_unreliable_suppliers( cls._extract_rows(data, "defense_unreliable_suppliers"), + allowed_organization_inns=allowed_organization_inns, ) information_security_summary = cls._upsert_information_security_registries( cls._extract_rows(data, "information_security_registries"), + allowed_organization_inns=allowed_organization_inns, ) labor_vacancy_summary = cls._upsert_labor_vacancies( cls._extract_rows(data, "labor_vacancies"), + allowed_organization_inns=allowed_organization_inns, ) return { "organizations": organization_summary, + "industrial_certificates": industrial_certificate_summary, + "manufacturers": manufacturer_summary, "industrial_products": industrial_summary, "prosecutor_checks": prosecutor_summary, "public_procurements": procurement_summary, + "financial_reports": financial_report_summary, "arbitration_cases": arbitration_summary, "bankruptcy_procedures": bankruptcy_summary, "defense_unreliable_suppliers": defense_supplier_summary, @@ -511,6 +544,13 @@ class ExchangePackageImportService: normalized_rows.append(row) return normalized_rows + @classmethod + def _collect_package_organization_inns( + cls, + rows: list[dict[str, Any]], + ) -> set[str]: + return {inn for row in rows if (inn := cls._resolve_organization_inn(row))} + @classmethod def _upsert_organizations(cls, rows: list[dict[str, Any]]) -> dict[str, int]: created_count = 0 @@ -646,13 +686,124 @@ class ExchangePackageImportService: return update_fields @classmethod - def _upsert_industrial_products(cls, rows: list[dict[str, Any]]) -> dict[str, int]: + def _upsert_industrial_certificates( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) + certificate_number = cls._clean_string(row.get("certificate_number")) + if not certificate_number: + skipped_count += 1 + continue + + defaults = { + "issue_date": cls._parse_date_value( + row.get("issue_date"), + field_name="issue_date", + allow_null=True, + ), + "expiry_date": cls._parse_date_value( + row.get("expiry_date"), + field_name="expiry_date", + allow_null=True, + ), + "certificate_file_url": cls._clean_string( + row.get("certificate_file_url") + ), + "organisation_name": cls._clean_string(row.get("organisation_name")), + "ogrn": cls._clean_digits(row.get("ogrn")), + } + state = cls._upsert_external_row( + model=IndustrialCertificate, + lookup={ + "organization": organization, + "certificate_number": certificate_number, + }, + defaults=defaults, + ) + if state == "created": + created_count += 1 + elif state == "updated": + updated_count += 1 + + return { + "created": created_count, + "updated": updated_count, + "skipped": skipped_count, + } + + @classmethod + def _upsert_manufacturers( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: + created_count = 0 + updated_count = 0 + skipped_count = 0 + + for row in rows: + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) + inn = cls._clean_digits(row.get("inn")) or organization.inn + if not inn: + skipped_count += 1 + continue + + defaults = { + "full_legal_name": cls._clean_string(row.get("full_legal_name")) + or organization.name, + "ogrn": cls._clean_digits(row.get("ogrn")), + "address": cls._clean_string(row.get("address")), + } + state = cls._upsert_external_row( + model=ManufacturerRegistryEntry, + lookup={ + "organization": organization, + "inn": inn, + }, + defaults=defaults, + ) + if state == "created": + created_count += 1 + elif state == "updated": + updated_count += 1 + + return { + "created": created_count, + "updated": updated_count, + "skipped": skipped_count, + } + + @classmethod + def _upsert_industrial_products( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: + created_count = 0 + updated_count = 0 + skipped_count = 0 + + for row in rows: + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) registry_number = cls._clean_string(row.get("registry_number")) if not registry_number: skipped_count += 1 @@ -684,13 +835,21 @@ class ExchangePackageImportService: } @classmethod - def _upsert_prosecutor_checks(cls, rows: list[dict[str, Any]]) -> dict[str, int]: + def _upsert_prosecutor_checks( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) registration_number = cls._clean_string(row.get("registration_number")) if not registration_number: skipped_count += 1 @@ -726,13 +885,21 @@ class ExchangePackageImportService: } @classmethod - def _upsert_public_procurements(cls, rows: list[dict[str, Any]]) -> dict[str, int]: + def _upsert_public_procurements( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) purchase_number = cls._clean_string(row.get("purchase_number")) if not purchase_number: skipped_count += 1 @@ -782,13 +949,148 @@ class ExchangePackageImportService: } @classmethod - def _upsert_arbitration_cases(cls, rows: list[dict[str, Any]]) -> dict[str, int]: + def _upsert_financial_reports( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: + created_count = 0 + updated_count = 0 + skipped_count = 0 + created_lines_count = 0 + updated_lines_count = 0 + + for row in rows: + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) + external_id = cls._clean_string(row.get("external_id")) + if not external_id: + skipped_count += 1 + continue + + defaults = { + "ogrn": cls._clean_digits(row.get("ogrn")), + "file_name": cls._clean_string(row.get("file_name")), + "file_hash": cls._clean_string(row.get("file_hash")), + "load_batch": cls._parse_optional_int( + row.get("load_batch"), + field_name="load_batch", + ), + "status": cls._clean_string(row.get("status")), + "source": cls._clean_string(row.get("source")), + "error_message": cls._clean_string(row.get("error_message")), + } + report = FinancialReport.objects.filter( + organization=organization, + external_id=external_id, + ).first() + if report is None: + report = FinancialReport.objects.create( + organization=organization, + external_id=external_id, + **defaults, + ) + created_count += 1 + else: + update_fields: list[str] = [] + for field_name, value in defaults.items(): + if getattr(report, field_name) != value: + setattr(report, field_name, value) + update_fields.append(field_name) + if update_fields: + report.save(update_fields=update_fields + ["updated_at"]) + updated_count += 1 + + line_result = cls._upsert_financial_report_lines( + report, + row.get("lines"), + ) + created_lines_count += line_result["created"] + updated_lines_count += line_result["updated"] + + return { + "created": created_count, + "updated": updated_count, + "skipped": skipped_count, + "created_lines": created_lines_count, + "updated_lines": updated_lines_count, + } + + @classmethod + def _upsert_financial_report_lines( + cls, + report: FinancialReport, + lines: Any, + ) -> dict[str, int]: + if lines is None: + return {"created": 0, "updated": 0} + if not isinstance(lines, list): + raise ExchangeImportError( + "Поле lines финансового отчета должно быть списком" + ) + + created_count = 0 + updated_count = 0 + for line in lines: + if not isinstance(line, dict): + raise ExchangeImportError( + "Финансовый отчет должен содержать строки-словари" + ) + form_code = cls._clean_string(line.get("form_code")) + line_code = cls._clean_string(line.get("line_code")) + if not form_code or not line_code: + continue + year = cls._parse_int_value(line.get("year"), field_name="year") + + defaults = { + "line_name": cls._clean_string(line.get("line_name")), + "period_start": cls._parse_optional_int( + line.get("period_start"), + field_name="period_start", + allow_negative=True, + ), + "period_end": cls._parse_optional_int( + line.get("period_end"), + field_name="period_end", + allow_negative=True, + ), + } + state = cls._upsert_external_row( + model=FinancialReportLine, + lookup={ + "report": report, + "form_code": form_code, + "line_code": line_code, + "year": year, + }, + defaults=defaults, + ) + if state == "created": + created_count += 1 + elif state == "updated": + updated_count += 1 + + return {"created": created_count, "updated": updated_count} + + @classmethod + def _upsert_arbitration_cases( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) case_number = cls._clean_string(row.get("case_number")) if not case_number: skipped_count += 1 @@ -826,13 +1128,18 @@ class ExchangePackageImportService: def _upsert_bankruptcy_procedures( cls, rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) external_id = cls._clean_string(row.get("external_id")) if not external_id: skipped_count += 1 @@ -872,13 +1179,18 @@ class ExchangePackageImportService: def _upsert_defense_unreliable_suppliers( cls, rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) external_id = cls._clean_string(row.get("external_id")) if not external_id: skipped_count += 1 @@ -920,13 +1232,18 @@ class ExchangePackageImportService: def _upsert_information_security_registries( cls, rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) external_id = cls._clean_string(row.get("external_id")) registry_name = cls._clean_string(row.get("registry_name")) entry_number = cls._clean_string(row.get("entry_number")) @@ -978,13 +1295,21 @@ class ExchangePackageImportService: } @classmethod - def _upsert_labor_vacancies(cls, rows: list[dict[str, Any]]) -> dict[str, int]: + def _upsert_labor_vacancies( + cls, + rows: list[dict[str, Any]], + *, + allowed_organization_inns: set[str], + ) -> dict[str, int]: created_count = 0 updated_count = 0 skipped_count = 0 for row in rows: - organization = cls._resolve_organization(row) + organization = cls._resolve_organization( + row, + allowed_organization_inns=allowed_organization_inns, + ) external_id = cls._clean_string(row.get("external_id")) if not external_id: skipped_count += 1 @@ -1049,12 +1374,21 @@ class ExchangePackageImportService: return "unchanged" @classmethod - def _resolve_organization(cls, row: dict[str, Any]) -> Organization: + def _resolve_organization( + cls, + row: dict[str, Any], + *, + allowed_organization_inns: set[str], + ) -> Organization: inn = cls._resolve_organization_inn(row) if not inn: raise ExchangeImportError( "В строке внешних данных отсутствует organization_inn" ) + if inn not in allowed_organization_inns: + raise ExchangeImportError( + f"Организация с ИНН {inn} отсутствует в разделе organizations пакета" + ) organization = Organization.objects.filter(inn=inn).first() if organization is None: raise ExchangeImportError(f"Организация с ИНН {inn} не найдена") @@ -1146,6 +1480,26 @@ class ExchangePackageImportService: raise ExchangeImportError(f"Поле {field_name} не может быть отрицательным") return parsed + @classmethod + def _parse_optional_int( + cls, + value: Any, + *, + field_name: str, + allow_negative: bool = False, + ) -> int | None: + if value in (None, ""): + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ExchangeImportError( + f"Поле {field_name} должно быть целым числом" + ) from exc + if parsed < 0 and not allow_negative: + raise ExchangeImportError(f"Поле {field_name} не может быть отрицательным") + return parsed + @classmethod def _parse_decimal_value( cls, diff --git a/src/apps/external_data/api.py b/src/apps/external_data/api.py index a45d783..27dfb22 100644 --- a/src/apps/external_data/api.py +++ b/src/apps/external_data/api.py @@ -5,9 +5,12 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + FinancialReport, + IndustrialCertificate, IndustrialProduct, InformationSecurityRegistryEntry, LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -15,9 +18,12 @@ from apps.external_data.serializers import ( ArbitrationCaseSerializer, BankruptcyProcedureSerializer, DefenseUnreliableSupplierSerializer, + FinancialReportSerializer, + IndustrialCertificateSerializer, IndustrialProductSerializer, InformationSecurityRegistryEntrySerializer, LaborVacancySerializer, + ManufacturerRegistryEntrySerializer, ProsecutorCheckSerializer, PublicProcurementSerializer, ) @@ -34,6 +40,24 @@ class IndustrialProductFilter(filters.FilterSet): fields = ["organization", "product_class"] +class IndustrialCertificateFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + issue_date_from = filters.DateFilter(field_name="issue_date", lookup_expr="gte") + issue_date_to = filters.DateFilter(field_name="issue_date", lookup_expr="lte") + + class Meta: + model = IndustrialCertificate + fields = ["organization", "issue_date_from", "issue_date_to"] + + +class ManufacturerRegistryEntryFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + + class Meta: + model = ManufacturerRegistryEntry + fields = ["organization"] + + class ProsecutorCheckFilter(filters.FilterSet): organization = filters.UUIDFilter(field_name="organization_id") law_type = filters.CharFilter(lookup_expr="exact") @@ -127,6 +151,15 @@ class LaborVacancyFilter(filters.FilterSet): ] +class FinancialReportFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + status = filters.CharFilter(lookup_expr="exact") + + class Meta: + model = FinancialReport + fields = ["organization", "status"] + + class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]): queryset = IndustrialProduct.objects.select_related("organization").all() serializer_class = IndustrialProductSerializer @@ -137,6 +170,28 @@ class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]): ordering = ["product_name"] +class IndustrialCertificateViewSet(ClassicReadOnlyViewSet[IndustrialCertificate]): + queryset = IndustrialCertificate.objects.select_related("organization").all() + serializer_class = IndustrialCertificateSerializer + permission_classes = [IsAuthenticated] + filterset_class = IndustrialCertificateFilter + search_fields = ["certificate_number", "organisation_name", "ogrn"] + ordering_fields = ["issue_date", "expiry_date", "created_at"] + ordering = ["-issue_date"] + + +class ManufacturerRegistryEntryViewSet( + ClassicReadOnlyViewSet[ManufacturerRegistryEntry] +): + queryset = ManufacturerRegistryEntry.objects.select_related("organization").all() + serializer_class = ManufacturerRegistryEntrySerializer + permission_classes = [IsAuthenticated] + filterset_class = ManufacturerRegistryEntryFilter + search_fields = ["full_legal_name", "inn", "ogrn", "address"] + ordering_fields = ["full_legal_name", "created_at"] + ordering = ["full_legal_name"] + + class ProsecutorCheckViewSet(ClassicReadOnlyViewSet[ProsecutorCheck]): queryset = ProsecutorCheck.objects.select_related("organization").all() serializer_class = ProsecutorCheckSerializer @@ -215,3 +270,15 @@ class LaborVacancyViewSet(ClassicReadOnlyViewSet[LaborVacancy]): search_fields = ["title", "status"] ordering_fields = ["published_at", "created_at", "salary_amount"] ordering = ["-published_at"] + + +class FinancialReportViewSet(ClassicReadOnlyViewSet[FinancialReport]): + queryset = FinancialReport.objects.select_related("organization").prefetch_related( + "lines" + ) + serializer_class = FinancialReportSerializer + permission_classes = [IsAuthenticated] + filterset_class = FinancialReportFilter + search_fields = ["external_id", "file_name", "ogrn", "status"] + ordering_fields = ["created_at", "updated_at"] + ordering = ["-created_at"] diff --git a/src/apps/external_data/migrations/0004_auto_20260527_1928.py b/src/apps/external_data/migrations/0004_auto_20260527_1928.py new file mode 100644 index 0000000..ee30fdf --- /dev/null +++ b/src/apps/external_data/migrations/0004_auto_20260527_1928.py @@ -0,0 +1,362 @@ +# Generated by Django 3.2.25 on 2026-05-27 19:28 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("organization", "0003_auto_20260407_1326"), + ("external_data", "0003_exchange_additional_external_data"), + ] + + operations = [ + migrations.CreateModel( + name="FinancialReport", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="Дата и время создания записи", + verbose_name="создано", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Дата и время последнего обновления", + verbose_name="обновлено", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.CharField( + db_index=True, max_length=255, verbose_name="внешний ID" + ), + ), + ( + "ogrn", + models.CharField( + blank=True, default="", max_length=15, verbose_name="ОГРН" + ), + ), + ( + "file_name", + models.CharField( + blank=True, default="", max_length=255, verbose_name="имя файла" + ), + ), + ( + "file_hash", + models.CharField( + blank=True, default="", max_length=64, verbose_name="хеш файла" + ), + ), + ( + "load_batch", + models.PositiveIntegerField( + blank=True, + db_index=True, + null=True, + verbose_name="ID пакета загрузки", + ), + ), + ( + "status", + models.CharField( + blank=True, + db_index=True, + default="", + max_length=64, + verbose_name="статус", + ), + ), + ( + "source", + models.CharField( + blank=True, default="", max_length=64, verbose_name="источник" + ), + ), + ( + "error_message", + models.TextField( + blank=True, default="", verbose_name="сообщение об ошибке" + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="financial_reports", + to="organization.organization", + verbose_name="организация", + ), + ), + ], + options={ + "ordering": ["-created_at", "external_id"], + }, + ), + migrations.CreateModel( + name="ManufacturerRegistryEntry", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="Дата и время создания записи", + verbose_name="создано", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Дата и время последнего обновления", + verbose_name="обновлено", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "full_legal_name", + models.CharField( + db_index=True, + max_length=1024, + verbose_name="полное наименование", + ), + ), + ( + "inn", + models.CharField(db_index=True, max_length=12, verbose_name="ИНН"), + ), + ( + "ogrn", + models.CharField( + blank=True, default="", max_length=15, verbose_name="ОГРН" + ), + ), + ( + "address", + models.TextField(blank=True, default="", verbose_name="адрес"), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="manufacturer_registry_entries", + to="organization.organization", + verbose_name="организация", + ), + ), + ], + options={ + "ordering": ["full_legal_name", "created_at"], + }, + ), + migrations.CreateModel( + name="IndustrialCertificate", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="Дата и время создания записи", + verbose_name="создано", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Дата и время последнего обновления", + verbose_name="обновлено", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "certificate_number", + models.CharField( + db_index=True, max_length=100, verbose_name="номер сертификата" + ), + ), + ( + "issue_date", + models.DateField(blank=True, null=True, verbose_name="дата выдачи"), + ), + ( + "expiry_date", + models.DateField( + blank=True, null=True, verbose_name="дата окончания" + ), + ), + ( + "certificate_file_url", + models.TextField( + blank=True, + default="", + verbose_name="ссылка на файл сертификата", + ), + ), + ( + "organisation_name", + models.CharField( + blank=True, + default="", + max_length=500, + verbose_name="наименование организации из источника", + ), + ), + ( + "ogrn", + models.CharField( + blank=True, default="", max_length=15, verbose_name="ОГРН" + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="industrial_certificates", + to="organization.organization", + verbose_name="организация", + ), + ), + ], + options={ + "ordering": ["-issue_date", "certificate_number"], + }, + ), + migrations.CreateModel( + name="FinancialReportLine", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="Дата и время создания записи", + verbose_name="создано", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Дата и время последнего обновления", + verbose_name="обновлено", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "form_code", + models.CharField( + db_index=True, max_length=10, verbose_name="код формы" + ), + ), + ( + "line_code", + models.CharField( + db_index=True, max_length=10, verbose_name="код строки" + ), + ), + ( + "line_name", + models.CharField( + max_length=255, verbose_name="наименование строки" + ), + ), + ( + "year", + models.PositiveSmallIntegerField(db_index=True, verbose_name="год"), + ), + ( + "period_start", + models.BigIntegerField( + blank=True, null=True, verbose_name="на начало периода" + ), + ), + ( + "period_end", + models.BigIntegerField( + blank=True, null=True, verbose_name="на конец периода" + ), + ), + ( + "report", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lines", + to="external_data.financialreport", + verbose_name="финансовый отчет", + ), + ), + ], + options={ + "ordering": ["year", "form_code", "line_code"], + }, + ), + migrations.AddIndex( + model_name="financialreportline", + index=models.Index( + fields=["report", "form_code", "line_code"], + name="external_da_report__9a15ff_idx", + ), + ), + migrations.AddIndex( + model_name="financialreportline", + index=models.Index( + fields=["year", "line_code"], name="external_da_year_cfe1f2_idx" + ), + ), + migrations.AddConstraint( + model_name="financialreportline", + constraint=models.UniqueConstraint( + fields=("report", "form_code", "line_code", "year"), + name="unique_external_financial_report_line_year", + ), + ), + ] diff --git a/src/apps/external_data/models.py b/src/apps/external_data/models.py index 262d3e6..c8e2c6d 100644 --- a/src/apps/external_data/models.py +++ b/src/apps/external_data/models.py @@ -34,6 +34,57 @@ class IndustrialProduct(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): return f"{self.product_name} ({self.organization_id})" +class IndustrialCertificate(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="industrial_certificates", + verbose_name=_("организация"), + ) + certificate_number = models.CharField( + _("номер сертификата"), max_length=100, db_index=True + ) + issue_date = models.DateField(_("дата выдачи"), null=True, blank=True) + expiry_date = models.DateField(_("дата окончания"), null=True, blank=True) + certificate_file_url = models.TextField( + _("ссылка на файл сертификата"), blank=True, default="" + ) + organisation_name = models.CharField( + _("наименование организации из источника"), + max_length=500, + blank=True, + default="", + ) + ogrn = models.CharField(_("ОГРН"), max_length=15, blank=True, default="") + + class Meta: + ordering = ["-issue_date", "certificate_number"] + + def __str__(self) -> str: + return f"{self.certificate_number} ({self.organization_id})" + + +class ManufacturerRegistryEntry(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="manufacturer_registry_entries", + verbose_name=_("организация"), + ) + full_legal_name = models.CharField( + _("полное наименование"), max_length=1024, db_index=True + ) + inn = models.CharField(_("ИНН"), max_length=12, db_index=True) + ogrn = models.CharField(_("ОГРН"), max_length=15, blank=True, default="") + address = models.TextField(_("адрес"), blank=True, default="") + + class Meta: + ordering = ["full_legal_name", "created_at"] + + def __str__(self) -> str: + return f"{self.full_legal_name} ({self.organization_id})" + + class ProsecutorCheck(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): organization = models.ForeignKey( Organization, @@ -247,3 +298,69 @@ class LaborVacancy(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): def __str__(self) -> str: return f"{self.title} ({self.organization_id})" + + +class FinancialReport(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="financial_reports", + verbose_name=_("организация"), + ) + external_id = models.CharField(_("внешний ID"), max_length=255, db_index=True) + ogrn = models.CharField(_("ОГРН"), max_length=15, blank=True, default="") + file_name = models.CharField(_("имя файла"), max_length=255, blank=True, default="") + file_hash = models.CharField(_("хеш файла"), max_length=64, blank=True, default="") + load_batch = models.PositiveIntegerField( + _("ID пакета загрузки"), null=True, blank=True, db_index=True + ) + status = models.CharField( + _("статус"), max_length=64, blank=True, default="", db_index=True + ) + source = models.CharField(_("источник"), max_length=64, blank=True, default="") + error_message = models.TextField(_("сообщение об ошибке"), blank=True, default="") + + class Meta: + ordering = ["-created_at", "external_id"] + + def __str__(self) -> str: + return f"{self.external_id} ({self.organization_id})" + + +class FinancialReportLine(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + report = models.ForeignKey( + FinancialReport, + on_delete=models.CASCADE, + related_name="lines", + verbose_name=_("финансовый отчет"), + ) + form_code = models.CharField(_("код формы"), max_length=10, db_index=True) + line_code = models.CharField(_("код строки"), max_length=10, db_index=True) + line_name = models.CharField(_("наименование строки"), max_length=255) + year = models.PositiveSmallIntegerField(_("год"), db_index=True) + period_start = models.BigIntegerField( + _("на начало периода"), + null=True, + blank=True, + ) + period_end = models.BigIntegerField( + _("на конец периода"), + null=True, + blank=True, + ) + + class Meta: + ordering = ["year", "form_code", "line_code"] + constraints = [ + models.UniqueConstraint( + fields=["report", "form_code", "line_code", "year"], + name="unique_external_financial_report_line_year", + ), + ] + indexes = [ + models.Index(fields=["report", "form_code", "line_code"]), + models.Index(fields=["year", "line_code"]), + ] + + def __str__(self) -> str: + return f"{self.line_code} ({self.line_name[:30]}) - {self.year}" diff --git a/src/apps/external_data/serializers.py b/src/apps/external_data/serializers.py index 3b48bd3..26364d3 100644 --- a/src/apps/external_data/serializers.py +++ b/src/apps/external_data/serializers.py @@ -4,9 +4,13 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, InformationSecurityRegistryEntry, LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -29,6 +33,38 @@ class IndustrialProductSerializer(serializers.ModelSerializer): ] +class IndustrialCertificateSerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = IndustrialCertificate + fields = [ + "id", + "organization", + "certificate_number", + "issue_date", + "expiry_date", + "certificate_file_url", + "organisation_name", + "ogrn", + ] + + +class ManufacturerRegistryEntrySerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = ManufacturerRegistryEntry + fields = [ + "id", + "organization", + "full_legal_name", + "inn", + "ogrn", + "address", + ] + + class ProsecutorCheckSerializer(serializers.ModelSerializer): organization = serializers.UUIDField(source="organization_id", read_only=True) @@ -152,4 +188,39 @@ class LaborVacancySerializer(serializers.ModelSerializer): ] +class FinancialReportLineSerializer(serializers.ModelSerializer): + class Meta: + model = FinancialReportLine + fields = [ + "id", + "form_code", + "line_code", + "line_name", + "year", + "period_start", + "period_end", + ] + + +class FinancialReportSerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + lines = FinancialReportLineSerializer(many=True, read_only=True) + + class Meta: + model = FinancialReport + fields = [ + "id", + "organization", + "external_id", + "ogrn", + "file_name", + "file_hash", + "load_batch", + "status", + "source", + "error_message", + "lines", + ] + + CorporationMembershipSerializer = InformationSecurityRegistryEntrySerializer diff --git a/src/apps/external_data/urls.py b/src/apps/external_data/urls.py index 4f69b05..1265596 100644 --- a/src/apps/external_data/urls.py +++ b/src/apps/external_data/urls.py @@ -5,9 +5,12 @@ from apps.external_data.api import ( BankruptcyProcedureViewSet, CorporationMembershipViewSet, DefenseUnreliableSupplierViewSet, + FinancialReportViewSet, + IndustrialCertificateViewSet, IndustrialProductViewSet, InformationSecurityRegistryEntryViewSet, LaborVacancyViewSet, + ManufacturerRegistryEntryViewSet, ProsecutorCheckViewSet, PublicProcurementViewSet, ) @@ -20,6 +23,16 @@ router = DefaultRouter() router.register( "industrial-products", IndustrialProductViewSet, basename="industrial-products" ) +router.register( + "industrial-certificates", + IndustrialCertificateViewSet, + basename="industrial-certificates", +) +router.register( + "manufacturers", + ManufacturerRegistryEntryViewSet, + basename="manufacturers", +) router.register( "prosecutor-checks", ProsecutorCheckViewSet, basename="prosecutor-checks" ) @@ -54,6 +67,11 @@ router.register( LaborVacancyViewSet, basename="labor-vacancies", ) +router.register( + "financial-reports", + FinancialReportViewSet, + basename="financial-reports", +) urlpatterns = [ path("", include(router.urls)), diff --git a/src/apps/form_1/admin.py b/src/apps/form_1/admin.py index bb6ace3..ae1b128 100644 --- a/src/apps/form_1/admin.py +++ b/src/apps/form_1/admin.py @@ -88,6 +88,7 @@ class FormF1RecordAdmin( ] list_filter = [ "report_year", + "report_month", "report_quarter", "is_active_version", "load_batch", @@ -105,7 +106,13 @@ class FormF1RecordAdmin( "superseded_by_batch", ] raw_id_fields = ["organization"] - ordering = ["-is_active_version", "-report_year", "-report_quarter", "-created_at"] + ordering = [ + "-is_active_version", + "-report_year", + "-report_month", + "-report_quarter", + "-created_at", + ] fieldsets = [ ( @@ -116,6 +123,7 @@ class FormF1RecordAdmin( "organization", "load_batch", "report_year", + "report_month", "report_quarter", ], }, diff --git a/src/apps/form_1/api.py b/src/apps/form_1/api.py index 544c3fd..194039a 100644 --- a/src/apps/form_1/api.py +++ b/src/apps/form_1/api.py @@ -9,6 +9,11 @@ API для формы Ф-1. import logging from apps.core.response import api_response +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_1.models import FormF1Record from apps.form_1.serializers import ( @@ -44,6 +49,7 @@ class FormF1Filter(filters.FilterSet): organization_inn = filters.CharFilter(field_name="organization__inn") load_batch = filters.NumberFilter() report_year = filters.NumberFilter() + report_month = filters.NumberFilter() report_quarter = filters.NumberFilter() class Meta: @@ -53,6 +59,7 @@ class FormF1Filter(filters.FilterSet): "organization_inn", "load_batch", "report_year", + "report_month", "report_quarter", ] @@ -76,7 +83,7 @@ class FormF1UploadView(APIView): ), request_body=FormF1UploadSerializer, responses={ - 200: ParseResultSerializer, + 200: "Файл обработан", 202: "Задача поставлена в очередь (для больших файлов)", 400: "Ошибка валидации", }, @@ -84,23 +91,42 @@ class FormF1UploadView(APIView): def post(self, request: Request) -> Response: """Загрузка и обработка файла.""" serializer = FormF1UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] - report_quarter = serializer.validated_data.get("report_quarter") + report_month = serializer.validated_data["report_month"] # Определяем размер файла для выбора режима обработки file_size = file.size # Для небольших файлов - синхронная обработка if file_size < 1024 * 1024: # < 1MB - result = parse_form_f1_file( - file, - report_year=report_year, - report_quarter=report_quarter, - ) - return api_response(result.to_dict()) + try: + result = parse_form_f1_file( + file, + report_year=report_year, + report_month=report_month, + ) + result_serializer = ParseResultSerializer(result) + return Response( + build_upload_success_payload( + form="f1", + report_year=report_year, + report_month=report_month, + status="done", + result=result_serializer.data, + ), + status=status.HTTP_200_OK, + ) + except Exception as e: + logger.exception("Ошибка обработки файла Ф-1") + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + ) # Для больших файлов - фоновая обработка # Сохраняем файл во временное хранилище @@ -112,17 +138,17 @@ class FormF1UploadView(APIView): file_path=saved_path, user_id=request.user.id, report_year=report_year, - report_quarter=report_quarter, + report_month=report_month, ) return Response( - { - "success": True, - "data": { - "task_id": task.id, - "message": "Файл поставлен в очередь на обработку", - }, - }, + build_upload_success_payload( + form="f1", + report_year=report_year, + report_month=report_month, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -144,8 +170,14 @@ class FormF1RecordViewSet(ReadOnlyViewSet[FormF1Record]): permission_classes = [IsAuthenticated] filterset_class = FormF1Filter search_fields = ["organization__name", "organization__inn"] - ordering_fields = ["created_at", "load_batch", "report_year", "report_quarter"] - ordering = ["-report_year", "-report_quarter", "-created_at"] + ordering_fields = [ + "created_at", + "load_batch", + "report_year", + "report_month", + "report_quarter", + ] + ordering = ["-report_year", "-report_month", "-report_quarter", "-created_at"] serializer_classes = { "list": FormF1RecordListSerializer, diff --git a/src/apps/form_1/migrations/0003_auto_20260527_1954.py b/src/apps/form_1/migrations/0003_auto_20260527_1954.py new file mode 100644 index 0000000..9e7115f --- /dev/null +++ b/src/apps/form_1/migrations/0003_auto_20260527_1954.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.25 on 2026-05-27 19:54 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('form_1', '0002_auto_20260328_1621'), + ] + + operations = [ + migrations.AddField( + model_name='formf1record', + name='report_month', + field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Месяц отчетности от 1 до 12 для ежемесячной формы Ф-1.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(12)], verbose_name='отчетный месяц'), + ), + migrations.AddIndex( + model_name='formf1record', + index=models.Index(fields=['organization', 'report_year', 'report_month', 'is_active_version'], name='form_1_form_organiz_e22c8d_idx'), + ), + migrations.AddIndex( + model_name='formf1record', + index=models.Index(fields=['report_year', 'report_month', 'is_active_version'], name='form_1_form_report__e64194_idx'), + ), + migrations.AddConstraint( + model_name='formf1record', + constraint=models.CheckConstraint(check=models.Q(('report_month__isnull', True), models.Q(('report_month__gte', 1), ('report_month__lte', 12)), _connector='OR'), name='form_1_f1_report_month_range'), + ), + ] diff --git a/src/apps/form_1/models.py b/src/apps/form_1/models.py index c924541..c923a3c 100644 --- a/src/apps/form_1/models.py +++ b/src/apps/form_1/models.py @@ -9,9 +9,25 @@ import uuid from apps.core.mixins import ReportingPeriodMixin, TimestampMixin from apps.organization.models import Organization +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ +_MONTH_NAMES = { + 1: "Январь", + 2: "Февраль", + 3: "Март", + 4: "Апрель", + 5: "Май", + 6: "Июнь", + 7: "Июль", + 8: "Август", + 9: "Сентябрь", + 10: "Октябрь", + 11: "Ноябрь", + 12: "Декабрь", +} + class FormF1Record(ReportingPeriodMixin, TimestampMixin, models.Model): """ @@ -38,6 +54,14 @@ class FormF1Record(ReportingPeriodMixin, TimestampMixin, models.Model): db_index=True, help_text=_("Идентификатор пакета загрузки"), ) + report_month = models.PositiveSmallIntegerField( + _("отчетный месяц"), + null=True, + blank=True, + db_index=True, + validators=[MinValueValidator(1), MaxValueValidator(12)], + help_text=_("Месяц отчетности от 1 до 12 для ежемесячной формы Ф-1."), + ) # === Выпуск военной продукции (фактические цены) === military_output_actual = models.DecimalField( @@ -246,6 +270,15 @@ class FormF1Record(ReportingPeriodMixin, TimestampMixin, models.Model): indexes = [ models.Index(fields=["organization", "load_batch"]), models.Index(fields=["load_batch"]), + models.Index( + fields=[ + "organization", + "report_year", + "report_month", + "is_active_version", + ] + ), + models.Index(fields=["report_year", "report_month", "is_active_version"]), models.Index( fields=[ "organization", @@ -262,7 +295,33 @@ class FormF1Record(ReportingPeriodMixin, TimestampMixin, models.Model): | models.Q(report_quarter__gte=1, report_quarter__lte=4), name="form_1_f1_report_quarter_range", ), + models.CheckConstraint( + check=models.Q(report_month__isnull=True) + | models.Q(report_month__gte=1, report_month__lte=12), + name="form_1_f1_report_month_range", + ), ] + @property + def report_period_display(self) -> str: + """Человекочитаемый отчетный период Ф-1.""" + if self.report_month: + return f"{_MONTH_NAMES.get(self.report_month, self.report_month)} {self.report_year}" + return super().report_period_display + + @property + def report_period_short_label(self) -> str: + """Короткая подпись периода Ф-1.""" + if self.report_month: + return f"M{self.report_month:02d} {self.report_year}" + return super().report_period_short_label + + @property + def report_period_key(self) -> str: + """Стабильный ключ периода Ф-1.""" + if self.report_month: + return f"{self.report_year}-M{self.report_month:02d}" + return super().report_period_key + def __str__(self) -> str: return f"Ф-1: {self.organization.name} (batch: {self.load_batch})" diff --git a/src/apps/form_1/serializers.py b/src/apps/form_1/serializers.py index d8b4d97..ab8d5a7 100644 --- a/src/apps/form_1/serializers.py +++ b/src/apps/form_1/serializers.py @@ -8,6 +8,7 @@ - FormF1ParseResultSerializer - результат парсинга """ +from apps.core.upload_contracts import UploadMonthSerializer from apps.form_1.models import FormF1Record from apps.organization.serializers import OrganizationListSerializer from rest_framework import serializers @@ -26,6 +27,7 @@ class FormF1RecordSerializer(serializers.ModelSerializer): "organization", "load_batch", "report_year", + "report_month", "report_quarter", "report_period_display", # Военная продукция (факт.) @@ -86,6 +88,7 @@ class FormF1RecordListSerializer(serializers.ModelSerializer): "organization_inn", "load_batch", "report_year", + "report_month", "report_quarter", "report_period_display", "military_output_actual", @@ -94,31 +97,8 @@ class FormF1RecordListSerializer(serializers.ModelSerializer): ] -class FormF1UploadSerializer(serializers.Serializer): - """Сериализатор для загрузки файла.""" - - file = serializers.FileField(help_text="Excel файл формы Ф-1 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - def validate_file(self, value): - """Валидация загруженного файла.""" - if not value.name.endswith((".xlsx", ".xls")): - raise serializers.ValidationError( - "Файл должен быть в формате Excel (.xlsx или .xls)" - ) - - # Проверка размера файла (макс. 50MB) - if value.size > 50 * 1024 * 1024: - raise serializers.ValidationError("Размер файла не должен превышать 50MB") - - return value +class FormF1UploadSerializer(UploadMonthSerializer): + """Загрузка файла формы Ф-1 (ежемесячная отчетность).""" class FieldErrorSerializer(serializers.Serializer): diff --git a/src/apps/form_1/services.py b/src/apps/form_1/services.py index 898ebe3..6e46672 100644 --- a/src/apps/form_1/services.py +++ b/src/apps/form_1/services.py @@ -275,23 +275,20 @@ class FormF1Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF1Record]): """Создать запись формы Ф-1.""" row_data = self._normalize_row_data(row_data) batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id()) - # Получаем или создаём организацию - org, _ = OrganizationService.get_or_create_by_inn( - inn=row_data.inn, - defaults={ - "name": row_data.organization_name, - "ogrn": row_data.ogrn or "", - "okpo": row_data.okpo or "", - "kpp": row_data.kpp or "", - }, - ) + org = OrganizationService.get_required_by_inn(row_data.inn) + extra_period_fields = {} + report_quarter = self.report_quarter + if self.report_month is not None: + extra_period_fields["report_month"] = self.report_month + report_quarter = None # Создаём запись формы record = FormF1Service.create_versioned_record( organization=org, load_batch=batch_id, report_year=self.report_year, - report_quarter=self.report_quarter, + report_quarter=report_quarter, + extra_period_fields=extra_period_fields, **row_data.fields, ) @@ -302,7 +299,7 @@ def parse_form_f1_file( file, *, report_year: int, - report_quarter: int | None = None, + report_month: int, ) -> ParseResult: """ Парсит Excel файл формы Ф-1. @@ -313,5 +310,5 @@ def parse_form_f1_file( Returns: ParseResult с результатами парсинга """ - parser = FormF1Parser(report_year=report_year, report_quarter=report_quarter) + parser = FormF1Parser(report_year=report_year, report_month=report_month) return parser.parse(file) diff --git a/src/apps/form_1/tasks.py b/src/apps/form_1/tasks.py index 9065880..03b775b 100644 --- a/src/apps/form_1/tasks.py +++ b/src/apps/form_1/tasks.py @@ -23,6 +23,7 @@ def process_form_f1_file( file_path: str, user_id: int | None = None, report_year: int | None = None, + report_month: int | None = None, report_quarter: int | None = None, ): """ @@ -47,7 +48,9 @@ def process_form_f1_file( result = parse_form_f1_file( f, report_year=report_year or timezone.now().year, - report_quarter=report_quarter, + report_month=report_month + or (report_quarter * 3 if report_quarter is not None else None) + or timezone.now().month, ) job.update_progress(90, "Завершение...") diff --git a/src/apps/form_2/api.py b/src/apps/form_2/api.py index 06985c3..6120006 100644 --- a/src/apps/form_2/api.py +++ b/src/apps/form_2/api.py @@ -23,6 +23,7 @@ from apps.form_2.serializers import ( ) from apps.form_2.services import parse_form_f2_file from apps.form_2.tasks import process_form_f2_file +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated @@ -44,6 +45,16 @@ class FormF2UploadView(APIView): parser_classes = [MultiPartParser] + @swagger_auto_schema( + tags=["Форма Ф-2"], + operation_summary="Загрузка файла Ф-2", + request_body=FormF2UploadSerializer, + responses={ + 200: "Файл обработан", + 202: "Задача поставлена в очередь", + 400: "Ошибка валидации", + }, + ) def post(self, request): """Загрузка и обработка файла.""" serializer = FormF2UploadSerializer(data=request.data) diff --git a/src/apps/form_2/services.py b/src/apps/form_2/services.py index f5ad9fa..3f78649 100644 --- a/src/apps/form_2/services.py +++ b/src/apps/form_2/services.py @@ -366,15 +366,7 @@ class FormF2Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF2Record]): """Создать запись формы Ф-2.""" row_data = self._normalize_row_data(row_data) batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id()) - org, _ = OrganizationService.get_or_create_by_inn( - inn=row_data.inn, - defaults={ - "name": row_data.organization_name, - "ogrn": row_data.ogrn or "", - "okpo": row_data.okpo or "", - "kpp": row_data.kpp or "", - }, - ) + org = OrganizationService.get_required_by_inn(row_data.inn) record = FormF2Service.create_versioned_record( organization=org, diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py index 6705ea4..84c10fd 100644 --- a/src/apps/form_3/api.py +++ b/src/apps/form_3/api.py @@ -23,6 +23,7 @@ from apps.form_3.serializers import ( ) from apps.form_3.services import parse_form_f3_file from apps.form_3.tasks import process_form_f3_file +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated @@ -43,6 +44,16 @@ class FormF3UploadView(APIView): parser_classes = [MultiPartParser] + @swagger_auto_schema( + tags=["Форма Ф-3"], + operation_summary="Загрузка файла Ф-3", + request_body=FormF3UploadSerializer, + responses={ + 200: "Файл обработан", + 202: "Задача поставлена в очередь", + 400: "Ошибка валидации", + }, + ) def post(self, request): """Загрузка и обработка файла.""" serializer = FormF3UploadSerializer(data=request.data) diff --git a/src/apps/form_3/services.py b/src/apps/form_3/services.py index a853694..d79d1ea 100644 --- a/src/apps/form_3/services.py +++ b/src/apps/form_3/services.py @@ -185,15 +185,7 @@ class FormF3Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF3Record]): """Создать запись формы Ф-3.""" row_data = self._normalize_row_data(row_data) batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id()) - org, _ = OrganizationService.get_or_create_by_inn( - inn=row_data.inn, - defaults={ - "name": row_data.organization_name, - "ogrn": row_data.ogrn or "", - "okpo": row_data.okpo or "", - "kpp": row_data.kpp or "", - }, - ) + org = OrganizationService.get_required_by_inn(row_data.inn) record = FormF3Service.create_versioned_record( organization=org, diff --git a/src/apps/form_4/admin.py b/src/apps/form_4/admin.py index 6b3842d..9696ae7 100644 --- a/src/apps/form_4/admin.py +++ b/src/apps/form_4/admin.py @@ -71,6 +71,7 @@ class FormF4RecordAdmin( ] list_filter = [ "report_year", + "report_half_year", "report_quarter", "is_active_version", "load_batch", @@ -88,7 +89,13 @@ class FormF4RecordAdmin( "superseded_by_batch", ] raw_id_fields = ["organization"] - ordering = ["-is_active_version", "-report_year", "-report_quarter", "-created_at"] + ordering = [ + "-is_active_version", + "-report_year", + "-report_half_year", + "-report_quarter", + "-created_at", + ] fieldsets = [ ( @@ -99,6 +106,7 @@ class FormF4RecordAdmin( "organization", "load_batch", "report_year", + "report_half_year", "report_quarter", ] }, diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py index 6832732..d84e9ab 100644 --- a/src/apps/form_4/api.py +++ b/src/apps/form_4/api.py @@ -17,6 +17,7 @@ from apps.form_4.serializers import ( ) from apps.form_4.services import parse_form_f4_file from apps.form_4.tasks import process_form_f4_file +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated @@ -30,6 +31,16 @@ BACKGROUND_THRESHOLD = 1024 * 1024 class FormF4UploadView(APIView): parser_classes = [MultiPartParser] + @swagger_auto_schema( + tags=["Форма Ф-4"], + operation_summary="Загрузка файла Ф-4", + request_body=FormF4UploadSerializer, + responses={ + 200: "Файл обработан", + 202: "Задача поставлена в очередь", + 400: "Ошибка валидации", + }, + ) def post(self, request): serializer = FormF4UploadSerializer(data=request.data) if not serializer.is_valid(): @@ -37,15 +48,14 @@ class FormF4UploadView(APIView): file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] - report_half_year = serializer.validated_data.get("report_half_year") - report_quarter = report_half_year + report_half_year = serializer.validated_data["report_half_year"] if file.size > BACKGROUND_THRESHOLD: task = process_form_f4_file.delay( file.read(), file.name, report_year, - report_quarter, + report_half_year, ) return Response( build_upload_success_payload( @@ -62,7 +72,7 @@ class FormF4UploadView(APIView): result = parse_form_f4_file( file, report_year=report_year, - report_quarter=report_quarter, + report_half_year=report_half_year, ) return Response( build_upload_success_payload( @@ -101,11 +111,14 @@ class FormF4RecordViewSet(ReadOnlyViewSet[FormF4Record]): qs = super().get_queryset() batch_id = self.request.query_params.get("batch_id") report_year = self.request.query_params.get("report_year") + report_half_year = self.request.query_params.get("report_half_year") report_quarter = self.request.query_params.get("report_quarter") if batch_id: qs = qs.filter(load_batch=batch_id) if report_year: qs = qs.filter(report_year=report_year) + if report_half_year: + qs = qs.filter(report_half_year=report_half_year) if report_quarter: qs = qs.filter(report_quarter=report_quarter) return qs diff --git a/src/apps/form_4/migrations/0003_auto_20260527_1954.py b/src/apps/form_4/migrations/0003_auto_20260527_1954.py new file mode 100644 index 0000000..88b4f0a --- /dev/null +++ b/src/apps/form_4/migrations/0003_auto_20260527_1954.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.25 on 2026-05-27 19:54 + +import django.core.validators +from django.db import migrations, models +from django.db.models import F + + +def forwards_copy_legacy_half_year(apps, schema_editor): + form_f4_record = apps.get_model('form_4', 'FormF4Record') + form_f4_record.objects.filter( + report_half_year__isnull=True, + report_quarter__in=(1, 2), + ).update( + report_half_year=F('report_quarter'), + report_quarter=None, + ) + + +def backwards_restore_legacy_quarter(apps, schema_editor): + form_f4_record = apps.get_model('form_4', 'FormF4Record') + form_f4_record.objects.filter( + report_quarter__isnull=True, + report_half_year__isnull=False, + ).update(report_quarter=F('report_half_year')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('form_4', '0002_auto_20260328_1621'), + ] + + operations = [ + migrations.AddField( + model_name='formf4record', + name='report_half_year', + field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Полугодие отчетности: 1 или 2 для формы Ф-4.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(2)], verbose_name='отчетное полугодие'), + ), + migrations.RunPython( + forwards_copy_legacy_half_year, + backwards_restore_legacy_quarter, + ), + migrations.AddIndex( + model_name='formf4record', + index=models.Index(fields=['organization', 'report_year', 'report_half_year', 'is_active_version'], name='form_4_form_organiz_279d43_idx'), + ), + migrations.AddIndex( + model_name='formf4record', + index=models.Index(fields=['report_year', 'report_half_year', 'is_active_version'], name='form_4_form_report__51dd40_idx'), + ), + migrations.AddConstraint( + model_name='formf4record', + constraint=models.CheckConstraint(check=models.Q(('report_half_year__isnull', True), models.Q(('report_half_year__gte', 1), ('report_half_year__lte', 2)), _connector='OR'), name='form_4_f4_report_half_year_range'), + ), + ] diff --git a/src/apps/form_4/models.py b/src/apps/form_4/models.py index 96a50ad..02eb641 100644 --- a/src/apps/form_4/models.py +++ b/src/apps/form_4/models.py @@ -9,9 +9,15 @@ import uuid from apps.core.mixins import ReportingPeriodMixin, TimestampMixin from apps.organization.models import Organization +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ +_ROMAN_NUMBERS = { + 1: "I", + 2: "II", +} + class FormF4Record(ReportingPeriodMixin, TimestampMixin, models.Model): """ @@ -37,6 +43,14 @@ class FormF4Record(ReportingPeriodMixin, TimestampMixin, models.Model): db_index=True, help_text=_("Идентификатор пакета загрузки"), ) + report_half_year = models.PositiveSmallIntegerField( + _("отчетное полугодие"), + null=True, + blank=True, + db_index=True, + validators=[MinValueValidator(1), MaxValueValidator(2)], + help_text=_("Полугодие отчетности: 1 или 2 для формы Ф-4."), + ) # === Выручка === revenue_rsbu = models.DecimalField( @@ -243,6 +257,17 @@ class FormF4Record(ReportingPeriodMixin, TimestampMixin, models.Model): indexes = [ models.Index(fields=["organization", "load_batch"]), models.Index(fields=["load_batch"]), + models.Index( + fields=[ + "organization", + "report_year", + "report_half_year", + "is_active_version", + ] + ), + models.Index( + fields=["report_year", "report_half_year", "is_active_version"] + ), models.Index( fields=[ "organization", @@ -259,7 +284,36 @@ class FormF4Record(ReportingPeriodMixin, TimestampMixin, models.Model): | models.Q(report_quarter__gte=1, report_quarter__lte=4), name="form_4_f4_report_quarter_range", ), + models.CheckConstraint( + check=models.Q(report_half_year__isnull=True) + | models.Q(report_half_year__gte=1, report_half_year__lte=2), + name="form_4_f4_report_half_year_range", + ), ] + @property + def report_period_display(self) -> str: + """Человекочитаемый отчетный период Ф-4.""" + if self.report_half_year: + return ( + f"{_ROMAN_NUMBERS.get(self.report_half_year, self.report_half_year)} " + f"полугодие {self.report_year}" + ) + return super().report_period_display + + @property + def report_period_short_label(self) -> str: + """Короткая подпись периода Ф-4.""" + if self.report_half_year: + return f"H{self.report_half_year} {self.report_year}" + return super().report_period_short_label + + @property + def report_period_key(self) -> str: + """Стабильный ключ периода Ф-4.""" + if self.report_half_year: + return f"{self.report_year}-H{self.report_half_year}" + return super().report_period_key + def __str__(self) -> str: return f"Ф-4: {self.organization.name} (batch: {self.load_batch})" diff --git a/src/apps/form_4/serializers.py b/src/apps/form_4/serializers.py index 22db8c5..e805ab6 100644 --- a/src/apps/form_4/serializers.py +++ b/src/apps/form_4/serializers.py @@ -34,6 +34,7 @@ class FormF4RecordListSerializer(serializers.ModelSerializer): "organization_inn", "load_batch", "report_year", + "report_half_year", "report_quarter", "report_period_display", "revenue_rsbu", diff --git a/src/apps/form_4/services.py b/src/apps/form_4/services.py index 2ae80f3..734e765 100644 --- a/src/apps/form_4/services.py +++ b/src/apps/form_4/services.py @@ -166,20 +166,21 @@ class FormF4Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF4Record]): ) -> FormF4Record: row_data = self._normalize_row_data(row_data) batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id()) - org, _ = OrganizationService.get_or_create_by_inn( - inn=row_data.inn, - defaults={ - "name": row_data.organization_name, - "ogrn": row_data.ogrn or "", - "okpo": row_data.okpo or "", - "kpp": row_data.kpp or "", - }, - ) + org = OrganizationService.get_required_by_inn(row_data.inn) + report_half_year = self.report_half_year + if report_half_year is None and self.report_quarter in {1, 2}: + report_half_year = self.report_quarter + report_quarter = None if report_half_year is not None else self.report_quarter return FormF4Service.create_versioned_record( organization=org, load_batch=batch_id, report_year=self.report_year, - report_quarter=self.report_quarter, + report_quarter=report_quarter, + extra_period_fields=( + {"report_half_year": report_half_year} + if report_half_year is not None + else {} + ), **row_data.fields, ) @@ -188,7 +189,7 @@ def parse_form_f4_file( file, *, report_year: int, - report_quarter: int | None = None, + report_half_year: int, ) -> ParseResult: - parser = FormF4Parser(report_year=report_year, report_quarter=report_quarter) + parser = FormF4Parser(report_year=report_year, report_half_year=report_half_year) return parser.parse(file) diff --git a/src/apps/form_4/tasks.py b/src/apps/form_4/tasks.py index 9e337d7..bb8e91d 100644 --- a/src/apps/form_4/tasks.py +++ b/src/apps/form_4/tasks.py @@ -16,10 +16,14 @@ def process_form_f4_file( file_content: bytes, file_name: str, report_year: int, + report_half_year: int | None = None, report_quarter: int | None = None, ) -> dict: logger.info(f"Начало обработки файла Ф-4: {file_name}") - parser = FormF4Parser(report_year=report_year, report_quarter=report_quarter) + parser = FormF4Parser( + report_year=report_year, + report_half_year=report_half_year or report_quarter, + ) result = parser.parse(BytesIO(file_content)) logger.info( f"Обработка Ф-4 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено" diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py index b183704..a1d2c59 100644 --- a/src/apps/form_5/api.py +++ b/src/apps/form_5/api.py @@ -17,6 +17,7 @@ from apps.form_5.serializers import ( ) from apps.form_5.services import parse_form_f5_file from apps.form_5.tasks import process_form_f5_file +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated @@ -30,6 +31,16 @@ BACKGROUND_THRESHOLD = 1024 * 1024 class FormF5UploadView(APIView): parser_classes = [MultiPartParser] + @swagger_auto_schema( + tags=["Форма Ф-5"], + operation_summary="Загрузка файла Ф-5", + request_body=FormF5UploadSerializer, + responses={ + 200: "Файл обработан", + 202: "Задача поставлена в очередь", + 400: "Ошибка валидации", + }, + ) def post(self, request): serializer = FormF5UploadSerializer(data=request.data) if not serializer.is_valid(): diff --git a/src/apps/form_5/services.py b/src/apps/form_5/services.py index 4735750..1c1a3b2 100644 --- a/src/apps/form_5/services.py +++ b/src/apps/form_5/services.py @@ -148,15 +148,7 @@ class FormF5Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF5Record]): ) -> FormF5Record: row_data = self._normalize_row_data(row_data) batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id()) - org, _ = OrganizationService.get_or_create_by_inn( - inn=row_data.inn, - defaults={ - "name": row_data.organization_name, - "ogrn": row_data.ogrn or "", - "okpo": row_data.okpo or "", - "kpp": row_data.kpp or "", - }, - ) + org = OrganizationService.get_required_by_inn(row_data.inn) return FormF5Service.create_versioned_record( organization=org, load_batch=batch_id, diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py index 7029c6c..de8d18f 100644 --- a/src/apps/form_6/api.py +++ b/src/apps/form_6/api.py @@ -17,6 +17,7 @@ from apps.form_6.serializers import ( ) from apps.form_6.services import parse_form_f6_file from apps.form_6.tasks import process_form_f6_file +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated @@ -30,6 +31,16 @@ BACKGROUND_THRESHOLD = 1024 * 1024 class FormF6UploadView(APIView): parser_classes = [MultiPartParser] + @swagger_auto_schema( + tags=["Форма Ф-6"], + operation_summary="Загрузка файла Ф-6", + request_body=FormF6UploadSerializer, + responses={ + 200: "Файл обработан", + 202: "Задача поставлена в очередь", + 400: "Ошибка валидации", + }, + ) def post(self, request): serializer = FormF6UploadSerializer(data=request.data) if not serializer.is_valid(): diff --git a/src/apps/form_6/services.py b/src/apps/form_6/services.py index ad5b64d..6bdea80 100644 --- a/src/apps/form_6/services.py +++ b/src/apps/form_6/services.py @@ -135,15 +135,7 @@ class FormF6Parser(ReportingPeriodParserMixin, BaseExcelParser[FormF6Record]): ) -> FormF6Record: row_data = self._normalize_row_data(row_data) batch_id = batch_id or getattr(self, "load_batch", self.get_next_batch_id()) - org, _ = OrganizationService.get_or_create_by_inn( - inn=row_data.inn, - defaults={ - "name": row_data.organization_name, - "ogrn": row_data.ogrn or "", - "okpo": row_data.okpo or "", - "kpp": row_data.kpp or "", - }, - ) + org = OrganizationService.get_required_by_inn(row_data.inn) return FormF6Service.create_versioned_record( organization=org, load_batch=batch_id, diff --git a/src/apps/organization/services.py b/src/apps/organization/services.py index 0331ddf..addd5fc 100644 --- a/src/apps/organization/services.py +++ b/src/apps/organization/services.py @@ -1,18 +1,13 @@ -""" -Сервисы для работы с организациями. +"""Services for organization lookup and read-side helpers.""" -Содержит: -- OrganizationService - CRUD операции и бизнес-логика -""" - -import logging from typing import Any from apps.core.services import BaseService from apps.organization.models import Organization -from django.db import transaction -logger = logging.getLogger(__name__) + +class OrganizationNotFoundError(ValueError): + """Raised when a non-Mostovik import references an unknown organization.""" class OrganizationService(BaseService[Organization]): @@ -20,7 +15,7 @@ class OrganizationService(BaseService[Organization]): Сервис для работы с организациями. Методы: - get_or_create_by_inn: Получить или создать организацию по ИНН + get_or_create_by_inn: Legacy lookup API. Creation is intentionally disabled. update_organization: Обновить данные организации search_by_name: Поиск по наименованию """ @@ -28,53 +23,35 @@ class OrganizationService(BaseService[Organization]): model = Organization @classmethod - @transaction.atomic def get_or_create_by_inn( cls, inn: str, defaults: dict[str, Any] | None = None, ) -> tuple[Organization, bool]: """ - Получить или создать организацию по ИНН. + Получить организацию по ИНН без создания новой записи. - Args: - inn: ИНН организации - defaults: Значения по умолчанию для создания - - Returns: - (Organization, created) - организация и флаг создания + Организации создаются только из Mostovik exchange-пакетов. Метод сохраняет + старую сигнатуру для существующих импортов форм, но при отсутствии ИНН + выбрасывает ошибку вместо создания справочника из вторичных источников. """ - defaults = defaults or {} + _ = defaults + return cls.get_required_by_inn(inn), False - org, created = cls.model.objects.get_or_create( - inn=inn, - defaults=defaults, - ) + @classmethod + def get_required_by_inn(cls, inn: str) -> Organization: + """Return organization by INN or fail explicitly.""" + normalized_inn = cls._clean_digits(inn) + if not normalized_inn: + raise OrganizationNotFoundError("ИНН организации не указан") - if created: - logger.info( - f"Создана организация: {org.name} (ИНН: {inn})", - extra={"inn": inn, "org_id": str(org.id)}, + organization = cls.get_by_inn(normalized_inn) + if organization is None: + raise OrganizationNotFoundError( + f"Организация с ИНН {normalized_inn} не найдена. " + "Сначала загрузите организацию из Mostovik exchange-пакета." ) - else: - # Обновляем данные если переданы новые - updated_fields = [] - for field, value in defaults.items(): - if value and getattr(org, field, None) != value: - # Обновляем только пустые поля или если значение изменилось - current = getattr(org, field, None) - if not current: - setattr(org, field, value) - updated_fields.append(field) - - if updated_fields: - org.save(update_fields=updated_fields + ["updated_at"]) - logger.info( - f"Обновлена организация: {org.name} (ИНН: {inn}), поля: {updated_fields}", - extra={"inn": inn, "org_id": str(org.id), "fields": updated_fields}, - ) - - return org, created + return organization @classmethod def search_by_name(cls, query: str, limit: int = 20): @@ -101,7 +78,16 @@ class OrganizationService(BaseService[Organization]): Returns: Organization или None """ + normalized_inn = cls._clean_digits(inn) + if not normalized_inn: + return None try: - return cls.model.objects.get(inn=inn) + return cls.model.objects.get(inn=normalized_inn) except cls.model.DoesNotExist: return None + + @staticmethod + def _clean_digits(value: Any) -> str: + if value is None: + return "" + return "".join(char for char in str(value).strip() if char.isdigit()) diff --git a/src/apps/registers/services.py b/src/apps/registers/services.py index 826d9e2..092a14f 100644 --- a/src/apps/registers/services.py +++ b/src/apps/registers/services.py @@ -87,6 +87,7 @@ class RegisterImportService: snapshot_org_ids, organizations_created, organizations_updated, + organizations_skipped, ) = cls._upsert_organizations(rows) active_by_org = cls._get_active_periods_by_org(registry) @@ -118,6 +119,7 @@ class RegisterImportService: "rows_in_file": len(rows), "organizations_created": organizations_created, "organizations_updated": organizations_updated, + "organizations_skipped": organizations_skipped, "opened_periods": opened_periods, "closed_periods": closed_periods, "active_periods": active_periods_count, @@ -127,63 +129,26 @@ class RegisterImportService: def _upsert_organizations( cls, rows: list[ParsedOrganization], - ) -> tuple[set, int, int]: + ) -> tuple[set, int, int, int]: snapshot_org_ids = set() organizations_created = 0 organizations_updated = 0 + organizations_skipped = 0 for row in rows: - organization, created = Organization.objects.get_or_create( - inn=row.inn, - defaults={ - "name": row.name, - "ogrn": row.ogrn, - "kpp": row.kpp, - "okpo": row.okpo, - }, - ) - - if created: - organizations_created += 1 - else: - updated = cls._update_organization_fields( - organization=organization, - row=row, - ) - if updated: - organizations_updated += 1 + organization = Organization.objects.filter(inn=row.inn).first() + if organization is None: + organizations_skipped += 1 + continue snapshot_org_ids.add(organization.id) - return snapshot_org_ids, organizations_created, organizations_updated - - @classmethod - def _update_organization_fields( - cls, - *, - organization: Organization, - row: ParsedOrganization, - ) -> bool: - update_fields: list[str] = [] - - if organization.name != row.name: - organization.name = row.name - update_fields.append("name") - if organization.ogrn != row.ogrn: - organization.ogrn = row.ogrn - update_fields.append("ogrn") - if organization.kpp != row.kpp: - organization.kpp = row.kpp - update_fields.append("kpp") - if organization.okpo != row.okpo: - organization.okpo = row.okpo - update_fields.append("okpo") - - if not update_fields: - return False - - organization.save(update_fields=update_fields + ["updated_at"]) - return True + return ( + snapshot_org_ids, + organizations_created, + organizations_updated, + organizations_skipped, + ) @classmethod def _get_active_periods_by_org( @@ -629,6 +594,7 @@ class RegisterBackupImportService: organization_map, organizations_created, organizations_updated, + organizations_skipped, ) = cls._upsert_organizations(organizations_rows) upload_map, uploads_created = cls._upsert_uploads( upload_rows=upload_rows, @@ -655,6 +621,7 @@ class RegisterBackupImportService: "registers_created": registers_created, "organizations_created": organizations_created, "organizations_updated": organizations_updated, + "organizations_skipped": organizations_skipped, "uploads_created": uploads_created, "periods_imported": periods_imported, "closed_periods": closed_periods, @@ -824,10 +791,11 @@ class RegisterBackupImportService: def _upsert_organizations( cls, rows: list[dict[str, object]], - ) -> tuple[dict[str, Organization], int, int]: + ) -> tuple[dict[str, Organization], int, int, int]: organization_map: dict[str, Organization] = {} created_count = 0 updated_count = 0 + skipped_count = 0 for row in rows: source_id = str(row.get("id") or "").strip() @@ -842,28 +810,14 @@ class RegisterBackupImportService: kpp=cls._clean_digits(row.get("in_kpp")), okpo=cls._clean_digits(row.get("mn_okpo")), ) - organization, created = Organization.objects.get_or_create( - inn=parsed.inn, - defaults={ - "name": parsed.name, - "ogrn": parsed.ogrn, - "kpp": parsed.kpp, - "okpo": parsed.okpo, - }, - ) - if created: - created_count += 1 - else: - updated = RegisterImportService._update_organization_fields( - organization=organization, - row=parsed, - ) - if updated: - updated_count += 1 + organization = Organization.objects.filter(inn=parsed.inn).first() + if organization is None: + skipped_count += 1 + continue organization_map[source_id] = organization - return organization_map, created_count, updated_count + return organization_map, created_count, updated_count, skipped_count @classmethod def _upsert_uploads( diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py index 765bd3c..8199b5f 100644 --- a/tests/apps/exchange/test_api.py +++ b/tests/apps/exchange/test_api.py @@ -18,9 +18,13 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, InformationSecurityRegistryEntry, LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -164,6 +168,26 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: "registry_number": "prod-001", } ], + "industrial_certificates": [ + { + "organization_inn": "7707083893", + "certificate_number": "CERT-001", + "issue_date": "2026-01-10", + "expiry_date": "2027-01-10", + "certificate_file_url": "https://minpromtorg.gov.ru/cert/001", + "organisation_name": "АО Альфа Обновленная", + "ogrn": "1027700132195", + } + ], + "manufacturers": [ + { + "organization_inn": "7707083893", + "full_legal_name": "АО Альфа Обновленная", + "inn": "7707083893", + "ogrn": "1027700132195", + "address": "г. Москва, ул. Тверская, д. 1", + } + ], "prosecutor_checks": [ { "organization_inn": "7707083893", @@ -188,6 +212,28 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: "purchase_name": "Поставка специализированного оборудования", } ], + "financial_reports": [ + { + "organization_inn": "7707083893", + "external_id": "fin-001", + "ogrn": "1027700132195", + "file_name": "fin_001_1027700132195.xlsx", + "file_hash": "f" * 64, + "load_batch": 7, + "status": "success", + "source": "api", + "lines": [ + { + "form_code": "1", + "line_code": "1600", + "line_name": "Баланс", + "year": 2025, + "period_start": 1000, + "period_end": 1500, + } + ], + } + ], "arbitration_cases": [ { "organization_inn": "7707083893", @@ -281,9 +327,13 @@ class ExchangePackageApiTest(APITestCase): self.assertEqual(response.data["result"]["organizations"]["created"], 1) self.assertEqual(response.data["result"]["organizations"]["updated"], 1) self.assertEqual(Organization.objects.count(), 2) + self.assertEqual(IndustrialCertificate.objects.count(), 1) + self.assertEqual(ManufacturerRegistryEntry.objects.count(), 1) self.assertEqual(IndustrialProduct.objects.count(), 1) self.assertEqual(ProsecutorCheck.objects.count(), 1) self.assertEqual(PublicProcurement.objects.count(), 1) + self.assertEqual(FinancialReport.objects.count(), 1) + self.assertEqual(FinancialReportLine.objects.count(), 1) self.assertEqual(ArbitrationCase.objects.count(), 1) self.assertEqual(BankruptcyProcedure.objects.count(), 1) self.assertEqual(DefenseUnreliableSupplier.objects.count(), 1) @@ -293,6 +343,10 @@ class ExchangePackageApiTest(APITestCase): response.data["result"]["bankruptcy_procedures"]["created"], 1, ) + self.assertEqual( + response.data["result"]["financial_reports"]["created_lines"], + 1, + ) self.assertEqual( response.data["result"]["defense_unreliable_suppliers"]["created"], 1, @@ -332,6 +386,43 @@ class ExchangePackageApiTest(APITestCase): self.assertEqual(ExchangePackageImport.objects.count(), 0) self.assertEqual(Organization.objects.count(), 0) + def test_upload_rejects_external_rows_for_organization_absent_from_package(self): + Organization.objects.create( + inn="7707083893", + name="АО Альфа", + ogrn="1027700132195", + kpp="770701001", + ) + archive = build_exchange_archive( + package_id="pkg-missing-package-organization", + data={ + "organizations": [], + "industrial_products": [ + { + "organization_inn": "7707083893", + "product_name": "Система связи М-1", + "registry_number": "prod-001", + } + ], + }, + ) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("отсутствует в разделе organizations", response.data["file"][0]) + self.assertEqual(Organization.objects.count(), 1) + self.assertEqual(IndustrialProduct.objects.count(), 0) + package_import = ExchangePackageImport.objects.get( + package_id="pkg-missing-package-organization" + ) + self.assertEqual(package_import.status, "failed") + def test_upload_is_idempotent_for_duplicate_package(self): archive = build_exchange_archive( package_id="pkg-duplicate-001", diff --git a/tests/apps/external_data/factories.py b/tests/apps/external_data/factories.py index 141d827..f6619b1 100644 --- a/tests/apps/external_data/factories.py +++ b/tests/apps/external_data/factories.py @@ -5,9 +5,13 @@ from apps.external_data.models import ( ArbitrationCase, BankruptcyProcedure, DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, InformationSecurityRegistryEntry, LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -30,6 +34,32 @@ class IndustrialProductFactory(factory.django.DjangoModelFactory): registry_number = factory.Sequence(lambda n: f"{1000 + n}/2026") +class IndustrialCertificateFactory(factory.django.DjangoModelFactory): + class Meta: + model = IndustrialCertificate + + organization = factory.SubFactory(OrganizationFactory) + certificate_number = factory.Sequence(lambda n: f"CERT-{n:04d}") + issue_date = factory.LazyAttribute(lambda _: fake.date_this_year()) + expiry_date = factory.LazyAttribute(lambda _: fake.date_this_decade()) + certificate_file_url = factory.Sequence( + lambda n: f"https://minpromtorg.gov.ru/cert/{n}" + ) + organisation_name = factory.LazyAttribute(lambda obj: obj.organization.name) + ogrn = factory.LazyAttribute(lambda obj: obj.organization.ogrn) + + +class ManufacturerRegistryEntryFactory(factory.django.DjangoModelFactory): + class Meta: + model = ManufacturerRegistryEntry + + organization = factory.SubFactory(OrganizationFactory) + full_legal_name = factory.LazyAttribute(lambda obj: obj.organization.name) + inn = factory.LazyAttribute(lambda obj: obj.organization.inn) + ogrn = factory.LazyAttribute(lambda obj: obj.organization.ogrn) + address = factory.LazyAttribute(lambda _: fake.address()) + + class ProsecutorCheckFactory(factory.django.DjangoModelFactory): class Meta: model = ProsecutorCheck @@ -126,3 +156,30 @@ class LaborVacancyFactory(factory.django.DjangoModelFactory): published_at = factory.LazyAttribute(lambda _: fake.date_this_year()) salary_amount = "175000.00" source_url = factory.Sequence(lambda n: f"https://trudvsem.ru/vacancy/{n}") + + +class FinancialReportFactory(factory.django.DjangoModelFactory): + class Meta: + model = FinancialReport + + organization = factory.SubFactory(OrganizationFactory) + external_id = factory.Sequence(lambda n: f"fin-{n:04d}") + ogrn = factory.LazyAttribute(lambda obj: obj.organization.ogrn) + file_name = factory.Sequence(lambda n: f"fin_{n:04d}.xlsx") + file_hash = factory.Sequence(lambda n: f"{n:064x}"[-64:]) + load_batch = factory.Sequence(lambda n: n + 1) + status = "success" + source = "api" + + +class FinancialReportLineFactory(factory.django.DjangoModelFactory): + class Meta: + model = FinancialReportLine + + report = factory.SubFactory(FinancialReportFactory) + form_code = "1" + line_code = "1600" + line_name = "Баланс" + year = 2025 + period_start = 1000 + period_end = 1500 diff --git a/tests/apps/external_data/test_api.py b/tests/apps/external_data/test_api.py index de4b9a0..6aa2ec6 100644 --- a/tests/apps/external_data/test_api.py +++ b/tests/apps/external_data/test_api.py @@ -12,9 +12,13 @@ from tests.apps.external_data.factories import ( ArbitrationCaseFactory, BankruptcyProcedureFactory, DefenseUnreliableSupplierFactory, + FinancialReportFactory, + FinancialReportLineFactory, + IndustrialCertificateFactory, IndustrialProductFactory, InformationSecurityRegistryEntryFactory, LaborVacancyFactory, + ManufacturerRegistryEntryFactory, ProsecutorCheckFactory, PublicProcurementFactory, ) @@ -48,6 +52,33 @@ class ExternalDataApiTest(APITestCase): response.data["results"][0]["organization"], str(self.organization.id) ) + def test_manufacturing_source_endpoints_are_exposed(self): + IndustrialCertificateFactory.create( + organization=self.organization, + certificate_number="CERT-001", + issue_date=date(2026, 1, 10), + ) + ManufacturerRegistryEntryFactory.create( + organization=self.organization, + full_legal_name="АО Альфа", + ) + IndustrialCertificateFactory.create(organization=self.other_organization) + ManufacturerRegistryEntryFactory.create(organization=self.other_organization) + + certificates_response = self.client.get( + f"/api/v1/industrial-certificates/?organization={self.organization.id}" + "&issue_date_from=2026-01-01&issue_date_to=2026-12-31" + ) + manufacturers_response = self.client.get( + f"/api/v1/manufacturers/?organization={self.organization.id}" + "&search=Альфа" + ) + + self.assertEqual(certificates_response.status_code, status.HTTP_200_OK) + self.assertEqual(manufacturers_response.status_code, status.HTTP_200_OK) + self.assertEqual(certificates_response.data["count"], 1) + self.assertEqual(manufacturers_response.data["count"], 1) + def test_prosecutor_checks_support_date_range_filters(self): ProsecutorCheckFactory.create( organization=self.organization, @@ -163,3 +194,20 @@ class ExternalDataApiTest(APITestCase): vacancies_response.data["results"][0]["title"], "Инженер-испытатель", ) + + def test_financial_reports_endpoint_returns_lines(self): + report = FinancialReportFactory.create( + organization=self.organization, + status="success", + ) + FinancialReportLineFactory.create(report=report, line_code="1600") + FinancialReportFactory.create(organization=self.other_organization) + + response = self.client.get( + f"/api/v1/financial-reports/?organization={self.organization.id}" + "&status=success" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["lines"][0]["line_code"], "1600") diff --git a/tests/apps/form_1/factories.py b/tests/apps/form_1/factories.py index 2c7cc68..1b773e4 100644 --- a/tests/apps/form_1/factories.py +++ b/tests/apps/form_1/factories.py @@ -18,7 +18,8 @@ class FormF1RecordFactory(factory.django.DjangoModelFactory): organization = factory.SubFactory(OrganizationFactory) load_batch = factory.Sequence(lambda n: n + 1) report_year = 2026 - report_quarter = 1 + report_month = 1 + report_quarter = None # Выпуск военной продукции (факт. цены) military_output_actual = factory.LazyAttribute( diff --git a/tests/apps/form_1/test_services.py b/tests/apps/form_1/test_services.py index 8afa2c4..924ba38 100644 --- a/tests/apps/form_1/test_services.py +++ b/tests/apps/form_1/test_services.py @@ -53,14 +53,16 @@ class FormF1ServiceTest(TestCase): organization=org, load_batch=501, report_year=2026, - report_quarter=1, + report_month=9, + report_quarter=None, ) current = FormF1Service.create_versioned_record( organization=org, load_batch=502, report_year=2026, - report_quarter=1, + report_quarter=None, + extra_period_fields={"report_month": 9}, military_output_actual=150, ) @@ -69,6 +71,8 @@ class FormF1ServiceTest(TestCase): self.assertEqual(previous.superseded_by_batch, 502) self.assertIsNotNone(previous.superseded_at) self.assertTrue(current.is_active_version) + self.assertEqual(current.report_month, 9) + self.assertIsNone(current.report_quarter) class FormF1ParserTest(TestCase): @@ -76,7 +80,7 @@ class FormF1ParserTest(TestCase): def test_get_column_mappings_returns_mappings(self): """Test get_column_mappings returns correct mappings.""" - parser = FormF1Parser(report_year=2026, report_quarter=1) + parser = FormF1Parser(report_year=2026, report_month=9) mappings = parser.get_column_mappings() self.assertIsInstance(mappings, list) @@ -87,10 +91,11 @@ class FormF1ParserTest(TestCase): self.assertIn("military_output_actual", field_names) self.assertIn("civilian_output_actual", field_names) - def test_create_record_creates_organization(self): - """Test create_record creates organization if not exists.""" - parser = FormF1Parser(report_year=2026, report_quarter=1) + def test_create_record_uses_existing_organization(self): + """Report imports must attach rows only to preloaded Mostovik organizations.""" + parser = FormF1Parser(report_year=2026, report_month=9) parser.load_batch = 505 + organization = OrganizationFactory.create(inn="1234567890") row_data = { "inn": "1234567890", @@ -103,7 +108,9 @@ class FormF1ParserTest(TestCase): record = parser.create_record(row_data) self.assertIsNotNone(record) - self.assertEqual(record.organization.inn, "1234567890") + self.assertEqual(record.organization_id, organization.id) self.assertEqual(record.military_output_actual, 100.0) self.assertEqual(record.report_year, 2026) - self.assertEqual(record.report_quarter, 1) + self.assertEqual(record.report_month, 9) + self.assertIsNone(record.report_quarter) + self.assertEqual(record.report_period_display, "Сентябрь 2026") diff --git a/tests/apps/form_2/test_services.py b/tests/apps/form_2/test_services.py index 7974fde..8415028 100644 --- a/tests/apps/form_2/test_services.py +++ b/tests/apps/form_2/test_services.py @@ -60,10 +60,11 @@ class FormF2ParserTest(TestCase): self.assertIn("total_assets", field_names) self.assertIn("revenue", field_names) - def test_create_record_creates_organization(self): - """Test create_record creates organization if not exists.""" + def test_create_record_uses_existing_organization(self): + """Report imports must attach rows only to preloaded Mostovik organizations.""" parser = FormF2Parser(report_year=2026, report_quarter=1) parser.load_batch = 502 + organization = OrganizationFactory.create(inn="2234567890") row_data = { "inn": "2234567890", @@ -75,4 +76,4 @@ class FormF2ParserTest(TestCase): record = parser.create_record(row_data) self.assertIsNotNone(record) - self.assertEqual(record.organization.inn, "2234567890") + self.assertEqual(record.organization_id, organization.id) diff --git a/tests/apps/form_3/test_services.py b/tests/apps/form_3/test_services.py index 87a8a3b..c8650f0 100644 --- a/tests/apps/form_3/test_services.py +++ b/tests/apps/form_3/test_services.py @@ -60,10 +60,11 @@ class FormF3ParserTest(TestCase): self.assertIn("avg_employees", field_names) self.assertIn("total_equipment", field_names) - def test_create_record_creates_organization(self): - """Test create_record creates organization if not exists.""" + def test_create_record_uses_existing_organization(self): + """Report imports must attach rows only to preloaded Mostovik organizations.""" parser = FormF3Parser(report_year=2026, report_quarter=1) parser.load_batch = 503 + organization = OrganizationFactory.create(inn="3234567890") row_data = { "inn": "3234567890", @@ -75,4 +76,4 @@ class FormF3ParserTest(TestCase): record = parser.create_record(row_data) self.assertIsNotNone(record) - self.assertEqual(record.organization.inn, "3234567890") + self.assertEqual(record.organization_id, organization.id) diff --git a/tests/apps/form_4/factories.py b/tests/apps/form_4/factories.py index 1c030b8..5967544 100644 --- a/tests/apps/form_4/factories.py +++ b/tests/apps/form_4/factories.py @@ -18,7 +18,8 @@ class FormF4RecordFactory(factory.django.DjangoModelFactory): organization = factory.SubFactory(OrganizationFactory) load_batch = factory.Sequence(lambda n: n + 1) report_year = 2026 - report_quarter = 1 + report_half_year = 1 + report_quarter = None # Выручка revenue_rsbu = factory.LazyAttribute( diff --git a/tests/apps/form_4/test_services.py b/tests/apps/form_4/test_services.py index f7b3b67..d5cf841 100644 --- a/tests/apps/form_4/test_services.py +++ b/tests/apps/form_4/test_services.py @@ -50,7 +50,7 @@ class FormF4ParserTest(TestCase): def test_get_column_mappings_returns_mappings(self): """Test get_column_mappings returns correct mappings.""" - parser = FormF4Parser(report_year=2026, report_quarter=1) + parser = FormF4Parser(report_year=2026, report_half_year=1) mappings = parser.get_column_mappings() self.assertIsInstance(mappings, list) @@ -60,10 +60,11 @@ class FormF4ParserTest(TestCase): self.assertIn("revenue_rsbu", field_names) self.assertIn("net_profit_rsbu", field_names) - def test_create_record_creates_organization(self): - """Test create_record creates organization if not exists.""" - parser = FormF4Parser(report_year=2026, report_quarter=1) + def test_create_record_uses_existing_organization(self): + """Report imports must attach rows only to preloaded Mostovik organizations.""" + parser = FormF4Parser(report_year=2026, report_half_year=1) parser.load_batch = 504 + organization = OrganizationFactory.create(inn="4234567890") row_data = { "inn": "4234567890", @@ -75,4 +76,7 @@ class FormF4ParserTest(TestCase): record = parser.create_record(row_data) self.assertIsNotNone(record) - self.assertEqual(record.organization.inn, "4234567890") + self.assertEqual(record.organization_id, organization.id) + self.assertEqual(record.report_half_year, 1) + self.assertIsNone(record.report_quarter) + self.assertEqual(record.report_period_display, "I полугодие 2026") diff --git a/tests/apps/form_5/test_services.py b/tests/apps/form_5/test_services.py index 7023f5e..d505575 100644 --- a/tests/apps/form_5/test_services.py +++ b/tests/apps/form_5/test_services.py @@ -86,10 +86,11 @@ class FormF5ParserTest(TestCase): self.assertIn("equipment_id", field_names) self.assertIn("name", field_names) - def test_create_record_creates_organization(self): - """Test create_record creates organization if not exists.""" + def test_create_record_uses_existing_organization(self): + """Report imports must attach rows only to preloaded Mostovik organizations.""" parser = FormF5Parser(report_year=2026, report_quarter=1) parser.load_batch = 505 + organization = OrganizationFactory.create(inn="5234567890") row_data = { "inn": "5234567890", @@ -101,6 +102,6 @@ class FormF5ParserTest(TestCase): record = parser.create_record(row_data) self.assertIsNotNone(record) - self.assertEqual(record.organization.inn, "5234567890") + self.assertEqual(record.organization_id, organization.id) self.assertEqual(record.report_year, 2026) self.assertEqual(record.report_quarter, 1) diff --git a/tests/apps/form_6/test_services.py b/tests/apps/form_6/test_services.py index c7136d5..f195f44 100644 --- a/tests/apps/form_6/test_services.py +++ b/tests/apps/form_6/test_services.py @@ -60,10 +60,11 @@ class FormF6ParserTest(TestCase): self.assertIn("row_code", field_names) self.assertIn("total_equipment", field_names) - def test_create_record_creates_organization(self): - """Test create_record creates organization if not exists.""" + def test_create_record_uses_existing_organization(self): + """Report imports must attach rows only to preloaded Mostovik organizations.""" parser = FormF6Parser(report_year=2026, report_quarter=1) parser.load_batch = 506 + organization = OrganizationFactory.create(inn="6234567890") row_data = { "inn": "6234567890", @@ -76,4 +77,4 @@ class FormF6ParserTest(TestCase): record = parser.create_record(row_data) self.assertIsNotNone(record) - self.assertEqual(record.organization.inn, "6234567890") + self.assertEqual(record.organization_id, organization.id) diff --git a/tests/apps/forms/test_upload_contracts_api.py b/tests/apps/forms/test_upload_contracts_api.py index 1a36541..3a9f71b 100644 --- a/tests/apps/forms/test_upload_contracts_api.py +++ b/tests/apps/forms/test_upload_contracts_api.py @@ -1,4 +1,4 @@ -"""Contract tests for F-2…F-6 upload endpoints.""" +"""Contract tests for F-1…F-6 upload endpoints.""" from __future__ import annotations @@ -16,7 +16,7 @@ from tests.apps.user.factories import UserFactory @override_settings(ROOT_URLCONF="core.urls") class FormUploadContractsApiTest(APITestCase): - """Contract tests for multipart upload endpoints Ф-2 ... Ф-6.""" + """Contract tests for multipart upload endpoints Ф-1 ... Ф-6.""" BACKGROUND_THRESHOLD_PLUS = 1024 * 1024 + 1 RESULT_PAYLOAD = { @@ -26,6 +26,16 @@ class FormUploadContractsApiTest(APITestCase): "errors": [], } CASES = { + "f1": { + "url": "/api/v1/forms/f1/upload/", + "form": "f1", + "payload": {"report_year": 2026, "report_month": 9}, + "period_field": "report_month", + "period_value": 9, + "report_period_display": "Сентябрь 2026", + "parse_target": "apps.form_1.api.parse_form_f1_file", + "task_target": "apps.form_1.api.process_form_f1_file", + }, "f2": { "url": "/api/v1/forms/f2/upload/", "form": "f2", @@ -109,6 +119,7 @@ class FormUploadContractsApiTest(APITestCase): if case["period_field"]: self.assertEqual(response_data[case["period_field"]], case["period_value"]) else: + self.assertNotIn("report_month", response_data) self.assertNotIn("report_quarter", response_data) self.assertNotIn("report_half_year", response_data) diff --git a/tests/apps/organization/test_services.py b/tests/apps/organization/test_services.py index 5697752..74b6537 100644 --- a/tests/apps/organization/test_services.py +++ b/tests/apps/organization/test_services.py @@ -1,6 +1,7 @@ """Tests for Organization services.""" -from apps.organization.services import OrganizationService +from apps.organization.models import Organization +from apps.organization.services import OrganizationNotFoundError, OrganizationService from django.test import TestCase from .factories import OrganizationFactory @@ -9,15 +10,15 @@ from .factories import OrganizationFactory class OrganizationServiceTest(TestCase): """Tests for OrganizationService.""" - def test_get_or_create_by_inn_creates_new(self): - """Test get_or_create_by_inn creates new organization.""" - org, created = OrganizationService.get_or_create_by_inn( - inn="1234567890", - defaults={"name": "Test Org", "ogrn": "1234567890123"}, - ) - self.assertTrue(created) - self.assertEqual(org.inn, "1234567890") - self.assertEqual(org.name, "Test Org") + def test_get_or_create_by_inn_rejects_missing_organization(self): + """OrganizationService must not create organizations outside exchange import.""" + with self.assertRaises(OrganizationNotFoundError): + OrganizationService.get_or_create_by_inn( + inn="1234567890", + defaults={"name": "Test Org", "ogrn": "1234567890123"}, + ) + + self.assertEqual(Organization.objects.count(), 0) def test_get_or_create_by_inn_returns_existing(self): """Test get_or_create_by_inn returns existing organization.""" diff --git a/tests/apps/registers/test_backup_import.py b/tests/apps/registers/test_backup_import.py index 851e512..9f8ea8a 100644 --- a/tests/apps/registers/test_backup_import.py +++ b/tests/apps/registers/test_backup_import.py @@ -108,6 +108,20 @@ class RegisterBackupImportServiceTest(TestCase): ) def test_import_backup_archive_creates_registers_uploads_and_periods(self): + Organization.objects.create( + name="АО Альфа", + inn="7707083893", + ogrn="1027700132195", + kpp="770701001", + okpo="12345678", + ) + Organization.objects.create( + name="АО Бета", + inn="7707083894", + ogrn="1027700132196", + kpp="770701002", + okpo="12345679", + ) uploaded_file = build_backup_archive( actual_date=date(2026, 3, 15), data={ @@ -174,8 +188,9 @@ class RegisterBackupImportServiceTest(TestCase): ) self.assertEqual(result["registers_created"], 1) - self.assertEqual(result["organizations_created"], 2) + self.assertEqual(result["organizations_created"], 0) self.assertEqual(result["organizations_updated"], 0) + self.assertEqual(result["organizations_skipped"], 0) self.assertEqual(result["uploads_created"], 1) self.assertEqual(result["periods_imported"], 2) self.assertEqual(result["closed_periods"], 0) @@ -201,7 +216,7 @@ class RegisterBackupImportServiceTest(TestCase): 2, ) - def test_import_backup_archive_closes_missing_periods_and_updates_orgs(self): + def test_import_backup_archive_closes_missing_periods_and_skips_unknown_orgs(self): register = Register.objects.create(name="Реестр предприятий ОПК") existing_upload = RegisterUpload.objects.create( registry=register, @@ -312,28 +327,22 @@ class RegisterBackupImportServiceTest(TestCase): ) self.assertEqual(result["registers_created"], 0) - self.assertEqual(result["organizations_created"], 1) - self.assertEqual(result["organizations_updated"], 1) + self.assertEqual(result["organizations_created"], 0) + self.assertEqual(result["organizations_updated"], 0) + self.assertEqual(result["organizations_skipped"], 1) self.assertEqual(result["uploads_created"], 1) - self.assertEqual(result["periods_imported"], 2) + self.assertEqual(result["periods_imported"], 1) self.assertEqual(result["closed_periods"], 1) - self.assertEqual(result["active_periods"], 2) + self.assertEqual(result["active_periods"], 1) alpha_period = RegistryMembershipPeriod.objects.get( registry=register, organization=alpha, ) beta.refresh_from_db() - gamma = Organization.objects.get(inn="7707083895") - gamma_period = RegistryMembershipPeriod.objects.get( - registry=register, - organization=gamma, - ended_at__isnull=True, - ) self.assertEqual(alpha_period.ended_at, date(2026, 3, 15)) self.assertIsNone(alpha_period.ended_by_upload) - self.assertEqual(beta.name, "АО Бета обновленная") - self.assertEqual(beta.kpp, "770701099") - self.assertEqual(gamma.name, "АО Гамма") - self.assertEqual(gamma_period.started_at, date(2026, 3, 15)) + self.assertEqual(beta.name, "АО Бета") + self.assertEqual(beta.kpp, "770701002") + self.assertFalse(Organization.objects.filter(inn="7707083895").exists()) diff --git a/tests/apps/registers/test_services.py b/tests/apps/registers/test_services.py index f436b03..9b90dd4 100644 --- a/tests/apps/registers/test_services.py +++ b/tests/apps/registers/test_services.py @@ -51,7 +51,21 @@ class RegisterImportServiceTest(TestCase): self.registry = Register.objects.create(name="Реестр предприятий ОПК") def test_sync_registry_memberships_creates_snapshot(self): - """First upload creates organizations, upload fact and active periods.""" + """First upload links only organizations already loaded from Mostovik.""" + Organization.objects.create( + name="АО Альфа", + inn="7707083893", + ogrn="1027700132195", + okpo="12345678", + kpp="770701001", + ) + Organization.objects.create( + name="АО Бета", + inn="7707083894", + ogrn="1027700132196", + okpo="12345679", + kpp="770701002", + ) uploaded_file = build_registry_upload( "registry.xlsx", [ @@ -79,7 +93,9 @@ class RegisterImportServiceTest(TestCase): ) self.assertEqual(result["rows_in_file"], 2) - self.assertEqual(result["organizations_created"], 2) + self.assertEqual(result["organizations_created"], 0) + self.assertEqual(result["organizations_updated"], 0) + self.assertEqual(result["organizations_skipped"], 0) self.assertEqual(result["opened_periods"], 2) self.assertEqual(Organization.objects.count(), 2) self.assertEqual(RegisterUpload.objects.count(), 1) @@ -88,8 +104,22 @@ class RegisterImportServiceTest(TestCase): 2, ) - def test_sync_registry_memberships_closes_missing_and_opens_new(self): - """Next snapshot closes missing orgs and opens periods for new ones.""" + def test_sync_registry_memberships_closes_missing_and_skips_unknown_orgs(self): + """Next snapshot closes missing orgs but does not create unknown ones.""" + Organization.objects.create( + name="АО Альфа", + inn="7707083893", + ogrn="1027700132195", + okpo="12345678", + kpp="770701001", + ) + Organization.objects.create( + name="АО Бета", + inn="7707083894", + ogrn="1027700132196", + okpo="12345679", + kpp="770701002", + ) first_upload = build_registry_upload( "registry-1.xlsx", [ @@ -140,14 +170,14 @@ class RegisterImportServiceTest(TestCase): actual_date=date(2026, 4, 1), ) - self.assertEqual(result["organizations_created"], 1) - self.assertEqual(result["organizations_updated"], 1) - self.assertEqual(result["opened_periods"], 1) + self.assertEqual(result["organizations_created"], 0) + self.assertEqual(result["organizations_updated"], 0) + self.assertEqual(result["organizations_skipped"], 1) + self.assertEqual(result["opened_periods"], 0) self.assertEqual(result["closed_periods"], 1) alpha = Organization.objects.get(inn="7707083893") beta = Organization.objects.get(inn="7707083894") - gamma = Organization.objects.get(inn="7707083895") alpha_period = RegistryMembershipPeriod.objects.get( registry=self.registry, @@ -158,15 +188,10 @@ class RegisterImportServiceTest(TestCase): organization=beta, ended_at__isnull=True, ) - gamma_period = RegistryMembershipPeriod.objects.get( - registry=self.registry, - organization=gamma, - ended_at__isnull=True, - ) self.assertEqual(alpha_period.ended_at, date(2026, 4, 1)) self.assertEqual(beta_period.started_at, date(2026, 3, 1)) - self.assertEqual(gamma_period.started_at, date(2026, 4, 1)) + self.assertFalse(Organization.objects.filter(inn="7707083895").exists()) beta.refresh_from_db() - self.assertEqual(beta.name, "АО Бета обновлённое") - self.assertEqual(beta.kpp, "770701099") + self.assertEqual(beta.name, "АО Бета") + self.assertEqual(beta.kpp, "770701002")