diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 5349b80..9e7a868 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -14,6 +14,10 @@ on: env: PYTHON_VERSION: "3.11" + REGISTRY_HOST: "registry.dev.nii-ecos.ru" + REGISTRY_NAMESPACE: "${{ github.repository_owner }}" + WEB_IMAGE: "state-corp-backend-web" + CELERY_IMAGE: "state-corp-backend-celery" jobs: lint: @@ -109,49 +113,12 @@ jobs: export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}" .venv/bin/python -m pytest tests -q - build: - name: Build Docker Images + build_push: + name: Build and Push Dev Images runs-on: ubuntu-latest + timeout-minutes: 45 needs: [lint, test] - - steps: - - name: Checkout code - run: | - set -euo pipefail - REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") - BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - git clone --depth=1 --branch="${BRANCH}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . - git checkout "${GITHUB_SHA}" - - - name: Build web image - run: | - set -euo pipefail - BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') - SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7) - docker build \ - -f ./docker/Dockerfile \ - --target runtime-web \ - -t state_corp-web:${BRANCH_TAG} \ - -t state_corp-web:${BRANCH_TAG}-${SHA_SHORT} \ - . - - - name: Build celery image - run: | - set -euo pipefail - BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') - SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7) - docker build \ - -f ./docker/Dockerfile \ - --target runtime-celery \ - -t state_corp-celery:${BRANCH_TAG} \ - -t state_corp-celery:${BRANCH_TAG}-${SHA_SHORT} \ - . - - push: - name: Push to Gitea Registry - runs-on: ubuntu-latest - needs: [build] - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/dev' + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && !contains(github.event.head_commit.message, '#no_image') steps: - name: Checkout code @@ -164,46 +131,63 @@ jobs: - name: Build and push images env: - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | set -euo pipefail if [ -z "${REGISTRY_USER:-}" ] || [ -z "${REGISTRY_PASSWORD:-}" ]; then - echo "Registry credentials are not configured; skipping image push." - exit 0 + echo "Registry credentials are not configured" >&2 + exit 1 fi - curl -sL https://github.com/google/go-containerregistry/releases/download/v0.19.0/go-containerregistry_Linux_x86_64.tar.gz | tar xz crane - chmod +x crane + SHA_SHORT="$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + REGISTRY="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}" + WEB_REF="${REGISTRY}/${WEB_IMAGE}" + CELERY_REF="${REGISTRY}/${CELERY_IMAGE}" - BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') - SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7) - REGISTRY_HOST="10.10.0.10:3000" - REGISTRY="${REGISTRY_HOST}/${{ github.repository_owner }}" + echo "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" \ + -u "${REGISTRY_USER}" \ + --password-stdin - echo "${REGISTRY_PASSWORD}" | ./crane auth login --insecure "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin - - docker build -f ./docker/Dockerfile --target runtime-web -t state_corp-web:local . - docker save state_corp-web:local -o /tmp/web.tar - ./crane push --insecure /tmp/web.tar "${REGISTRY}/state_corp-web:${BRANCH_TAG}" - ./crane push --insecure /tmp/web.tar "${REGISTRY}/state_corp-web:${BRANCH_TAG}-${SHA_SHORT}" - if [ "${GITHUB_REF_NAME}" = "dev" ]; then - ./crane push --insecure /tmp/web.tar "${REGISTRY}/state_corp-web:latest" + if ! docker buildx inspect state-corp-builder >/dev/null 2>&1; then + docker buildx create --name state-corp-builder --use + else + docker buildx use state-corp-builder fi + docker buildx inspect --bootstrap - docker build -f ./docker/Dockerfile --target runtime-celery -t state_corp-celery:local . - docker save state_corp-celery:local -o /tmp/celery.tar - ./crane push --insecure /tmp/celery.tar "${REGISTRY}/state_corp-celery:${BRANCH_TAG}" - ./crane push --insecure /tmp/celery.tar "${REGISTRY}/state_corp-celery:${BRANCH_TAG}-${SHA_SHORT}" - if [ "${GITHUB_REF_NAME}" = "dev" ]; then - ./crane push --insecure /tmp/celery.tar "${REGISTRY}/state_corp-celery:latest" - fi + docker buildx build \ + -f ./docker/Dockerfile \ + --target runtime-web \ + --build-arg INSTALL_DEV=false \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.created=${BUILD_TIME}" \ + --tag "${WEB_REF}:dev-${SHA_SHORT}" \ + --tag "${WEB_REF}:dev" \ + --push \ + . - deploy: - name: Deploy to Server + docker buildx build \ + -f ./docker/Dockerfile \ + --target runtime-celery \ + --build-arg INSTALL_DEV=false \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.created=${BUILD_TIME}" \ + --tag "${CELERY_REF}:dev-${SHA_SHORT}" \ + --tag "${CELERY_REF}:dev" \ + --push \ + . + + deploy_dev: + name: Deploy Dev via Compose runs-on: ubuntu-latest - needs: [push] - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/dev' + timeout-minutes: 5 + needs: [build_push] + if: needs.build_push.result == 'success' steps: - name: Checkout code @@ -214,38 +198,40 @@ jobs: git clone --depth=1 --branch="${BRANCH}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . git checkout "${GITHUB_SHA}" - - name: Deploy via SSH + - name: Deploy prebuilt images via SSH env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }} + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} run: | set -euo pipefail - if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_USER:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ] || [ -z "${REGISTRY_USER:-}" ] || [ -z "${REGISTRY_PASSWORD:-}" ]; then - echo "Deploy credentials are not configured; skipping deploy." + + if [ "${GITHUB_REF}" != "refs/heads/dev" ]; then + echo "Skip dev deploy for ${GITHUB_REF}" exit 0 fi - BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') + case "${HEAD_COMMIT_MESSAGE:-}" in + *"#no_deploy"* | *"#no_image"*) + echo "Skip dev deploy because commit message disables deploy or image build" + exit 0 + ;; + esac + short_sha="$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)" + image_tag="dev-${short_sha}" mkdir -p ~/.ssh - echo "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key + printf '%s' "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/ecos_deploy_key + chmod 0600 ~/.ssh/ecos_deploy_key ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null - - scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml "${DEPLOY_USER}@${DEPLOY_HOST}:/opt/state-corp-backend/" - - ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " - set -euo pipefail - cd /opt/state-corp-backend - echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin 10.10.0.10:3000 - REGISTRY=10.10.0.10:3000/${{ github.repository_owner }} - export WEB_IMAGE=\${REGISTRY}/state_corp-web:${BRANCH_TAG} - export CELERY_IMAGE=\${REGISTRY}/state_corp-celery:${BRANCH_TAG} - docker compose --env-file .env.prod -f docker-compose.prod.yml pull web celery_worker celery_beat - docker compose --env-file .env.prod -f docker-compose.prod.yml down --remove-orphans || true - docker compose --env-file .env.prod -f docker-compose.prod.yml up -d - docker image prune -f - " + tmp_current="$(mktemp)" + ssh -i ~/.ssh/ecos_deploy_key "${DEPLOY_USER}@${DEPLOY_HOST}" 'cat /opt/ecos-dev/releases/current.env' > "${tmp_current}" + grep -v '^STATE_CORP_BACKEND_' "${tmp_current}" > "${tmp_current}.new" + cat >> "${tmp_current}.new" < /opt/ecos-dev/releases/current.env && rm -f /tmp/current.env && /opt/ecos-dev/deploy.sh state-corp-backend' diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d176df4..105dd0d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,7 +19,7 @@ services: - ./data/db:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "5432:5432" + - "${STATE_CORP_POSTGRES_HOST_PORT:-25432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s @@ -31,7 +31,7 @@ services: container_name: state_corp_redis restart: unless-stopped ports: - - "6379:6379" + - "${STATE_CORP_REDIS_HOST_PORT:-26379}:6379" volumes: - ./data/redis:/data healthcheck: @@ -54,7 +54,7 @@ services: redis: condition: service_healthy ports: - - "8000:8000" + - "${STATE_CORP_WEB_HOST_PORT:-28000}:8000" volumes: - ./src:/app/src - ./logs:/app/logs diff --git a/docker-compose.service.yml b/docker-compose.service.yml index 1234bd8..7041ba2 100644 --- a/docker-compose.service.yml +++ b/docker-compose.service.yml @@ -11,7 +11,7 @@ services: - ./data/db:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "5432:5432" + - "${STATE_CORP_POSTGRES_HOST_PORT:-25432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -23,7 +23,7 @@ services: container_name: state_corp_redis restart: unless-stopped ports: - - "6379:6379" + - "${STATE_CORP_REDIS_HOST_PORT:-26379}:6379" volumes: - ./data/redis:/data healthcheck: diff --git a/docker/scripts/start-web.sh b/docker/scripts/start-web.sh index ef5599d..5b32530 100755 --- a/docker/scripts/start-web.sh +++ b/docker/scripts/start-web.sh @@ -12,6 +12,6 @@ esac exec gunicorn core.wsgi:application \ --bind "0.0.0.0:${PORT:-8000}" \ --workers "${GUNICORN_WORKERS:-3}" \ - --timeout "${GUNICORN_TIMEOUT:-60}" \ + --timeout "${GUNICORN_TIMEOUT:-300}" \ --access-logfile "-" \ --error-logfile "-" diff --git a/src/apps/core/excel.py b/src/apps/core/excel.py index 04b5347..4645214 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_or_create_from_form_identifiers(...) 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 eb0e669..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, @@ -82,7 +108,9 @@ def build_upload_success_payload( report_quarter=report_quarter, ) else: - payload["report_period_display"] = report_annual_display(report_year=report_year) + payload["report_period_display"] = report_annual_display( + report_year=report_year + ) if result is not None: payload["result"] = result @@ -183,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 3915776..93e882a 100644 --- a/src/apps/exchange/admin.py +++ b/src/apps/exchange/admin.py @@ -159,20 +159,36 @@ 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", {}) + information_security_registries = result.get( + "information_security_registries", {} + ) + labor_vacancies = result.get("labor_vacancies", {}) self.message_user( request, ( "Импорт пакета обмена завершён: " 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"арбитраж {arbitration_cases.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)}, " + f"ИБ-реестры {information_security_registries.get('created', 0)}, " + f"вакансии {labor_vacancies.get('created', 0)}." ), level=messages.SUCCESS, ) diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index 6c5b203..ca0773b 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -6,9 +6,10 @@ import base64 import hashlib import json import struct +import uuid import zlib from dataclasses import dataclass -from datetime import date +from datetime import date, datetime from decimal import Decimal, InvalidOperation from io import BytesIO from typing import Any @@ -21,7 +22,15 @@ from apps.exchange.models import ( ) from apps.external_data.models import ( ArbitrationCase, + BankruptcyProcedure, + DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, + InformationSecurityRegistryEntry, + LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -30,6 +39,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings from django.db import transaction from django.db.models import Q +from django.utils import timezone class ExchangeImportError(ValueError): @@ -55,44 +65,134 @@ class ExchangePackageImportService: AAD = b"state-corp-exchange-v1" PAYLOAD_FORMAT = "state-corp-exchange-payload" BIN_FORMAT = "state-corp-exchange-bin" + SUPPORTED_SCHEMA_VERSION = 3 ORGANIZATION_STR_FIELDS = { + "full_name": ("full_name",), "short_name": ("short_name",), + "pn_name": ("pn_name",), + "pn_name_en": ("pn_name_en",), "ogrn": ("ogrn",), "kpp": ("kpp",), "okpo": ("okpo",), + "ogrip": ("ogrip",), + "identity_status": ("identity_status",), + "primary_identity": ("primary_identity",), + "gk_code": ("gk_code",), + "gk_name": ("gk_name",), + "in_korp_code": ("in_korp_code",), + "in_korp_name": ("in_korp_name",), + "filial": ("filial",), + "create_date": ("create_date",), + "organizational_legal_form": ("organizational_legal_form",), + "organizational_legal_form1": ("organizational_legal_form1",), + "ownership_form": ("ownership_form",), + "ownership_form1": ("ownership_form1",), "legal_address": ("legal_address",), - "activity_type": ("activity_type",), - "founder_name": ("founder_name",), - "ownership_type": ("ownership_type",), - "legal_form": ("legal_form",), - "general_director_name": ("general_director_name",), - "general_director_inn": ("general_director_inn",), + "business_act_cod": ("business_act_cod",), + "business_activity": ("business_activity",), + "general_director": ("general_director",), + "general_director_tax_id": ("general_director_tax_id",), + "uk": ("uk",), + "inn_uk": ("inn_uk",), + "cf_fl_rn": ("cf_fl_rn",), + "akc_fs": ("akc_fs",), + "akc_sf": ("akc_sf",), + "ropk_num": ("ropk_num",), + "ropk_razdel_num": ("ropk_razdel_num",), + "ropk_razdel_name": ("ropk_razdel_name",), + "min": ("min",), + "dep": ("dep",), + "otr": ("otr",), + "integrated_structure": ("integrated_structure",), + "state_sector_code": ("state_sector_code",), + "state_sector_name": ("state_sector_name",), } ORGANIZATION_DATE_FIELDS = { "registration_date": ("registration_date",), - "general_director_appointment_date": ("general_director_appointment_date",), + "appointment_date": ("appointment_date",), } ORGANIZATION_BOOL_FIELDS = { - "financial_reports_available": ("financial_reports_available",), - "tax_reports_available": ("tax_reports_available",), - "in_defense_unreliable_suppliers_registry": ( - "in_defense_unreliable_suppliers_registry", - ), - "in_275_fz_registry": ("in_275_fz_registry",), - "bankruptcy_messages_found": ("bankruptcy_messages_found",), + "is_branch": ("is_branch",), + "re_za": ("re_za",), + "re_zasf": ("re_zasf",), + "goz_participation": ("goz_participation",), + "opk_registry_membership": ("opk_registry_membership",), } ORGANIZATION_INT_FIELDS = { - "executors_count": ("executors_count",), + "rn": ("rn",), } ORGANIZATION_DECIMAL_FIELDS = { - "charter_capital_amount": ("charter_capital_amount",), + "authorized_capital": ("authorized_capital",), } + ORGANIZATION_UUID_FIELDS = { + "mostovik_uid": ("mostovik_uid",), + } + ORGANIZATION_REQUIRED_FIELDS = ( + "mostovik_uid", + "rn", + "name", + "full_name", + "short_name", + "pn_name", + "pn_name_en", + "inn", + "kpp", + "ogrn", + "ogrip", + "okpo", + "identity_status", + "primary_identity", + "gk_code", + "gk_name", + "in_korp_code", + "in_korp_name", + "filial", + "is_branch", + "re_za", + "re_zasf", + "goz_participation", + "opk_registry_membership", + "ropk_num", + "ropk_razdel_num", + "ropk_razdel_name", + "registration_date", + "create_date", + "organizational_legal_form", + "organizational_legal_form1", + "ownership_form", + "ownership_form1", + "authorized_capital", + "legal_address", + "business_act_cod", + "business_activity", + "general_director", + "general_director_tax_id", + "uk", + "inn_uk", + "appointment_date", + "cf_fl_rn", + "akc_fs", + "akc_sf", + "min", + "dep", + "otr", + "integrated_structure", + "state_sector_code", + "state_sector_name", + ) SECTION_KEYS = ( "organizations", + "industrial_certificates", + "manufacturers", "industrial_products", "prosecutor_checks", "public_procurements", + "financial_reports", "arbitration_cases", + "bankruptcy_procedures", + "defense_unreliable_suppliers", + "information_security_registries", + "labor_vacancies", ) @classmethod @@ -137,7 +237,11 @@ class ExchangePackageImportService: try: with transaction.atomic(): - summary = cls._import_payload(decoded.payload) + summary = cls._import_payload( + decoded.payload, + package_hash=decoded.package_hash, + package_name=decoded.archive_name, + ) record = ExchangePackageImport.objects.create( package_id=package_id, source_system=source_system, @@ -344,6 +448,34 @@ class ExchangePackageImportService: if not isinstance(manifest, dict) or not isinstance(data, dict): raise ExchangeImportError("Payload пакета не содержит manifest/data") cls._read_required_str(manifest, "package_id") + schema_version = cls._read_schema_version(payload, manifest) + if schema_version != cls.SUPPORTED_SCHEMA_VERSION: + raise ExchangeImportError( + "Поддерживается только schema_version " + f"{cls.SUPPORTED_SCHEMA_VERSION}" + ) + if "registry_memberships" in data: + raise ExchangeImportError( + "Раздел registry_memberships не поддерживается в schema_version 3" + ) + cls._validate_organization_rows_schema(data) + + @classmethod + def _validate_organization_rows_schema(cls, data: dict[str, Any]) -> None: + organization_rows = cls._extract_rows(data, "organizations") + for index, row in enumerate(organization_rows, start=1): + missing_fields = [ + field_name + for field_name in cls.ORGANIZATION_REQUIRED_FIELDS + if field_name not in row + ] + if missing_fields: + raise ExchangeImportError( + "Строка organizations " + f"#{index} не соответствует schema_version 3; " + "отсутствуют поля: " + f"{', '.join(missing_fields)}" + ) @classmethod def _extract_manifest(cls, payload: dict[str, Any]) -> dict[str, Any]: @@ -368,6 +500,27 @@ class ExchangePackageImportService: "schema_version должен быть целым числом" ) from exc + @classmethod + def _read_actual_date(cls, manifest: dict[str, Any]) -> date: + raw_value = manifest.get("actual_date") or manifest.get("produced_at") + if raw_value in (None, ""): + return timezone.localdate() + if isinstance(raw_value, date): + return raw_value + + raw_text = str(raw_value).strip() + try: + return date.fromisoformat(raw_text) + except ValueError: + pass + + try: + return datetime.fromisoformat(raw_text.replace("Z", "+00:00")).date() + except ValueError as exc: + raise ExchangeImportError( + "actual_date должен быть датой или датой-временем ISO" + ) from exc + @classmethod def _find_duplicate( cls, @@ -438,33 +591,81 @@ class ExchangePackageImportService: ) @classmethod - def _import_payload(cls, payload: dict[str, Any]) -> dict[str, Any]: + def _import_payload( + cls, + payload: dict[str, Any], + *, + package_hash: str, + package_name: str, + ) -> dict[str, Any]: data = payload.get("data") 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, + "information_security_registries": information_security_summary, + "labor_vacancies": labor_vacancy_summary, } @classmethod @@ -487,6 +688,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 @@ -538,54 +746,96 @@ class ExchangePackageImportService: ) -> list[str]: update_fields: list[str] = [] + for field_name, aliases in cls.ORGANIZATION_UUID_FIELDS.items(): + value, present = cls._get_present_value(row, aliases) + if not present: + continue + parsed_value = cls._parse_uuid_value(value, field_name=field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) + for field_name, aliases in cls.ORGANIZATION_STR_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue cleaned_value = ( cls._clean_digits(value) - if field_name in {"ogrn", "kpp", "okpo", "general_director_inn"} + if field_name + in { + "ogrn", + "kpp", + "okpo", + "ogrip", + "general_director_tax_id", + "inn_uk", + } else cls._clean_string(value) ) - if getattr(organization, field_name) != cleaned_value: - setattr(organization, field_name, cleaned_value) - update_fields.append(field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=cleaned_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_DATE_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue parsed_value = cls._parse_date_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_BOOL_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_bool_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + parsed_value = cls._parse_optional_bool_value( + value, + field_name=field_name, + ) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_INT_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_int_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + parsed_value = cls._parse_optional_int_value(value, field_name=field_name) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) for field_name, aliases in cls.ORGANIZATION_DECIMAL_FIELDS.items(): value, present = cls._get_present_value(row, aliases) if not present: continue - parsed_value = cls._parse_decimal_value(value, field_name=field_name) - if getattr(organization, field_name) != parsed_value: - setattr(organization, field_name, parsed_value) - update_fields.append(field_name) + parsed_value = cls._parse_decimal_value( + value, + field_name=field_name, + allow_null=True, + ) + cls._set_organization_field( + organization=organization, + field_name=field_name, + value=parsed_value, + update_fields=update_fields, + ) organization_type_value, present = cls._get_present_value( row, @@ -621,14 +871,137 @@ class ExchangePackageImportService: return update_fields + @staticmethod + def _set_organization_field( + *, + organization: Organization, + field_name: str, + value: Any, + update_fields: list[str], + ) -> None: + if getattr(organization, field_name) != value: + setattr(organization, field_name, value) + update_fields.append(field_name) + @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 @@ -660,13 +1033,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 @@ -702,13 +1083,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 @@ -758,13 +1147,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 @@ -798,6 +1322,232 @@ class ExchangePackageImportService: "skipped": skipped_count, } + @classmethod + 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, + allowed_organization_inns=allowed_organization_inns, + ) + external_id = cls._clean_string(row.get("external_id")) + if not external_id: + skipped_count += 1 + continue + + defaults = { + "message_type": cls._clean_string(row.get("message_type")), + "message_date": cls._parse_date_value( + row.get("message_date"), + field_name="message_date", + allow_null=True, + ), + "case_number": cls._clean_string(row.get("case_number")), + "status": cls._clean_string(row.get("status")), + "source_url": cls._clean_string(row.get("source_url")), + } + state = cls._upsert_external_row( + model=BankruptcyProcedure, + lookup={ + "organization": organization, + "external_id": external_id, + }, + 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_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, + allowed_organization_inns=allowed_organization_inns, + ) + external_id = cls._clean_string(row.get("external_id")) + if not external_id: + skipped_count += 1 + continue + + defaults = { + "registry_source": cls._clean_string(row.get("registry_source")), + "registry_number": cls._clean_string(row.get("registry_number")), + "supplier_name": cls._clean_string(row.get("supplier_name")), + "reason": cls._clean_string(row.get("reason")), + "included_at": cls._parse_date_value( + row.get("included_at"), + field_name="included_at", + allow_null=True, + ), + "status": cls._clean_string(row.get("status")), + "source_url": cls._clean_string(row.get("source_url")), + } + state = cls._upsert_external_row( + model=DefenseUnreliableSupplier, + lookup={ + "organization": organization, + "external_id": external_id, + }, + 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_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, + 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")) + if not external_id and not (registry_name and entry_number): + skipped_count += 1 + continue + + lookup = {"organization": organization} + if external_id: + lookup["external_id"] = external_id + else: + lookup.update( + { + "registry_name": registry_name, + "entry_number": entry_number, + } + ) + + defaults = { + "registry_name": registry_name, + "presence_status": cls._clean_string(row.get("presence_status")) + or InformationSecurityRegistryEntry.PresenceStatus.PRESENT, + "entry_number": entry_number, + "issued_at": cls._parse_date_value( + row.get("issued_at"), + field_name="issued_at", + allow_null=True, + ), + "expires_at": cls._parse_date_value( + row.get("expires_at"), + field_name="expires_at", + allow_null=True, + ), + } + state = cls._upsert_external_row( + model=InformationSecurityRegistryEntry, + lookup=lookup, + 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_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, + allowed_organization_inns=allowed_organization_inns, + ) + external_id = cls._clean_string(row.get("external_id")) + if not external_id: + skipped_count += 1 + continue + + defaults = { + "vacancy_source": cls._clean_string(row.get("vacancy_source")), + "title": cls._clean_string(row.get("title")), + "status": cls._clean_string(row.get("status")), + "published_at": cls._parse_date_value( + row.get("published_at"), + field_name="published_at", + allow_null=True, + ), + "salary_amount": cls._parse_decimal_value( + row.get("salary_amount"), + field_name="salary_amount", + allow_null=True, + ), + "source_url": cls._clean_string(row.get("source_url")), + } + state = cls._upsert_external_row( + model=LaborVacancy, + lookup={ + "organization": organization, + "external_id": external_id, + }, + 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_external_row( cls, @@ -822,12 +1572,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} не найдена") @@ -875,6 +1634,20 @@ class ExchangePackageImportService: return "" return str(value).strip() + @classmethod + def _parse_uuid_value( + cls, + value: Any, + *, + field_name: str, + ) -> uuid.UUID | None: + if value in (None, ""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError) as exc: + raise ExchangeImportError(f"Поле {field_name} должно быть UUID") from exc + @classmethod def _parse_date_value( cls, @@ -907,6 +1680,17 @@ class ExchangePackageImportService: return False raise ExchangeImportError(f"Поле {field_name} должно быть bool-значением") + @classmethod + def _parse_optional_bool_value( + cls, + value: Any, + *, + field_name: str, + ) -> bool | None: + if value in (None, ""): + return None + return cls._parse_bool_value(value, field_name=field_name) + @classmethod def _parse_int_value(cls, value: Any, *, field_name: str) -> int: try: @@ -919,6 +1703,37 @@ class ExchangePackageImportService: raise ExchangeImportError(f"Поле {field_name} не может быть отрицательным") return parsed + @classmethod + def _parse_optional_int_value( + cls, + value: Any, + *, + field_name: str, + ) -> int | None: + if value in (None, ""): + return None + return cls._parse_int_value(value, field_name=field_name) + + @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 421c46b..27dfb22 100644 --- a/src/apps/external_data/api.py +++ b/src/apps/external_data/api.py @@ -3,17 +3,29 @@ from apps.core.viewsets import ClassicReadOnlyViewSet from apps.external_data.models import ( ArbitrationCase, + BankruptcyProcedure, + DefenseUnreliableSupplier, + FinancialReport, + IndustrialCertificate, IndustrialProduct, + InformationSecurityRegistryEntry, + LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, - InformationSecurityRegistryEntry, ) from apps.external_data.serializers import ( ArbitrationCaseSerializer, + BankruptcyProcedureSerializer, + DefenseUnreliableSupplierSerializer, + FinancialReportSerializer, + IndustrialCertificateSerializer, IndustrialProductSerializer, + InformationSecurityRegistryEntrySerializer, + LaborVacancySerializer, + ManufacturerRegistryEntrySerializer, ProsecutorCheckSerializer, PublicProcurementSerializer, - CorporationMembershipSerializer, ) from django_filters import rest_framework as filters from rest_framework.permissions import IsAuthenticated @@ -28,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") @@ -79,6 +109,57 @@ class CorporationMembershipFilter(filters.FilterSet): fields = ["organization", "presence_status"] +class BankruptcyProcedureFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + message_date_from = filters.DateFilter(field_name="message_date", lookup_expr="gte") + message_date_to = filters.DateFilter(field_name="message_date", lookup_expr="lte") + + class Meta: + model = BankruptcyProcedure + fields = ["organization", "message_date_from", "message_date_to"] + + +class DefenseUnreliableSupplierFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + registry_source = filters.CharFilter(lookup_expr="exact") + included_at_from = filters.DateFilter(field_name="included_at", lookup_expr="gte") + included_at_to = filters.DateFilter(field_name="included_at", lookup_expr="lte") + + class Meta: + model = DefenseUnreliableSupplier + fields = [ + "organization", + "registry_source", + "included_at_from", + "included_at_to", + ] + + +class LaborVacancyFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + vacancy_source = filters.CharFilter(lookup_expr="exact") + published_at_from = filters.DateFilter(field_name="published_at", lookup_expr="gte") + published_at_to = filters.DateFilter(field_name="published_at", lookup_expr="lte") + + class Meta: + model = LaborVacancy + fields = [ + "organization", + "vacancy_source", + "published_at_from", + "published_at_to", + ] + + +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 @@ -89,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 @@ -122,10 +225,60 @@ class ArbitrationCaseViewSet(ClassicReadOnlyViewSet[ArbitrationCase]): class CorporationMembershipViewSet( ClassicReadOnlyViewSet[InformationSecurityRegistryEntry] ): - queryset = InformationSecurityRegistryEntry.objects.select_related("organization").all() - serializer_class = CorporationMembershipSerializer + queryset = InformationSecurityRegistryEntry.objects.select_related( + "organization" + ).all() + serializer_class = InformationSecurityRegistryEntrySerializer permission_classes = [IsAuthenticated] filterset_class = CorporationMembershipFilter search_fields = ["registry_name", "entry_number"] ordering_fields = ["issued_at", "expires_at", "created_at"] ordering = ["-issued_at"] + + +class InformationSecurityRegistryEntryViewSet(CorporationMembershipViewSet): + pass + + +class BankruptcyProcedureViewSet(ClassicReadOnlyViewSet[BankruptcyProcedure]): + queryset = BankruptcyProcedure.objects.select_related("organization").all() + serializer_class = BankruptcyProcedureSerializer + permission_classes = [IsAuthenticated] + filterset_class = BankruptcyProcedureFilter + search_fields = ["message_type", "case_number"] + ordering_fields = ["message_date", "created_at"] + ordering = ["-message_date"] + + +class DefenseUnreliableSupplierViewSet( + ClassicReadOnlyViewSet[DefenseUnreliableSupplier] +): + queryset = DefenseUnreliableSupplier.objects.select_related("organization").all() + serializer_class = DefenseUnreliableSupplierSerializer + permission_classes = [IsAuthenticated] + filterset_class = DefenseUnreliableSupplierFilter + search_fields = ["registry_number", "supplier_name", "reason"] + ordering_fields = ["included_at", "created_at"] + ordering = ["-included_at"] + + +class LaborVacancyViewSet(ClassicReadOnlyViewSet[LaborVacancy]): + queryset = LaborVacancy.objects.select_related("organization").all() + serializer_class = LaborVacancySerializer + permission_classes = [IsAuthenticated] + filterset_class = LaborVacancyFilter + 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/0003_exchange_additional_external_data.py b/src/apps/external_data/migrations/0003_exchange_additional_external_data.py new file mode 100644 index 0000000..24d8da3 --- /dev/null +++ b/src/apps/external_data/migrations/0003_exchange_additional_external_data.py @@ -0,0 +1,340 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("external_data", "0002_information_security_registry_entry"), + ] + + operations = [ + migrations.AddField( + model_name="informationsecurityregistryentry", + name="external_id", + field=models.CharField( + blank=True, + db_index=True, + default="", + max_length=255, + verbose_name="внешний ID", + ), + ), + migrations.CreateModel( + name="LaborVacancy", + 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", + ), + ), + ( + "vacancy_source", + models.CharField( + db_index=True, + max_length=50, + verbose_name="источник вакансии", + ), + ), + ( + "title", + models.CharField( + db_index=True, + max_length=500, + verbose_name="название вакансии", + ), + ), + ( + "status", + models.CharField( + blank=True, + db_index=True, + default="", + max_length=64, + verbose_name="статус", + ), + ), + ( + "published_at", + models.DateField( + blank=True, + db_index=True, + null=True, + verbose_name="дата публикации", + ), + ), + ( + "salary_amount", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=20, + null=True, + verbose_name="зарплата", + ), + ), + ( + "source_url", + models.TextField( + blank=True, + default="", + verbose_name="ссылка на источник", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="labor_vacancies", + to="organization.organization", + verbose_name="организация", + ), + ), + ], + options={ + "ordering": ["-published_at", "title"], + }, + ), + migrations.CreateModel( + name="DefenseUnreliableSupplier", + 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", + ), + ), + ( + "registry_source", + models.CharField( + db_index=True, + max_length=50, + verbose_name="источник реестра", + ), + ), + ( + "registry_number", + models.CharField( + blank=True, + db_index=True, + default="", + max_length=128, + verbose_name="номер записи", + ), + ), + ( + "supplier_name", + models.CharField( + blank=True, + default="", + max_length=500, + verbose_name="наименование поставщика", + ), + ), + ( + "reason", + models.TextField( + blank=True, + default="", + verbose_name="основание", + ), + ), + ( + "included_at", + models.DateField( + blank=True, + db_index=True, + null=True, + verbose_name="дата включения", + ), + ), + ( + "status", + models.CharField( + blank=True, + db_index=True, + default="", + max_length=64, + verbose_name="статус", + ), + ), + ( + "source_url", + models.TextField( + blank=True, + default="", + verbose_name="ссылка на источник", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="defense_unreliable_suppliers", + to="organization.organization", + verbose_name="организация", + ), + ), + ], + options={ + "ordering": ["-included_at", "registry_source", "registry_number"], + }, + ), + migrations.CreateModel( + name="BankruptcyProcedure", + 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", + ), + ), + ( + "message_type", + models.CharField( + db_index=True, + max_length=255, + verbose_name="тип сообщения", + ), + ), + ( + "message_date", + models.DateField( + blank=True, + db_index=True, + null=True, + verbose_name="дата сообщения", + ), + ), + ( + "case_number", + models.CharField( + blank=True, + db_index=True, + default="", + max_length=128, + verbose_name="номер дела", + ), + ), + ( + "status", + models.CharField( + blank=True, + db_index=True, + default="", + max_length=64, + verbose_name="статус", + ), + ), + ( + "source_url", + models.TextField( + blank=True, + default="", + verbose_name="ссылка на источник", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bankruptcy_procedures", + to="organization.organization", + verbose_name="организация", + ), + ), + ], + options={ + "ordering": ["-message_date", "case_number", "message_type"], + }, + ), + ] 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 fc28e4f..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, @@ -112,7 +163,69 @@ class ArbitrationCase(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): return f"{self.case_number} ({self.organization_id})" -class InformationSecurityRegistryEntry(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): +class BankruptcyProcedure(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="bankruptcy_procedures", + verbose_name=_("организация"), + ) + external_id = models.CharField(_("внешний ID"), max_length=255, db_index=True) + message_type = models.CharField(_("тип сообщения"), max_length=255, db_index=True) + message_date = models.DateField( + _("дата сообщения"), null=True, blank=True, db_index=True + ) + case_number = models.CharField( + _("номер дела"), max_length=128, blank=True, default="", db_index=True + ) + status = models.CharField( + _("статус"), max_length=64, blank=True, default="", db_index=True + ) + source_url = models.TextField(_("ссылка на источник"), blank=True, default="") + + class Meta: + ordering = ["-message_date", "case_number", "message_type"] + + def __str__(self) -> str: + return f"{self.message_type} ({self.organization_id})" + + +class DefenseUnreliableSupplier(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="defense_unreliable_suppliers", + verbose_name=_("организация"), + ) + external_id = models.CharField(_("внешний ID"), max_length=255, db_index=True) + registry_source = models.CharField( + _("источник реестра"), max_length=50, db_index=True + ) + registry_number = models.CharField( + _("номер записи"), max_length=128, blank=True, default="", db_index=True + ) + supplier_name = models.CharField( + _("наименование поставщика"), max_length=500, blank=True, default="" + ) + reason = models.TextField(_("основание"), blank=True, default="") + included_at = models.DateField( + _("дата включения"), null=True, blank=True, db_index=True + ) + status = models.CharField( + _("статус"), max_length=64, blank=True, default="", db_index=True + ) + source_url = models.TextField(_("ссылка на источник"), blank=True, default="") + + class Meta: + ordering = ["-included_at", "registry_source", "registry_number"] + + def __str__(self) -> str: + return f"{self.registry_source}:{self.registry_number} ({self.organization_id})" + + +class InformationSecurityRegistryEntry( + UUIDPrimaryKeyMixin, TimestampMixin, models.Model +): class PresenceStatus(models.TextChoices): PRESENT = "present", _("В реестре") ABSENT = "absent", _("Не в реестре") @@ -123,6 +236,9 @@ class InformationSecurityRegistryEntry(UUIDPrimaryKeyMixin, TimestampMixin, mode related_name="information_security_registry_entries", verbose_name=_("организация"), ) + external_id = models.CharField( + _("внешний ID"), max_length=255, blank=True, default="", db_index=True + ) registry_name = models.CharField( _("название реестра"), max_length=255, db_index=True ) @@ -148,3 +264,103 @@ class InformationSecurityRegistryEntry(UUIDPrimaryKeyMixin, TimestampMixin, mode def __str__(self) -> str: return f"{self.registry_name} ({self.organization_id})" + + +class LaborVacancy(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="labor_vacancies", + verbose_name=_("организация"), + ) + external_id = models.CharField(_("внешний ID"), max_length=255, db_index=True) + vacancy_source = models.CharField( + _("источник вакансии"), max_length=50, db_index=True + ) + title = models.CharField(_("название вакансии"), max_length=500, db_index=True) + status = models.CharField( + _("статус"), max_length=64, blank=True, default="", db_index=True + ) + published_at = models.DateField( + _("дата публикации"), null=True, blank=True, db_index=True + ) + salary_amount = models.DecimalField( + _("зарплата"), + max_digits=20, + decimal_places=2, + null=True, + blank=True, + ) + source_url = models.TextField(_("ссылка на источник"), blank=True, default="") + + class Meta: + ordering = ["-published_at", "title"] + + 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 c9cbb44..26364d3 100644 --- a/src/apps/external_data/serializers.py +++ b/src/apps/external_data/serializers.py @@ -2,10 +2,17 @@ from apps.external_data.models import ( ArbitrationCase, + BankruptcyProcedure, + DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, + InformationSecurityRegistryEntry, + LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, - InformationSecurityRegistryEntry, ) from rest_framework import serializers @@ -26,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) @@ -78,7 +117,43 @@ class ArbitrationCaseSerializer(serializers.ModelSerializer): ] -class CorporationMembershipSerializer(serializers.ModelSerializer): +class BankruptcyProcedureSerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = BankruptcyProcedure + fields = [ + "id", + "organization", + "external_id", + "message_type", + "message_date", + "case_number", + "status", + "source_url", + ] + + +class DefenseUnreliableSupplierSerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = DefenseUnreliableSupplier + fields = [ + "id", + "organization", + "external_id", + "registry_source", + "registry_number", + "supplier_name", + "reason", + "included_at", + "status", + "source_url", + ] + + +class InformationSecurityRegistryEntrySerializer(serializers.ModelSerializer): organization = serializers.UUIDField(source="organization_id", read_only=True) class Meta: @@ -86,9 +161,66 @@ class CorporationMembershipSerializer(serializers.ModelSerializer): fields = [ "id", "organization", + "external_id", "registry_name", "presence_status", "entry_number", "issued_at", "expires_at", ] + + +class LaborVacancySerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = LaborVacancy + fields = [ + "id", + "organization", + "external_id", + "vacancy_source", + "title", + "status", + "published_at", + "salary_amount", + "source_url", + ] + + +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 2920190..1265596 100644 --- a/src/apps/external_data/urls.py +++ b/src/apps/external_data/urls.py @@ -2,10 +2,17 @@ from apps.external_data.api import ( ArbitrationCaseViewSet, + BankruptcyProcedureViewSet, + CorporationMembershipViewSet, + DefenseUnreliableSupplierViewSet, + FinancialReportViewSet, + IndustrialCertificateViewSet, IndustrialProductViewSet, + InformationSecurityRegistryEntryViewSet, + LaborVacancyViewSet, + ManufacturerRegistryEntryViewSet, ProsecutorCheckViewSet, PublicProcurementViewSet, - CorporationMembershipViewSet, ) from django.urls import include, path from rest_framework.routers import DefaultRouter @@ -16,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" ) @@ -30,6 +47,31 @@ router.register( CorporationMembershipViewSet, basename="corporation-memberships", ) +router.register( + "information-security-registries", + InformationSecurityRegistryEntryViewSet, + basename="information-security-registries", +) +router.register( + "bankruptcy-procedures", + BankruptcyProcedureViewSet, + basename="bankruptcy-procedures", +) +router.register( + "defense-unreliable-suppliers", + DefenseUnreliableSupplierViewSet, + basename="defense-unreliable-suppliers", +) +router.register( + "labor-vacancies", + 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..a4cd4b5 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 ( @@ -25,6 +30,7 @@ from django_filters import rest_framework as filters from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request @@ -36,6 +42,48 @@ logger = logging.getLogger(__name__) # Порог для фоновой обработки (количество строк) BACKGROUND_THRESHOLD = 100 +_PERIOD_ORDERING_FIELDS = {"report_year", "report_quarter"} +_ALL_PERIOD_ORDERING_FIELDS = _PERIOD_ORDERING_FIELDS | {"report_month"} + + +def _field_name(ordering_item: str) -> str: + return ordering_item.lstrip("-") + + +def _period_ordering_direction(ordering: list[str]) -> str: + for ordering_item in ordering: + if _field_name(ordering_item) in _ALL_PERIOD_ORDERING_FIELDS: + return "-" if ordering_item.startswith("-") else "" + return "-" + + +def _with_form_f1_period_tiebreakers(ordering: list[str]) -> list[str]: + ordered_fields = {_field_name(ordering_item) for ordering_item in ordering} + if not (ordered_fields & _PERIOD_ORDERING_FIELDS): + return ordering + + resolved_ordering = list(ordering) + direction = _period_ordering_direction(resolved_ordering) + + if "report_month" not in ordered_fields: + resolved_ordering.append(f"{direction}report_month") + + if "created_at" not in ordered_fields: + resolved_ordering.append("-created_at") + + return resolved_ordering + + +class FormF1OrderingFilter(OrderingFilter): + """Adds monthly period tiebreakers for F-1 ordering requests.""" + + def get_ordering(self, request, queryset, view): + ordering = super().get_ordering(request, queryset, view) + if not ordering: + return ordering + + return _with_form_f1_period_tiebreakers(list(ordering)) + class FormF1Filter(filters.FilterSet): """Фильтры для записей формы Ф-1.""" @@ -44,6 +92,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 +102,7 @@ class FormF1Filter(filters.FilterSet): "organization_inn", "load_batch", "report_year", + "report_month", "report_quarter", ] @@ -76,7 +126,7 @@ class FormF1UploadView(APIView): ), request_body=FormF1UploadSerializer, responses={ - 200: ParseResultSerializer, + 200: "Файл обработан", 202: "Задача поставлена в очередь (для больших файлов)", 400: "Ошибка валидации", }, @@ -84,23 +134,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 +181,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, ) @@ -142,10 +211,21 @@ class FormF1RecordViewSet(ReadOnlyViewSet[FormF1Record]): ) serializer_class = FormF1RecordSerializer permission_classes = [IsAuthenticated] + filter_backends = [ + filters.DjangoFilterBackend, + SearchFilter, + FormF1OrderingFilter, + ] 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, @@ -157,6 +237,12 @@ class FormF1RecordViewSet(ReadOnlyViewSet[FormF1Record]): return self.serializer_classes[self.action] return super().get_serializer_class() + def retrieve(self, request: Request, *args, **kwargs) -> Response: + """Return plain detail payload matching the generated F-1 API contract.""" + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + @swagger_auto_schema( tags=["Форма Ф-1"], operation_summary="Список загрузок", 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..59f7bb6 100644 --- a/src/apps/form_1/services.py +++ b/src/apps/form_1/services.py @@ -275,23 +275,26 @@ 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( + org = OrganizationService.get_or_create_from_form_identifiers( + name=row_data.organization_name, 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 "", - }, + ogrn=row_data.ogrn, + kpp=row_data.kpp, + okpo=row_data.okpo, ) + 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 +305,7 @@ def parse_form_f1_file( file, *, report_year: int, - report_quarter: int | None = None, + report_month: int, ) -> ParseResult: """ Парсит Excel файл формы Ф-1. @@ -313,5 +316,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/serializers.py b/src/apps/form_2/serializers.py index 3da59c3..a218b67 100644 --- a/src/apps/form_2/serializers.py +++ b/src/apps/form_2/serializers.py @@ -7,11 +7,11 @@ - FormF2ParseResultSerializer - результат парсинга """ -from apps.form_2.models import FormF2Record from apps.core.upload_contracts import ( - UploadQuarterSerializer, UploadParseResultSerializer, + UploadQuarterSerializer, ) +from apps.form_2.models import FormF2Record from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers diff --git a/src/apps/form_2/services.py b/src/apps/form_2/services.py index f5ad9fa..fdf18c6 100644 --- a/src/apps/form_2/services.py +++ b/src/apps/form_2/services.py @@ -366,14 +366,12 @@ 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( + org = OrganizationService.get_or_create_from_form_identifiers( + name=row_data.organization_name, 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 "", - }, + ogrn=row_data.ogrn, + kpp=row_data.kpp, + okpo=row_data.okpo, ) record = FormF2Service.create_versioned_record( 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/serializers.py b/src/apps/form_3/serializers.py index 1d2fd05..3a27cd8 100644 --- a/src/apps/form_3/serializers.py +++ b/src/apps/form_3/serializers.py @@ -7,11 +7,11 @@ - FormF3ParseResultSerializer - результат парсинга """ -from apps.form_3.models import FormF3Record from apps.core.upload_contracts import ( UploadAnnualSerializer, UploadParseResultSerializer, ) +from apps.form_3.models import FormF3Record from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers diff --git a/src/apps/form_3/services.py b/src/apps/form_3/services.py index a853694..386266b 100644 --- a/src/apps/form_3/services.py +++ b/src/apps/form_3/services.py @@ -185,14 +185,12 @@ 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( + org = OrganizationService.get_or_create_from_form_identifiers( + name=row_data.organization_name, 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 "", - }, + ogrn=row_data.ogrn, + kpp=row_data.kpp, + okpo=row_data.okpo, ) record = FormF3Service.create_versioned_record( 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 4c22e08..e805ab6 100644 --- a/src/apps/form_4/serializers.py +++ b/src/apps/form_4/serializers.py @@ -1,10 +1,10 @@ """Сериализаторы формы Ф-4.""" -from apps.form_4.models import FormF4Record from apps.core.upload_contracts import ( UploadHalfYearSerializer, UploadParseResultSerializer, ) +from apps.form_4.models import FormF4Record from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -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..42c5a7f 100644 --- a/src/apps/form_4/services.py +++ b/src/apps/form_4/services.py @@ -166,20 +166,27 @@ 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( + org = OrganizationService.get_or_create_from_form_identifiers( + name=row_data.organization_name, 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 "", - }, + ogrn=row_data.ogrn, + kpp=row_data.kpp, + okpo=row_data.okpo, ) + 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 +195,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/serializers.py b/src/apps/form_5/serializers.py index 5bcd954..6d1cfca 100644 --- a/src/apps/form_5/serializers.py +++ b/src/apps/form_5/serializers.py @@ -1,10 +1,10 @@ """Сериализаторы формы Ф-5.""" -from apps.form_5.models import FormF5Record from apps.core.upload_contracts import ( UploadAnnualSerializer, UploadParseResultSerializer, ) +from apps.form_5.models import FormF5Record from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers diff --git a/src/apps/form_5/services.py b/src/apps/form_5/services.py index 4735750..ef801f2 100644 --- a/src/apps/form_5/services.py +++ b/src/apps/form_5/services.py @@ -148,14 +148,12 @@ 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( + org = OrganizationService.get_or_create_from_form_identifiers( + name=row_data.organization_name, 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 "", - }, + ogrn=row_data.ogrn, + kpp=row_data.kpp, + okpo=row_data.okpo, ) return FormF5Service.create_versioned_record( organization=org, 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/serializers.py b/src/apps/form_6/serializers.py index 8a647ab..464a9c7 100644 --- a/src/apps/form_6/serializers.py +++ b/src/apps/form_6/serializers.py @@ -1,10 +1,10 @@ """Сериализаторы формы Ф-6.""" -from apps.form_6.models import FormF6Record from apps.core.upload_contracts import ( UploadAnnualSerializer, UploadParseResultSerializer, ) +from apps.form_6.models import FormF6Record from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers diff --git a/src/apps/form_6/services.py b/src/apps/form_6/services.py index ad5b64d..27d9e91 100644 --- a/src/apps/form_6/services.py +++ b/src/apps/form_6/services.py @@ -135,14 +135,12 @@ 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( + org = OrganizationService.get_or_create_from_form_identifiers( + name=row_data.organization_name, 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 "", - }, + ogrn=row_data.ogrn, + kpp=row_data.kpp, + okpo=row_data.okpo, ) return FormF6Service.create_versioned_record( organization=org, diff --git a/src/apps/organization/analytics_services.py b/src/apps/organization/analytics_services.py index 4bdd093..fdfe6ca 100644 --- a/src/apps/organization/analytics_services.py +++ b/src/apps/organization/analytics_services.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Iterable -from datetime import date from decimal import Decimal from apps.core.exceptions import NotFoundError @@ -14,8 +13,17 @@ from apps.form_3.models import FormF3Record from apps.form_4.models import FormF4Record from apps.form_5.models import FormF5Record from apps.form_6.models import FormF6Record +from apps.organization.availability import ( + has_financial_reports, + has_tax_reports, + risk_level_for_availability, +) from apps.organization.models import IndustryCluster, Organization -from apps.organization.scope_utils import filter_queryset_by_scopes +from apps.organization.scope_utils import ( + SCOPE_LABELS, + filter_queryset_by_scopes, + scopes_from_organization_fields, +) from django.db.models import Avg, Case, Count, IntegerField, Q, Sum, When ZERO = Decimal("0") @@ -103,6 +111,26 @@ def _best_records_by_year( return resolved +def _latest_report_year(records: Iterable) -> int | None: + years = [ + record.report_year + for record in records + if getattr(record, "report_year", None) is not None + ] + return max(years, default=None) + + +def _resolve_report_year(records: Iterable, requested_year: int) -> int: + years = { + record.report_year + for record in records + if getattr(record, "report_year", None) is not None + } + if requested_year in years: + return requested_year + return max(years, default=requested_year) + + def _weighted_average_age(age_distribution: list[dict[str, int]]) -> float: bucket_midpoints = { "under_30": 25, @@ -331,7 +359,26 @@ class OrganizationAnalyticsService: periods = sorted(set(f2_by_year) | set(f4_by_year)) if not periods: - raise NotFoundError(message="Economics data is not available") + latest_year = max( + filter( + None, + ( + _latest_report_year(cls._f2_records(organization)), + _latest_report_year(cls._f4_records(organization)), + ), + ), + default=None, + ) + if latest_year is None: + raise NotFoundError(message="Economics data is not available") + + f2_by_year = _best_records_by_year( + cls._f2_records(organization), latest_year, latest_year + ) + f4_by_year = _best_records_by_year( + cls._f4_records(organization), latest_year, latest_year + ) + periods = sorted(set(f2_by_year) | set(f4_by_year)) metric_units = cls._economics_metric_units() selected_metrics = cls._economics_metric_groups()[group] @@ -407,6 +454,7 @@ class OrganizationAnalyticsService: history_years: int, ) -> dict[str, object]: f3_records = cls._f3_records(organization) + report_year = _resolve_report_year(f3_records, report_year) current_f3 = cls._require_record( _pick_record(f3_records, report_year), entity="Personnel", @@ -460,6 +508,7 @@ class OrganizationAnalyticsService: report_year: int, ) -> dict[str, object]: f3_records = cls._f3_records(organization) + report_year = _resolve_report_year(f3_records, report_year) current_f3 = cls._require_record( _pick_record(f3_records, report_year), entity="Equipment", @@ -746,11 +795,16 @@ class OrganizationAnalyticsService: return base_rows if frequency == "annual": - return [cls._aggregate_product_metrics(base_rows, str(records[0].report_year))] + return [ + cls._aggregate_product_metrics(base_rows, str(records[0].report_year)) + ] if frequency == "semiannual": grouped_rows: list[dict[str, object]] = [] - periods = ((1, 2, f"{records[0].report_year}-H1"), (3, 4, f"{records[0].report_year}-H2")) + periods = ( + (1, 2, f"{records[0].report_year}-H1"), + (3, 4, f"{records[0].report_year}-H2"), + ) for quarter_start, quarter_end, period in periods: half_rows = [ row @@ -792,10 +846,11 @@ class OrganizationAnalyticsService: @staticmethod def _product_row_shipped_goods_amount(row: dict[str, object]) -> int: metrics = row["metrics"] - return _amount(metrics["military_domestic_amount"]) + _amount( - metrics["military_export_amount"] - ) + _amount(metrics["civilian_domestic_amount"]) + _amount( - metrics["civilian_export_amount"] + return ( + _amount(metrics["military_domestic_amount"]) + + _amount(metrics["military_export_amount"]) + + _amount(metrics["civilian_domestic_amount"]) + + _amount(metrics["civilian_export_amount"]) ) @classmethod @@ -807,11 +862,9 @@ class OrganizationAnalyticsService: frequency: str, price_mode: str, ) -> dict[str, object]: - records = [ - record - for record in cls._f1_records(organization) - if record.report_year == report_year - ] + f1_records = cls._f1_records(organization) + report_year = _resolve_report_year(f1_records, report_year) + records = [record for record in f1_records if record.report_year == report_year] if not records: raise NotFoundError(message="Products data is not available") @@ -911,14 +964,21 @@ class OrganizationAnalyticsService: @classmethod def get_risk_profile(cls, *, organization: Organization) -> dict[str, object]: + financial_reports_available = has_financial_reports(organization) + tax_reports_available = has_tax_reports(organization) + return { "organization_id": str(organization.id), - "financial_reports_available": organization.financial_reports_available, - "tax_reports_available": organization.tax_reports_available, + "financial_reports_available": financial_reports_available, + "tax_reports_available": tax_reports_available, "in_defense_unreliable_suppliers_registry": organization.in_defense_unreliable_suppliers_registry, "in_275_fz_registry": organization.in_275_fz_registry, "bankruptcy_messages_found": organization.bankruptcy_messages_found, - "risk_level": organization.risk_level, + "risk_level": risk_level_for_availability( + organization, + financial_reports_available=financial_reports_available, + tax_reports_available=tax_reports_available, + ), "updated_at": organization.updated_at.isoformat(), } @@ -1088,13 +1148,44 @@ class OrganizationAnalyticsService: class DashboardAnalyticsService: """Cross-organization dashboard aggregations.""" + @staticmethod + def _resolve_dashboard_cluster(organization: Organization) -> str: + if organization.cluster: + return organization.cluster + + scope_codes = scopes_from_organization_fields( + gk_code=organization.gk_code, + gk_name=organization.gk_name, + opk_registry_membership=organization.opk_registry_membership, + ) + for scope_code in scope_codes: + if scope_code in {"rosatom", "roscosmos"}: + return scope_code + return scope_codes[0] if scope_codes else IndustryCluster.OTHER + + @staticmethod + def _dashboard_cluster_label(cluster_code: str) -> str: + industry_cluster_label = dict(IndustryCluster.choices).get(cluster_code) + if industry_cluster_label: + return industry_cluster_label + return SCOPE_LABELS.get(cluster_code, "Иная") + + @staticmethod + def _resolve_executors_total(organizations: list[Organization]) -> int: + executors_total = sum( + organization.executors_count for organization in organizations + ) + if executors_total: + return executors_total + return sum( + 1 for organization in organizations if organization.goz_participation + ) + @classmethod def get_dashboard( cls, *, corporation_scope: str | None = None ) -> dict[str, object]: - queryset = Organization.objects.prefetch_related( - "membership_periods__registry" - ).all() + queryset = Organization.objects.all() if corporation_scope: queryset = filter_queryset_by_scopes(queryset, [corporation_scope]) @@ -1110,32 +1201,27 @@ class DashboardAnalyticsService: totals_by_cluster: dict[str, list[Organization]] = defaultdict(list) for organization in organizations: - cluster = organization.cluster or IndustryCluster.OTHER + cluster = cls._resolve_dashboard_cluster(organization) totals_by_cluster[cluster].append(organization) - total_organizations = len(organizations) - current_year = date.today().year - previous_year = current_year - 1 - f3_records = ( FormF3Record.objects.filter( organization__in=organizations, is_active_version=True, - report_year__in=[previous_year, current_year], ) .select_related("organization") .order_by( "organization_id", "report_year", "-report_quarter", "-created_at" ) ) - f3_best: dict[tuple[str, int], FormF3Record] = {} + f3_best_by_organization: dict[str, dict[int, FormF3Record]] = defaultdict(dict) for record in f3_records: - key = (str(record.organization_id), record.report_year) - f3_best.setdefault(key, record) - - def cluster_label(cluster_code: str) -> str: - return dict(IndustryCluster.choices).get(cluster_code, "Иная") + organization_key = str(record.organization_id) + f3_best_by_organization[organization_key].setdefault( + record.report_year, record + ) + total_organizations = len(organizations) distribution_by_cluster = [] executors_by_cluster = [] headcount_growth_by_cluster = [] @@ -1143,15 +1229,21 @@ class DashboardAnalyticsService: for cluster_code, cluster_organizations in sorted(totals_by_cluster.items()): cluster_total = len(cluster_organizations) - executors_total = sum(org.executors_count for org in cluster_organizations) + executors_total = cls._resolve_executors_total(cluster_organizations) bankruptcy_free = sum( 1 for org in cluster_organizations if not org.bankruptcy_messages_found ) growth_values = [] for organization in cluster_organizations: - current_record = f3_best.get((str(organization.id), current_year)) - previous_record = f3_best.get((str(organization.id), previous_year)) + organization_f3_by_year = f3_best_by_organization.get( + str(organization.id), {} + ) + report_years = sorted(organization_f3_by_year) + if len(report_years) < 2: + continue + previous_record = organization_f3_by_year[report_years[-2]] + current_record = organization_f3_by_year[report_years[-1]] if current_record is None or previous_record is None: continue growth_values.append( @@ -1163,7 +1255,7 @@ class DashboardAnalyticsService: distribution_by_cluster.append( { "cluster": cluster_code, - "cluster_label": cluster_label(cluster_code), + "cluster_label": cls._dashboard_cluster_label(cluster_code), "organizations_share_percent": round( (cluster_total / total_organizations) * 100, 1 ), diff --git a/src/apps/organization/api.py b/src/apps/organization/api.py index 89887bc..daa1c49 100644 --- a/src/apps/organization/api.py +++ b/src/apps/organization/api.py @@ -18,8 +18,6 @@ from apps.organization.serializers import ( OrganizationCatalogDetailSerializer, OrganizationCatalogListSerializer, ) -from apps.registers.models import RegistryMembershipPeriod -from django.db.models import Prefetch from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -32,19 +30,11 @@ class OrganizationFilter(filters.FilterSet): name = filters.CharFilter(lookup_expr="icontains") inn = filters.CharFilter(lookup_expr="exact") ogrn = filters.CharFilter(lookup_expr="exact") - registry = filters.UUIDFilter(method="filter_registry") corporation_scope = filters.CharFilter(method="filter_corporation_scope") registry_category = filters.CharFilter(method="filter_registry_category") registryCategory = filters.CharFilter(method="filter_registry_category") organization_type = filters.CharFilter(lookup_expr="exact") - @staticmethod - def filter_registry(queryset, _name, value): - return queryset.filter( - membership_periods__registry_id=value, - membership_periods__ended_at__isnull=True, - ).distinct() - @staticmethod def filter_corporation_scope(queryset, _name, value): requested_scopes = [ @@ -69,7 +59,6 @@ class OrganizationFilter(filters.FilterSet): "name", "inn", "ogrn", - "registry", "corporation_scope", "registry_category", "organization_type", @@ -125,12 +114,6 @@ class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]): description="Фильтр по ОГРН", type=openapi.TYPE_STRING, ), - openapi.Parameter( - "registry", - openapi.IN_QUERY, - description="ID активного реестра", - type=openapi.TYPE_STRING, - ), openapi.Parameter( "corporation_scope", openapi.IN_QUERY, @@ -196,18 +179,3 @@ class OrganizationViewSet(ClassicReadOnlyViewSet[Organization]): ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - - def get_queryset(self): - return ( - super() - .get_queryset() - .prefetch_related( - Prefetch( - "membership_periods", - queryset=RegistryMembershipPeriod.objects.filter( - ended_at__isnull=True - ).select_related("registry"), - to_attr="active_membership_periods_prefetched", - ) - ) - ) diff --git a/src/apps/organization/availability.py b/src/apps/organization/availability.py new file mode 100644 index 0000000..5b29c66 --- /dev/null +++ b/src/apps/organization/availability.py @@ -0,0 +1,48 @@ +"""Availability helpers for organization-facing contracts.""" + +from __future__ import annotations + +from apps.organization.models import Organization + + +def has_financial_reports(organization: Organization) -> bool: + """Return whether financial data is effectively available for an organization.""" + if organization.financial_reports_available: + return True + + return ( + organization.financial_reports.filter(status="success").exists() + or organization.form_f2_records.filter(is_active_version=True).exists() + ) + + +def has_tax_reports(organization: Organization) -> bool: + """Return whether tax-related report data is effectively available.""" + if organization.tax_reports_available: + return True + + return organization.form_f2_records.filter( + is_active_version=True, + income_tax__isnull=False, + ).exists() + + +def risk_level_for_availability( + organization: Organization, + *, + financial_reports_available: bool, + tax_reports_available: bool, +) -> str: + """Calculate risk level with effective availability flags.""" + risk_score = 0 + risk_score += 3 if organization.in_defense_unreliable_suppliers_registry else 0 + risk_score += 2 if organization.in_275_fz_registry else 0 + risk_score += 2 if organization.bankruptcy_messages_found else 0 + risk_score += 1 if not financial_reports_available else 0 + risk_score += 1 if not tax_reports_available else 0 + + if risk_score >= 5: + return "high" + if risk_score >= 2: + return "medium" + return "low" diff --git a/src/apps/organization/contract_serializers.py b/src/apps/organization/contract_serializers.py index 6e083fd..e739d5d 100644 --- a/src/apps/organization/contract_serializers.py +++ b/src/apps/organization/contract_serializers.py @@ -1,12 +1,11 @@ """Serializer contracts for OpenAPI documentation.""" -from rest_framework import serializers - from apps.organization.serializers import ( CorporationScopeDictionarySerializer, OrganizationCatalogDetailSerializer, OrganizationCatalogListSerializer, ) +from rest_framework import serializers class OrganizationCatalogListResponseSerializer(serializers.Serializer): diff --git a/src/apps/organization/migrations/0004_organization_directory_fields.py b/src/apps/organization/migrations/0004_organization_directory_fields.py new file mode 100644 index 0000000..b68ba85 --- /dev/null +++ b/src/apps/organization/migrations/0004_organization_directory_fields.py @@ -0,0 +1,228 @@ +# Generated manually for Mostovik organization directory exchange v3. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organization", "0003_auto_20260407_1326"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="full_name", + field=models.TextField(blank=True, default="", verbose_name="полное наименование"), + ), + migrations.AddField( + model_name="organization", + name="mostovik_uid", + field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name="UID организации в Моставике"), + ), + migrations.AddField( + model_name="organization", + name="rn", + field=models.PositiveBigIntegerField(blank=True, db_index=True, null=True, verbose_name="регистрационный номер"), + ), + migrations.AddField( + model_name="organization", + name="pn_name", + field=models.TextField(blank=True, default="", verbose_name="приведенное наименование"), + ), + migrations.AddField( + model_name="organization", + name="pn_name_en", + field=models.TextField(blank=True, default="", verbose_name="приведенное наименование на английском"), + ), + migrations.AddField( + model_name="organization", + name="ogrip", + field=models.CharField(blank=True, db_index=True, default="", max_length=15, verbose_name="ОГРИП"), + ), + migrations.AddField( + model_name="organization", + name="identity_status", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="полнота реквизитов"), + ), + migrations.AddField( + model_name="organization", + name="primary_identity", + field=models.CharField(blank=True, db_index=True, default="", max_length=255, verbose_name="основной идентификатор"), + ), + migrations.AddField( + model_name="organization", + name="gk_code", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="код государственной корпорации"), + ), + migrations.AddField( + model_name="organization", + name="gk_name", + field=models.CharField(blank=True, db_index=True, default="", max_length=255, verbose_name="государственная корпорация"), + ), + migrations.AddField( + model_name="organization", + name="in_korp_code", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="код положения внутри ГК"), + ), + migrations.AddField( + model_name="organization", + name="in_korp_name", + field=models.CharField(blank=True, default="", max_length=255, verbose_name="положение внутри ГК"), + ), + migrations.AddField( + model_name="organization", + name="filial", + field=models.CharField(blank=True, db_index=True, default="", max_length=64, verbose_name="признак филиала из источника"), + ), + migrations.AddField( + model_name="organization", + name="is_branch", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="филиал"), + ), + migrations.AddField( + model_name="organization", + name="create_date", + field=models.CharField(blank=True, default="", max_length=32, verbose_name="год или дата создания"), + ), + migrations.AddField( + model_name="organization", + name="organizational_legal_form", + field=models.CharField(blank=True, db_index=True, default="", max_length=32, verbose_name="код организационно-правовой формы"), + ), + migrations.AddField( + model_name="organization", + name="organizational_legal_form1", + field=models.CharField(blank=True, default="", max_length=512, verbose_name="организационно-правовая форма"), + ), + migrations.AddField( + model_name="organization", + name="ownership_form", + field=models.CharField(blank=True, db_index=True, default="", max_length=32, verbose_name="код формы собственности"), + ), + migrations.AddField( + model_name="organization", + name="ownership_form1", + field=models.CharField(blank=True, default="", max_length=512, verbose_name="форма собственности"), + ), + migrations.AddField( + model_name="organization", + name="authorized_capital", + field=models.DecimalField(blank=True, decimal_places=2, max_digits=24, null=True, verbose_name="уставный капитал из перечня"), + ), + migrations.AddField( + model_name="organization", + name="business_act_cod", + field=models.CharField(blank=True, db_index=True, default="", max_length=32, verbose_name="код вида деятельности"), + ), + migrations.AddField( + model_name="organization", + name="business_activity", + field=models.CharField(blank=True, default="", max_length=500, verbose_name="вид деятельности"), + ), + migrations.AddField( + model_name="organization", + name="general_director", + field=models.TextField(blank=True, default="", verbose_name="руководитель"), + ), + migrations.AddField( + model_name="organization", + name="general_director_tax_id", + field=models.CharField(blank=True, default="", max_length=32, verbose_name="ИНН руководителя"), + ), + migrations.AddField( + model_name="organization", + name="uk", + field=models.TextField(blank=True, default="", verbose_name="управляющая компания"), + ), + migrations.AddField( + model_name="organization", + name="inn_uk", + field=models.CharField(blank=True, db_index=True, default="", max_length=12, verbose_name="ИНН управляющей компании"), + ), + migrations.AddField( + model_name="organization", + name="appointment_date", + field=models.DateField(blank=True, null=True, verbose_name="дата назначения руководителя"), + ), + migrations.AddField( + model_name="organization", + name="cf_fl_rn", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="регистрационный номер руководителя"), + ), + migrations.AddField( + model_name="organization", + name="akc_fs", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="акционерный капитал федеральная собственность"), + ), + migrations.AddField( + model_name="organization", + name="akc_sf", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="акционерный капитал собственность субъекта РФ"), + ), + migrations.AddField( + model_name="organization", + name="re_za", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="реестр организаций-заказчиков"), + ), + migrations.AddField( + model_name="organization", + name="re_zasf", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="реестр организаций-заказчиков субъекта РФ"), + ), + migrations.AddField( + model_name="organization", + name="goz_participation", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="участие в ГОЗ"), + ), + migrations.AddField( + model_name="organization", + name="opk_registry_membership", + field=models.BooleanField(blank=True, db_index=True, null=True, verbose_name="членство в реестре ОПК"), + ), + migrations.AddField( + model_name="organization", + name="ropk_num", + field=models.CharField(blank=True, db_index=True, default="", max_length=64, verbose_name="номер в реестре ОПК"), + ), + migrations.AddField( + model_name="organization", + name="ropk_razdel_num", + field=models.CharField(blank=True, default="", max_length=64, verbose_name="номер раздела реестра ОПК"), + ), + migrations.AddField( + model_name="organization", + name="ropk_razdel_name", + field=models.TextField(blank=True, default="", verbose_name="раздел реестра ОПК"), + ), + migrations.AddField( + model_name="organization", + name="min", + field=models.TextField(blank=True, default="", verbose_name="министерство"), + ), + migrations.AddField( + model_name="organization", + name="dep", + field=models.TextField(blank=True, default="", verbose_name="департамент"), + ), + migrations.AddField( + model_name="organization", + name="otr", + field=models.TextField(blank=True, default="", verbose_name="отрасль"), + ), + migrations.AddField( + model_name="organization", + name="integrated_structure", + field=models.TextField(blank=True, default="", verbose_name="интегрированная структура"), + ), + migrations.AddField( + model_name="organization", + name="state_sector_code", + field=models.CharField(blank=True, db_index=True, default="", max_length=16, verbose_name="код госсектора"), + ), + migrations.AddField( + model_name="organization", + name="state_sector_name", + field=models.CharField(blank=True, default="", max_length=255, verbose_name="госсектор"), + ), + ] diff --git a/src/apps/organization/models.py b/src/apps/organization/models.py index ecd7c09..27aa768 100644 --- a/src/apps/organization/models.py +++ b/src/apps/organization/models.py @@ -10,7 +10,12 @@ from __future__ import annotations import uuid from apps.core.mixins import TimestampMixin -from apps.organization.scope_utils import scope_labels, scopes_from_registry_names +from apps.organization.scope_utils import ( + active_registry_items_from_organization_fields, + active_registry_names_from_organization_fields, + scope_labels, + scopes_from_organization_fields, +) from django.db import models from django.utils.translation import gettext_lazy as _ @@ -69,6 +74,11 @@ class Organization(TimestampMixin, models.Model): db_index=True, help_text=_("Полное наименование организации"), ) + full_name = models.TextField( + _("полное наименование"), + blank=True, + default="", + ) short_name = models.CharField( _("краткое наименование"), max_length=255, @@ -122,17 +132,152 @@ class Organization(TimestampMixin, models.Model): default="", help_text=_("Общероссийский классификатор предприятий и организаций"), ) + mostovik_uid = models.UUIDField( + _("UID организации в Моставике"), + null=True, + blank=True, + db_index=True, + ) + rn = models.PositiveBigIntegerField( + _("регистрационный номер"), + null=True, + blank=True, + db_index=True, + ) + pn_name = models.TextField( + _("приведенное наименование"), + blank=True, + default="", + ) + pn_name_en = models.TextField( + _("приведенное наименование на английском"), + blank=True, + default="", + ) + ogrip = models.CharField( + _("ОГРИП"), + max_length=15, + blank=True, + default="", + db_index=True, + ) + identity_status = models.CharField( + _("полнота реквизитов"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + primary_identity = models.CharField( + _("основной идентификатор"), + max_length=255, + blank=True, + default="", + db_index=True, + ) + gk_code = models.CharField( + _("код государственной корпорации"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + gk_name = models.CharField( + _("государственная корпорация"), + max_length=255, + blank=True, + default="", + db_index=True, + ) + in_korp_code = models.CharField( + _("код положения внутри ГК"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + in_korp_name = models.CharField( + _("положение внутри ГК"), + max_length=255, + blank=True, + default="", + ) + filial = models.CharField( + _("признак филиала из источника"), + max_length=64, + blank=True, + default="", + db_index=True, + ) + is_branch = models.BooleanField( + _("филиал"), + null=True, + blank=True, + db_index=True, + ) registration_date = models.DateField( _("дата регистрации"), null=True, blank=True, ) + create_date = models.CharField( + _("год или дата создания"), + max_length=32, + blank=True, + default="", + ) + organizational_legal_form = models.CharField( + _("код организационно-правовой формы"), + max_length=32, + blank=True, + default="", + db_index=True, + ) + organizational_legal_form1 = models.CharField( + _("организационно-правовая форма"), + max_length=512, + blank=True, + default="", + ) + ownership_form = models.CharField( + _("код формы собственности"), + max_length=32, + blank=True, + default="", + db_index=True, + ) + ownership_form1 = models.CharField( + _("форма собственности"), + max_length=512, + blank=True, + default="", + ) + authorized_capital = models.DecimalField( + _("уставный капитал из перечня"), + max_digits=24, + decimal_places=2, + null=True, + blank=True, + ) legal_address = models.CharField( _("юридический адрес"), max_length=500, blank=True, default="", ) + business_act_cod = models.CharField( + _("код вида деятельности"), + max_length=32, + blank=True, + default="", + db_index=True, + ) + business_activity = models.CharField( + _("вид деятельности"), + max_length=500, + blank=True, + default="", + ) activity_type = models.CharField( _("основной вид деятельности"), max_length=500, @@ -181,6 +326,127 @@ class Organization(TimestampMixin, models.Model): null=True, blank=True, ) + general_director = models.TextField( + _("руководитель"), + blank=True, + default="", + ) + general_director_tax_id = models.CharField( + _("ИНН руководителя"), + max_length=32, + blank=True, + default="", + ) + uk = models.TextField( + _("управляющая компания"), + blank=True, + default="", + ) + inn_uk = models.CharField( + _("ИНН управляющей компании"), + max_length=12, + blank=True, + default="", + db_index=True, + ) + appointment_date = models.DateField( + _("дата назначения руководителя"), + null=True, + blank=True, + ) + cf_fl_rn = models.CharField( + _("регистрационный номер руководителя"), + max_length=64, + blank=True, + default="", + ) + akc_fs = models.CharField( + _("акционерный капитал федеральная собственность"), + max_length=64, + blank=True, + default="", + ) + akc_sf = models.CharField( + _("акционерный капитал собственность субъекта РФ"), + max_length=64, + blank=True, + default="", + ) + re_za = models.BooleanField( + _("реестр организаций-заказчиков"), + null=True, + blank=True, + db_index=True, + ) + re_zasf = models.BooleanField( + _("реестр организаций-заказчиков субъекта РФ"), + null=True, + blank=True, + db_index=True, + ) + goz_participation = models.BooleanField( + _("участие в ГОЗ"), + null=True, + blank=True, + db_index=True, + ) + opk_registry_membership = models.BooleanField( + _("членство в реестре ОПК"), + null=True, + blank=True, + db_index=True, + ) + ropk_num = models.CharField( + _("номер в реестре ОПК"), + max_length=64, + blank=True, + default="", + db_index=True, + ) + ropk_razdel_num = models.CharField( + _("номер раздела реестра ОПК"), + max_length=64, + blank=True, + default="", + ) + ropk_razdel_name = models.TextField( + _("раздел реестра ОПК"), + blank=True, + default="", + ) + min = models.TextField( + _("министерство"), + blank=True, + default="", + ) + dep = models.TextField( + _("департамент"), + blank=True, + default="", + ) + otr = models.TextField( + _("отрасль"), + blank=True, + default="", + ) + integrated_structure = models.TextField( + _("интегрированная структура"), + blank=True, + default="", + ) + state_sector_code = models.CharField( + _("код госсектора"), + max_length=16, + blank=True, + default="", + db_index=True, + ) + state_sector_name = models.CharField( + _("госсектор"), + max_length=255, + blank=True, + default="", + ) executors_count = models.PositiveIntegerField( _("число исполнителей"), default=0, @@ -240,25 +506,37 @@ class Organization(TimestampMixin, models.Model): ) def get_active_registries(self): - return [period.registry for period in self.get_active_membership_periods()] + return active_registry_items_from_organization_fields( + gk_code=self.gk_code, + gk_name=self.gk_name, + goz_participation=self.goz_participation, + opk_registry_membership=self.opk_registry_membership, + ropk_razdel_num=self.ropk_razdel_num, + ropk_razdel_name=self.ropk_razdel_name, + ) def get_active_registry_names(self) -> list[str]: - return [registry.name for registry in self.get_active_registries()] + return active_registry_names_from_organization_fields( + gk_name=self.gk_name, + goz_participation=self.goz_participation, + opk_registry_membership=self.opk_registry_membership, + ropk_razdel_name=self.ropk_razdel_name, + ) def active_registry_names_display(self) -> str: names = self.get_active_registry_names() return ", ".join(names) if names else "—" - @property - def full_name(self) -> str: - return self.name - @property def display_short_name(self) -> str: return self.short_name or self.name def get_corporation_scopes(self) -> list[str]: - return scopes_from_registry_names(self.get_active_registry_names()) + return scopes_from_organization_fields( + gk_code=self.gk_code, + gk_name=self.gk_name, + opk_registry_membership=self.opk_registry_membership, + ) def get_corporation_scope_labels(self) -> list[str]: return scope_labels(self.get_corporation_scopes()) diff --git a/src/apps/organization/query_serializers.py b/src/apps/organization/query_serializers.py index 77f3628..faf53a4 100644 --- a/src/apps/organization/query_serializers.py +++ b/src/apps/organization/query_serializers.py @@ -1,6 +1,7 @@ """Query serializers for organization analytics endpoints.""" from apps.organization.models import CorporationScope +from apps.organization.scope_utils import SCOPE_ALIASES from rest_framework import serializers @@ -17,8 +18,8 @@ class AliasChoiceField(serializers.ChoiceField): class DashboardQuerySerializer(serializers.Serializer): - corporation_scope = serializers.ChoiceField( - choices=CorporationScope.choices, required=False + corporation_scope = AliasChoiceField( + choices=CorporationScope.choices, aliases=SCOPE_ALIASES, required=False ) diff --git a/src/apps/organization/scope_utils.py b/src/apps/organization/scope_utils.py index aaf6ccd..4f4b45c 100644 --- a/src/apps/organization/scope_utils.py +++ b/src/apps/organization/scope_utils.py @@ -1,4 +1,4 @@ -"""Helpers for corporation scope derivation from active registries.""" +"""Helpers for corporation scope derivation from organization fields.""" from __future__ import annotations @@ -12,6 +12,10 @@ SCOPE_KEYWORDS: dict[str, tuple[str, ...]] = { "opk": ("ОПК",), } +SCOPE_ALIASES: dict[str, str] = { + "roskosmos": "roscosmos", +} + SCOPE_LABELS: dict[str, str] = { "rosatom": "Госкорпорация «Росатом»", "roscosmos": "Госкорпорация «Роскосмос»", @@ -45,31 +49,134 @@ REGISTRY_CATEGORY_LABELS: dict[str, str] = { } -def scopes_from_registry_names(registry_names: Iterable[str]) -> list[str]: - normalized_names = [registry_name.casefold() for registry_name in registry_names] - scopes: list[str] = [] +def _append_unique(items: list[str], value: str) -> None: + if value and value not in items: + items.append(value) + + +def _scope_from_code_or_name(gk_code: str | None, gk_name: str | None) -> str: + normalized_code = normalize_scope_code(str(gk_code or "")) + if normalized_code in {"rosatom", "roscosmos"}: + return normalized_code + + normalized_values = [ + str(value or "").casefold() + for value in (gk_code, gk_name) + if str(value or "").strip() + ] for scope, keywords in SCOPE_KEYWORDS.items(): + if scope == "opk": + continue if any( - keyword.casefold() in registry_name - for registry_name in normalized_names + keyword.casefold() in value + for value in normalized_values for keyword in keywords ): - scopes.append(scope) + return scope + return "" + +def scopes_from_organization_fields( + *, + gk_code: str | None, + gk_name: str | None, + opk_registry_membership: bool | None, +) -> list[str]: + scopes: list[str] = [] + _append_unique(scopes, _scope_from_code_or_name(gk_code, gk_name)) + if opk_registry_membership: + _append_unique(scopes, "opk") return scopes +def active_registry_names_from_organization_fields( + *, + gk_name: str | None, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_razdel_name: str | None, +) -> list[str]: + names: list[str] = [] + if gk_name: + _append_unique(names, str(gk_name).strip()) + if goz_participation: + _append_unique(names, "Участие в ГОЗ") + if opk_registry_membership: + _append_unique(names, str(ropk_razdel_name or "").strip() or "Реестр ОПК") + return names + + +def active_registry_items_from_organization_fields( + *, + gk_code: str | None, + gk_name: str | None, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_razdel_num: str | None, + ropk_razdel_name: str | None, +) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + if gk_name: + items.append({"id": str(gk_code or "gk"), "name": str(gk_name).strip()}) + if goz_participation: + items.append({"id": "goz", "name": "Участие в ГОЗ"}) + if opk_registry_membership: + items.append( + { + "id": str(ropk_razdel_num or "opk"), + "name": str(ropk_razdel_name or "").strip() or "Реестр ОПК", + } + ) + return items + + +def registry_categories_from_organization_fields( + *, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_num: str | None, + ropk_razdel_num: str | None, + ropk_razdel_name: str | None, +) -> list[str]: + categories: list[str] = [] + if opk_registry_membership or ropk_num or ropk_razdel_num or ropk_razdel_name: + categories.append("opk") + if goz_participation: + categories.append("goz") + return categories or ["other"] + + +def primary_registry_category_from_organization_fields( + *, + goz_participation: bool | None, + opk_registry_membership: bool | None, + ropk_num: str | None, + ropk_razdel_num: str | None, + ropk_razdel_name: str | None, +) -> str: + return registry_categories_from_organization_fields( + goz_participation=goz_participation, + opk_registry_membership=opk_registry_membership, + ropk_num=ropk_num, + ropk_razdel_num=ropk_razdel_num, + ropk_razdel_name=ropk_razdel_name, + )[0] + + def scope_labels(scope_codes: Iterable[str]) -> list[str]: return [SCOPE_LABELS[code] for code in scope_codes if code in SCOPE_LABELS] +def normalize_scope_code(scope_code: str) -> str: + normalized = str(scope_code).strip().lower() + return SCOPE_ALIASES.get(normalized, normalized) + + def get_corporation_scope_dictionary() -> list[dict[str, str | int]]: """Возвращает справочник корпусов для API-словаря.""" items: list[dict[str, str | int]] = [] - for code, sort_order in sorted( - SCOPE_SORT_ORDER.items(), key=lambda item: item[1] - ): + for code, sort_order in sorted(SCOPE_SORT_ORDER.items(), key=lambda item: item[1]): label = SCOPE_LABELS.get(code) short_name = SCOPE_SHORT_NAMES.get(code) if not label or not short_name: @@ -85,25 +192,6 @@ def get_corporation_scope_dictionary() -> list[dict[str, str | int]]: return items -def registry_categories_from_registry_names(registry_names: Iterable[str]) -> list[str]: - normalized_names = [registry_name.casefold() for registry_name in registry_names] - categories: list[str] = [] - - for category, keywords in REGISTRY_CATEGORY_KEYWORDS.items(): - if any( - keyword.casefold() in registry_name - for registry_name in normalized_names - for keyword in keywords - ): - categories.append(category) - - return categories or ["other"] - - -def primary_registry_category(registry_names: Iterable[str]) -> str: - return registry_categories_from_registry_names(registry_names)[0] - - def registry_category_label(category_code: str) -> str: return REGISTRY_CATEGORY_LABELS.get(category_code, "") @@ -111,19 +199,27 @@ def registry_category_label(category_code: str) -> str: def build_scope_query(scope_codes: Iterable[str]) -> Q: query = Q() for scope_code in scope_codes: - keywords = SCOPE_KEYWORDS.get(scope_code, ()) - for keyword in keywords: - query |= Q( - membership_periods__registry__name__contains=keyword, - membership_periods__ended_at__isnull=True, + if scope_code == "rosatom": + query |= Q(gk_code__iexact="rosatom") | Q(gk_name__icontains="Росатом") + elif scope_code == "roscosmos": + query |= ( + Q(gk_code__iexact="roscosmos") + | Q(gk_code__iexact="roskosmos") + | Q(gk_name__icontains="Роскосмос") ) + elif scope_code == "opk": + query |= Q(opk_registry_membership=True) | ~Q(ropk_num="") return query def filter_queryset_by_scopes( queryset: QuerySet, scope_codes: Iterable[str] ) -> QuerySet: - scope_codes = [code for code in scope_codes if code in SCOPE_KEYWORDS] + scope_codes = [ + normalized_code + for code in scope_codes + if (normalized_code := normalize_scope_code(code)) in SCOPE_KEYWORDS + ] if not scope_codes: return queryset.none() return queryset.filter(build_scope_query(scope_codes)).distinct() @@ -133,15 +229,14 @@ def build_registry_category_query(category_codes: Iterable[str]) -> Q: query = Q() for category_code in category_codes: if category_code == "opk": - query |= Q( - membership_periods__registry__name__icontains="ОПК", - membership_periods__ended_at__isnull=True, + query |= ( + Q(opk_registry_membership=True) + | ~Q(ropk_num="") + | ~Q(ropk_razdel_num="") + | ~Q(ropk_razdel_name="") ) elif category_code == "goz": - query |= Q( - membership_periods__registry__name__icontains="ГОЗ", - membership_periods__ended_at__isnull=True, - ) + query |= Q(goz_participation=True) return query diff --git a/src/apps/organization/serializers.py b/src/apps/organization/serializers.py index 1eadd5a..1cee2f1 100644 --- a/src/apps/organization/serializers.py +++ b/src/apps/organization/serializers.py @@ -6,25 +6,16 @@ - frontend-facing serializers for organization catalog endpoints. """ +from apps.organization.availability import has_financial_reports, has_tax_reports from apps.organization.models import Organization from apps.organization.scope_utils import ( SCOPE_LABELS, - primary_registry_category, + primary_registry_category_from_organization_fields, registry_category_label, ) -from apps.registers.models import Register from rest_framework import serializers -class OrganizationRegisterSerializer(serializers.ModelSerializer): - """Краткий сериализатор активного реестра организации.""" - - class Meta: - model = Register - fields = ["id", "name"] - read_only_fields = fields - - class OrganizationSerializer(serializers.ModelSerializer): """Полный nested-сериализатор организации для внутренних API.""" @@ -37,9 +28,7 @@ class OrganizationSerializer(serializers.ModelSerializer): @staticmethod def get_active_registries(obj: Organization) -> list[dict[str, str]]: - return OrganizationRegisterSerializer( - obj.get_active_registries(), many=True - ).data + return obj.get_active_registries() class Meta: model = Organization @@ -78,14 +67,6 @@ class OrganizationListSerializer(serializers.ModelSerializer): ] -class GeneralDirectorSerializer(serializers.Serializer): - """Сериализатор блока генерального директора.""" - - full_name = serializers.CharField() - inn = serializers.CharField() - appointment_date = serializers.DateField(allow_null=True) - - class OrganizationCatalogSummarySerializer(serializers.Serializer): """Сериализатор summary блока организации.""" @@ -98,7 +79,7 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): """Базовый сериализатор frontend-контракта организации.""" short_name = serializers.SerializerMethodField() - full_name = serializers.CharField(source="name", read_only=True) + full_name = serializers.SerializerMethodField() corporation_scope = serializers.SerializerMethodField() corporation_scope_label = serializers.SerializerMethodField() registry_category = serializers.SerializerMethodField() @@ -110,13 +91,23 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): def get_short_name(obj: Organization) -> str: return obj.display_short_name + @staticmethod + def get_full_name(obj: Organization) -> str: + return obj.full_name or obj.name + @staticmethod def get_active_registry_names(obj: Organization) -> list[str]: return obj.get_active_registry_names() @staticmethod def get_registry_category(obj: Organization) -> str: - return primary_registry_category(obj.get_active_registry_names()) + return primary_registry_category_from_organization_fields( + goz_participation=obj.goz_participation, + opk_registry_membership=obj.opk_registry_membership, + ropk_num=obj.ropk_num, + ropk_razdel_num=obj.ropk_razdel_num, + ropk_razdel_name=obj.ropk_razdel_name, + ) @staticmethod def get_registry_category_label(obj: Organization) -> str: @@ -145,7 +136,6 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): return SCOPE_LABELS.get(scope_code, "") - class OrganizationCatalogListSerializer(OrganizationCatalogBaseSerializer): """Сериализатор списка организаций для `/api/v1/organizations/`.""" @@ -166,6 +156,12 @@ class OrganizationCatalogListSerializer(OrganizationCatalogBaseSerializer): "kpp", "okpo", "active_registry_names", + "gk_code", + "gk_name", + "in_korp_code", + "in_korp_name", + "goz_participation", + "opk_registry_membership", ] @@ -173,28 +169,17 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): """Сериализатор детальной карточки организации.""" active_registries = serializers.SerializerMethodField() - general_director = serializers.SerializerMethodField() summary = serializers.SerializerMethodField() @staticmethod def get_active_registries(obj: Organization) -> list[dict[str, str]]: - return OrganizationRegisterSerializer( - obj.get_active_registries(), many=True - ).data - - @staticmethod - def get_general_director(obj: Organization) -> dict[str, str | None]: - return { - "full_name": obj.general_director_name, - "inn": obj.general_director_inn, - "appointment_date": obj.general_director_appointment_date, - } + return obj.get_active_registries() @staticmethod def get_summary(obj: Organization) -> dict[str, object]: return { - "financial_reports_available": obj.financial_reports_available, - "tax_reports_available": obj.tax_reports_available, + "financial_reports_available": has_financial_reports(obj), + "tax_reports_available": has_tax_reports(obj), "active_registry_names": obj.get_active_registry_names(), } @@ -204,6 +189,10 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): "id", "short_name", "full_name", + "mostovik_uid", + "rn", + "pn_name", + "pn_name_en", "corporation_scope", "corporation_scope_label", "registry_category", @@ -214,14 +203,46 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): "ogrn", "kpp", "okpo", + "ogrip", + "identity_status", + "primary_identity", + "gk_code", + "gk_name", + "in_korp_code", + "in_korp_name", + "filial", + "is_branch", "registration_date", + "create_date", + "organizational_legal_form", + "organizational_legal_form1", + "ownership_form", + "ownership_form1", + "authorized_capital", "legal_address", - "activity_type", - "founder_name", - "ownership_type", - "legal_form", - "charter_capital_amount", + "business_act_cod", + "business_activity", "general_director", + "general_director_tax_id", + "uk", + "inn_uk", + "appointment_date", + "cf_fl_rn", + "akc_fs", + "akc_sf", + "re_za", + "re_zasf", + "goz_participation", + "opk_registry_membership", + "ropk_num", + "ropk_razdel_num", + "ropk_razdel_name", + "min", + "dep", + "otr", + "integrated_structure", + "state_sector_code", + "state_sector_name", "summary", "active_registries", "created_at", diff --git a/src/apps/organization/services.py b/src/apps/organization/services.py index 0331ddf..0e8bf54 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 organization identifiers cannot be resolved.""" class OrganizationService(BaseService[Organization]): @@ -20,7 +15,8 @@ class OrganizationService(BaseService[Organization]): Сервис для работы с организациями. Методы: - get_or_create_by_inn: Получить или создать организацию по ИНН + get_or_create_by_inn: Legacy lookup API. Creation is intentionally disabled. + get_or_create_from_form_identifiers: Создать организацию из строки формы. update_organization: Обновить данные организации search_by_name: Поиск по наименованию """ @@ -28,53 +24,87 @@ 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) + return organization - 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}, - ) + @classmethod + def get_or_create_from_form_identifiers( + cls, + *, + name: str | None, + inn: str | None, + ogrn: str | None = None, + kpp: str | None = None, + okpo: str | None = None, + ) -> Organization: + """ + Return an organization for a report row, creating it when missing. - return org, created + Form imports are allowed to create the organization reference when the row + contains the standard identifiers. Existing organizations are not + overwritten; the form row only fills identifier fields that are still blank. + """ + normalized_inn = cls._clean_digits(inn) + if not normalized_inn: + raise OrganizationNotFoundError("ИНН организации не указан") + + normalized_name = cls._clean_string(name) + if not normalized_name: + raise OrganizationNotFoundError("Наименование организации не указано") + + organization, created = cls.model.objects.get_or_create( + inn=normalized_inn, + defaults={ + "name": normalized_name, + "ogrn": cls._clean_digits(ogrn), + "kpp": cls._clean_digits(kpp), + "okpo": cls._clean_digits(okpo), + }, + ) + if created: + return organization + + update_fields: list[str] = [] + for field_name, value in ( + ("ogrn", cls._clean_digits(ogrn)), + ("kpp", cls._clean_digits(kpp)), + ("okpo", cls._clean_digits(okpo)), + ): + if value and not getattr(organization, field_name): + setattr(organization, field_name, value) + update_fields.append(field_name) + + if update_fields: + organization.save(update_fields=update_fields + ["updated_at"]) + + return organization @classmethod def search_by_name(cls, query: str, limit: int = 20): @@ -101,7 +131,22 @@ 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()) + + @staticmethod + def _clean_string(value: Any) -> str: + if value is None: + return "" + return str(value).strip() 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/src/apps/user/views.py b/src/apps/user/views.py index b078c76..b613835 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -249,16 +249,19 @@ class AdminUsersManagementView(APIView): return latest_jobs: dict[int, BackgroundJob] = {} - jobs = BackgroundJobService.get_queryset().filter( - user_id__in=user_ids - ).annotate( - _effective_job_ts=Coalesce( - F("completed_at"), - F("started_at"), - F("updated_at"), - F("created_at"), + jobs = ( + BackgroundJobService.get_queryset() + .filter(user_id__in=user_ids) + .annotate( + _effective_job_ts=Coalesce( + F("completed_at"), + F("started_at"), + F("updated_at"), + F("created_at"), + ) ) - ).order_by("user_id", "-_effective_job_ts") + .order_by("user_id", "-_effective_job_ts") + ) for job in jobs: if job.user_id not in latest_jobs: diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py index 86268bf..44c48bc 100644 --- a/tests/apps/exchange/test_api.py +++ b/tests/apps/exchange/test_api.py @@ -9,6 +9,7 @@ import struct import tempfile import zlib from datetime import date +from decimal import Decimal from io import BytesIO from zipfile import ZIP_DEFLATED, ZipFile @@ -16,7 +17,15 @@ from apps.exchange.models import ExchangeDeliveryChannel, ExchangePackageImport from apps.exchange.services import ExchangePackageImportService from apps.external_data.models import ( ArbitrationCase, + BankruptcyProcedure, + DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, + InformationSecurityRegistryEntry, + LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, ) @@ -44,15 +53,17 @@ def build_exchange_archive( archive_name: str = "exchange_package_20260407.zip", bin_name: str = "exchange_package_20260407.bin", data: dict[str, list[dict[str, object]]] | None = None, + schema_version: int = ExchangePackageImportService.SUPPORTED_SCHEMA_VERSION, ) -> SimpleUploadedFile: """Build encrypted exchange archive compatible with import service.""" payload = { "format": ExchangePackageImportService.PAYLOAD_FORMAT, - "schema_version": 1, + "schema_version": schema_version, "manifest": { "package_id": package_id, "source_system": "mostovik-dev", "produced_at": "2026-04-07T12:00:00+00:00", + "schema_version": schema_version, "sections": list((data or {}).keys()), }, "data": data or {}, @@ -75,7 +86,7 @@ def build_exchange_archive( "nonce": _b64url(nonce), "aad": _b64url(aad), "package_id": package_id, - "schema_version": 1, + "schema_version": schema_version, "plaintext_sha256": hashlib.sha256(payload_bytes).hexdigest(), "compressed_sha256": hashlib.sha256(compressed_payload).hexdigest(), "ciphertext_sha256": hashlib.sha256(encrypted_payload).hexdigest(), @@ -113,41 +124,110 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: return { "organizations": [ { + "mostovik_uid": "11111111-1111-4111-8111-111111111111", + "rn": 1001, "inn": "7707083893", "name": "АО Альфа Обновленная", + "full_name": "Акционерное общество Альфа Обновленная", "short_name": "АО Альфа", - "organization_type": "ao", - "cluster": "radioelectronics", + "pn_name": "АО Альфа", + "pn_name_en": "Alpha JSC", "ogrn": "1027700132195", "kpp": "770701001", "okpo": "12345678", + "ogrip": "", + "identity_status": "complete", + "primary_identity": "inn:7707083893|ogrn:1027700132195|kpp:770701001", + "gk_code": "1", + "gk_name": "Росатом", + "in_korp_code": "head", + "in_korp_name": "Головная организация", + "filial": ".F.", + "is_branch": False, "registration_date": "2024-02-15", + "create_date": "2024", + "organizational_legal_form": "12267", + "organizational_legal_form1": "Акционерное общество", + "ownership_form": "61", + "ownership_form1": "Федеральная собственность", + "authorized_capital": "1500000.50", "legal_address": "г. Москва, ул. Тверская, д. 1", - "activity_type": "Производство электронных компонентов", - "founder_name": "Госкорпорация Пример", - "ownership_type": "Федеральная собственность", - "legal_form": "Акционерное общество", - "charter_capital_amount": "1500000.50", - "general_director_name": "Иванов Иван Иванович", - "general_director_inn": "123456789012", - "general_director_appointment_date": "2025-01-10", - "executors_count": 175, - "financial_reports_available": True, - "tax_reports_available": True, - "in_defense_unreliable_suppliers_registry": False, - "in_275_fz_registry": True, - "bankruptcy_messages_found": False, + "business_act_cod": "26.11", + "business_activity": "Производство электронных компонентов", + "general_director": "Иванов Иван Иванович", + "general_director_tax_id": "123456789012", + "uk": "АО Управляющая компания", + "inn_uk": "7707000000", + "appointment_date": "2025-01-10", + "cf_fl_rn": "director-001", + "akc_fs": "100", + "akc_sf": "0", + "re_za": True, + "re_zasf": False, + "goz_participation": True, + "opk_registry_membership": True, + "ropk_num": "РОПК-001", + "ropk_razdel_num": "opk-1", + "ropk_razdel_name": "Росатом ОПК", + "min": "Минпромторг", + "dep": "Департамент радиоэлектроники", + "otr": "Радиоэлектроника", + "integrated_structure": "Интегрированная структура Альфа", + "state_sector_code": "10", + "state_sector_name": "Государственный сектор", }, { + "mostovik_uid": "22222222-2222-4222-8222-222222222222", + "rn": 1002, "inn": "7707083894", "name": "АО Бета", + "full_name": "Акционерное общество Бета", "short_name": "АО Бета", - "organization_type": "pao", - "cluster": "space", + "pn_name": "АО Бета", + "pn_name_en": "Beta JSC", "ogrn": "1027700132196", "kpp": "770701002", "okpo": "12345679", + "ogrip": "", + "identity_status": "complete", + "primary_identity": "inn:7707083894|ogrn:1027700132196|kpp:770701002", + "gk_code": "2", + "gk_name": "Роскосмос", + "in_korp_code": "member", + "in_korp_name": "Организация внутри ГК", + "filial": ".F.", + "is_branch": False, "registration_date": "2023-09-01", + "create_date": "2023", + "organizational_legal_form": "12267", + "organizational_legal_form1": "Акционерное общество", + "ownership_form": "61", + "ownership_form1": "Федеральная собственность", + "authorized_capital": None, + "legal_address": "", + "business_act_cod": "", + "business_activity": "", + "general_director": "", + "general_director_tax_id": "", + "uk": "", + "inn_uk": "", + "appointment_date": None, + "cf_fl_rn": "", + "akc_fs": "", + "akc_sf": "", + "re_za": None, + "re_zasf": None, + "goz_participation": False, + "opk_registry_membership": False, + "ropk_num": "", + "ropk_razdel_num": "", + "ropk_razdel_name": "", + "min": "", + "dep": "", + "otr": "", + "integrated_structure": "", + "state_sector_code": "", + "state_sector_name": "", }, ], "industrial_products": [ @@ -160,6 +240,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", @@ -184,6 +284,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", @@ -194,6 +316,53 @@ def build_exchange_payload() -> dict[str, list[dict[str, object]]]: "decision_date": "2026-03-25", } ], + "bankruptcy_procedures": [ + { + "organization_inn": "7707083893", + "external_id": "fedresurs:001", + "message_type": "Сообщение о намерении", + "message_date": "2026-03-26", + "case_number": "А40-555/2026", + "status": "published", + "source_url": "https://fedresurs.ru/message/001", + } + ], + "defense_unreliable_suppliers": [ + { + "organization_inn": "7707083893", + "external_id": "fas-goz:001", + "registry_source": "fas_goz", + "registry_number": "ГОЗ-001", + "supplier_name": "АО Альфа Обновленная", + "reason": "Уклонение от заключения контракта", + "included_at": "2026-02-20", + "status": "active", + "source_url": "https://fas.gov.ru/register/001", + } + ], + "information_security_registries": [ + { + "organization_inn": "7707083893", + "external_id": "fstec:001", + "registry_name": "Реестр лицензий ФСТЭК", + "presence_status": "present", + "entry_number": "77-001234", + "issued_at": "2026-01-10", + "expires_at": "2027-01-10", + } + ], + "labor_vacancies": [ + { + "organization_inn": "7707083893", + "external_id": "trudvsem:001", + "vacancy_source": "trudvsem", + "title": "Инженер-испытатель", + "status": "open", + "published_at": "2026-04-01", + "salary_amount": "175000.00", + "source_url": "https://trudvsem.ru/vacancy/001", + } + ], } @@ -229,21 +398,69 @@ class ExchangePackageApiTest(APITestCase): self.assertFalse(response.data["result"]["duplicate"]) self.assertEqual(response.data["result"]["organizations"]["created"], 1) self.assertEqual(response.data["result"]["organizations"]["updated"], 1) + self.assertNotIn("registry_memberships", response.data["result"]) 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) + self.assertEqual(InformationSecurityRegistryEntry.objects.count(), 1) + self.assertEqual(LaborVacancy.objects.count(), 1) + self.assertEqual( + 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, + ) + self.assertEqual( + response.data["result"]["information_security_registries"]["created"], + 1, + ) + self.assertEqual(response.data["result"]["labor_vacancies"]["created"], 1) organization = Organization.objects.get(inn="7707083893") self.assertEqual(organization.name, "АО Альфа Обновленная") + self.assertEqual( + organization.full_name, "Акционерное общество Альфа Обновленная" + ) self.assertEqual(organization.short_name, "АО Альфа") - self.assertEqual(organization.organization_type, "ao") - self.assertEqual(organization.cluster, "radioelectronics") + self.assertEqual( + str(organization.mostovik_uid), "11111111-1111-4111-8111-111111111111" + ) + self.assertEqual(organization.rn, 1001) + self.assertEqual(organization.gk_name, "Росатом") + self.assertEqual(organization.in_korp_name, "Головная организация") self.assertEqual(organization.registration_date, date(2024, 2, 15)) - self.assertEqual(organization.executors_count, 175) - self.assertTrue(organization.tax_reports_available) - self.assertTrue(organization.in_275_fz_registry) + self.assertEqual(organization.appointment_date, date(2025, 1, 10)) + self.assertEqual( + organization.business_activity, "Производство электронных компонентов" + ) + self.assertEqual( + organization.organizational_legal_form1, "Акционерное общество" + ) + self.assertEqual(organization.ownership_form1, "Федеральная собственность") + self.assertEqual(organization.authorized_capital, Decimal("1500000.50")) + self.assertEqual(organization.general_director, "Иванов Иван Иванович") + self.assertEqual(organization.general_director_tax_id, "123456789012") + self.assertTrue(organization.goz_participation) + self.assertTrue(organization.opk_registry_membership) + self.assertEqual(organization.ropk_razdel_name, "Росатом ОПК") + self.assertEqual( + organization.get_active_registry_names(), + ["Росатом", "Участие в ГОЗ", "Росатом ОПК"], + ) package_import = ExchangePackageImport.objects.get() self.assertEqual(package_import.delivery_channel, ExchangeDeliveryChannel.API) @@ -264,6 +481,112 @@ class ExchangePackageApiTest(APITestCase): self.assertEqual(ExchangePackageImport.objects.count(), 0) self.assertEqual(Organization.objects.count(), 0) + def test_upload_rejects_legacy_schema_version(self): + archive = build_exchange_archive( + package_id="pkg-legacy-schema-version", + schema_version=2, + data=build_exchange_payload(), + ) + + 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("schema_version 3", response.data["file"][0]) + self.assertEqual(Organization.objects.count(), 0) + + def test_upload_rejects_legacy_registry_memberships_section(self): + payload = build_exchange_payload() + payload["registry_memberships"] = [ + { + "organization_inn": "7707083893", + "registry_name": "Реестр госкорпорации Росатом", + "started_at": "2026-01-01", + "ended_at": None, + } + ] + archive = build_exchange_archive( + package_id="pkg-legacy-registry-memberships", + data=payload, + ) + + 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("registry_memberships", response.data["file"][0]) + self.assertEqual(Organization.objects.count(), 0) + + def test_upload_rejects_legacy_organization_row_schema(self): + archive = build_exchange_archive( + package_id="pkg-legacy-organization-row", + data={ + "organizations": [ + { + "inn": "7707083893", + "name": "АО Альфа", + "ogrn": "1027700132195", + } + ], + }, + ) + + 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("schema_version 3", response.data["file"][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 fc2e9f0..f6619b1 100644 --- a/tests/apps/external_data/factories.py +++ b/tests/apps/external_data/factories.py @@ -3,10 +3,17 @@ import factory from apps.external_data.models import ( ArbitrationCase, + BankruptcyProcedure, + DefenseUnreliableSupplier, + FinancialReport, + FinancialReportLine, + IndustrialCertificate, IndustrialProduct, + InformationSecurityRegistryEntry, + LaborVacancy, + ManufacturerRegistryEntry, ProsecutorCheck, PublicProcurement, - InformationSecurityRegistryEntry, ) from faker import Faker @@ -27,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 @@ -71,9 +104,7 @@ class ArbitrationCaseFactory(factory.django.DjangoModelFactory): decision_date = factory.LazyAttribute(lambda _: fake.date_this_year()) -class InformationSecurityRegistryEntryFactory( - factory.django.DjangoModelFactory -): +class InformationSecurityRegistryEntryFactory(factory.django.DjangoModelFactory): class Meta: model = InformationSecurityRegistryEntry @@ -83,3 +114,72 @@ class InformationSecurityRegistryEntryFactory( entry_number = "77-001234" issued_at = factory.LazyAttribute(lambda _: fake.date_this_year()) expires_at = factory.LazyAttribute(lambda _: fake.date_this_year()) + + +class BankruptcyProcedureFactory(factory.django.DjangoModelFactory): + class Meta: + model = BankruptcyProcedure + + organization = factory.SubFactory(OrganizationFactory) + external_id = factory.Sequence(lambda n: f"fedresurs:{n}") + message_type = "Сообщение о намерении" + message_date = factory.LazyAttribute(lambda _: fake.date_this_year()) + case_number = factory.Sequence(lambda n: f"А40-{20_000 + n}/2026") + status = "published" + source_url = factory.Sequence(lambda n: f"https://fedresurs.ru/message/{n}") + + +class DefenseUnreliableSupplierFactory(factory.django.DjangoModelFactory): + class Meta: + model = DefenseUnreliableSupplier + + organization = factory.SubFactory(OrganizationFactory) + external_id = factory.Sequence(lambda n: f"fas-goz:{n}") + registry_source = "fas_goz" + registry_number = factory.Sequence(lambda n: f"ГОЗ-{n:04d}") + supplier_name = factory.LazyAttribute(lambda obj: obj.organization.name) + reason = "Уклонение от заключения контракта" + included_at = factory.LazyAttribute(lambda _: fake.date_this_year()) + status = "active" + source_url = factory.Sequence(lambda n: f"https://fas.gov.ru/register/{n}") + + +class LaborVacancyFactory(factory.django.DjangoModelFactory): + class Meta: + model = LaborVacancy + + organization = factory.SubFactory(OrganizationFactory) + external_id = factory.Sequence(lambda n: f"trudvsem:{n}") + vacancy_source = "trudvsem" + title = "Инженер-испытатель" + status = "open" + 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 b950023..6aa2ec6 100644 --- a/tests/apps/external_data/test_api.py +++ b/tests/apps/external_data/test_api.py @@ -10,10 +10,17 @@ from rest_framework.test import APITestCase from tests.apps.external_data.factories import ( ArbitrationCaseFactory, + BankruptcyProcedureFactory, + DefenseUnreliableSupplierFactory, + FinancialReportFactory, + FinancialReportLineFactory, + IndustrialCertificateFactory, IndustrialProductFactory, + InformationSecurityRegistryEntryFactory, + LaborVacancyFactory, + ManufacturerRegistryEntryFactory, ProsecutorCheckFactory, PublicProcurementFactory, - InformationSecurityRegistryEntryFactory, ) from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -45,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, @@ -116,3 +150,64 @@ class ExternalDataApiTest(APITestCase): self.assertEqual(result["presence_status"], "present") self.assertIn("registry_name", result) self.assertIn("entry_number", result) + + def test_additional_exchange_sections_are_exposed(self): + BankruptcyProcedureFactory.create( + organization=self.organization, + message_type="Сообщение о намерении", + message_date=date(2026, 3, 26), + ) + DefenseUnreliableSupplierFactory.create( + organization=self.organization, + registry_source="fas_goz", + included_at=date(2026, 2, 20), + ) + LaborVacancyFactory.create( + organization=self.organization, + vacancy_source="trudvsem", + published_at=date(2026, 4, 1), + ) + BankruptcyProcedureFactory.create(organization=self.other_organization) + DefenseUnreliableSupplierFactory.create(organization=self.other_organization) + LaborVacancyFactory.create(organization=self.other_organization) + + bankruptcy_response = self.client.get( + f"/api/v1/bankruptcy-procedures/?organization={self.organization.id}" + "&message_date_from=2026-01-01&message_date_to=2026-12-31" + ) + defense_response = self.client.get( + f"/api/v1/defense-unreliable-suppliers/?organization={self.organization.id}" + "®istry_source=fas_goz" + ) + vacancies_response = self.client.get( + f"/api/v1/labor-vacancies/?organization={self.organization.id}" + "&vacancy_source=trudvsem" + ) + + self.assertEqual(bankruptcy_response.status_code, status.HTTP_200_OK) + self.assertEqual(defense_response.status_code, status.HTTP_200_OK) + self.assertEqual(vacancies_response.status_code, status.HTTP_200_OK) + self.assertEqual(bankruptcy_response.data["count"], 1) + self.assertEqual(defense_response.data["count"], 1) + self.assertEqual(vacancies_response.data["count"], 1) + self.assertEqual( + 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_api.py b/tests/apps/form_1/test_api.py new file mode 100644 index 0000000..26ab5ca --- /dev/null +++ b/tests/apps/form_1/test_api.py @@ -0,0 +1,82 @@ +"""API tests for form_1 records.""" + +from __future__ import annotations + +from decimal import Decimal + +from django.db import connection +from django.test import override_settings +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APITestCase + +from tests.apps.form_1.factories import FormF1RecordFactory +from tests.apps.organization.factories import OrganizationFactory +from tests.apps.user.factories import UserFactory + + +@override_settings(ROOT_URLCONF="core.urls") +class FormF1RecordApiOrderingTest(APITestCase): + """Ordering behavior for monthly F-1 records.""" + + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + self.organization = OrganizationFactory.create(inn="1111111111") + + def test_ordering_appends_report_month_tiebreaker_for_monthly_records(self): + FormF1RecordFactory.create( + organization=self.organization, + report_year=2025, + report_month=9, + report_quarter=None, + ) + FormF1RecordFactory.create( + organization=self.organization, + report_year=2025, + report_month=12, + report_quarter=None, + ) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get( + "/api/v1/forms/f1/records/", + { + "organization_inn": self.organization.inn, + "ordering": "-report_year,-report_quarter", + "page_size": 100, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [record["report_month"] for record in response.data["data"]], + [12, 9], + ) + list_query = next( + query["sql"] + for query in queries.captured_queries + if 'FROM "form_1_formf1record"' in query["sql"] + and "ORDER BY" in query["sql"] + ) + self.assertIn('"form_1_formf1record"."report_month" DESC', list_query) + + def test_read_returns_form_f1_record_payload_without_response_envelope(self): + record = FormF1RecordFactory.create( + organization=self.organization, + report_year=2025, + report_month=12, + report_quarter=None, + avg_employees=Decimal("785.00"), + avg_payroll_employees=Decimal("787.00"), + payroll_fund=Decimal("266614.00"), + ) + + response = self.client.get(f"/api/v1/forms/f1/records/{record.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], str(record.id)) + self.assertEqual(response.data["avg_employees"], "785.00") + self.assertEqual(response.data["avg_payroll_employees"], "787.00") + self.assertEqual(response.data["payroll_fund"], "266614.00") + self.assertNotIn("data", response.data) diff --git a/tests/apps/form_1/test_services.py b/tests/apps/form_1/test_services.py index 8afa2c4..3b3a80b 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 reuse existing organizations when available.""" + 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..1f336b5 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 reuse existing organizations when available.""" 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..bb1d3fc 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 reuse existing organizations when available.""" 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..fafe7ad 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 reuse existing organizations when available.""" + 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..ce526f0 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 reuse existing organizations when available.""" 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..fad00db 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 reuse existing organizations when available.""" 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_parser_organization_creation.py b/tests/apps/forms/test_parser_organization_creation.py new file mode 100644 index 0000000..94e4021 --- /dev/null +++ b/tests/apps/forms/test_parser_organization_creation.py @@ -0,0 +1,127 @@ +"""Tests for organization creation during form imports.""" + +from apps.form_1.services import FormF1Parser +from apps.form_2.services import FormF2Parser +from apps.form_3.services import FormF3Parser +from apps.form_4.services import FormF4Parser +from apps.form_5.services import FormF5Parser +from apps.form_6.services import FormF6Parser +from apps.organization.models import Organization +from django.test import TestCase + + +class FormParserOrganizationCreationTest(TestCase): + """Tests that report rows may create organizations without exchange preload.""" + + CASES = ( + ( + "f1", + FormF1Parser, + {"report_year": 2026, "report_month": 9}, + 601, + { + "organization_name": "Тестовая организация Ф-1", + "inn": "7100000001", + "ogrn": "1020000000001", + "kpp": "770100001", + "okpo": "11110001", + "military_output_actual": 100.0, + "civilian_output_actual": 200.0, + }, + ), + ( + "f2", + FormF2Parser, + {"report_year": 2026, "report_quarter": 1}, + 602, + { + "organization_name": "Тестовая организация Ф-2", + "inn": "7100000002", + "ogrn": "1020000000002", + "kpp": "770100002", + "okpo": "11110002", + "total_assets": 1000000.0, + "revenue": 500000.0, + }, + ), + ( + "f3", + FormF3Parser, + {"report_year": 2026, "report_quarter": 1}, + 603, + { + "organization_name": "Тестовая организация Ф-3", + "inn": "7100000003", + "ogrn": "1020000000003", + "kpp": "770100003", + "okpo": "11110003", + "avg_employees": 100, + "total_equipment": 50, + }, + ), + ( + "f4", + FormF4Parser, + {"report_year": 2026, "report_half_year": 1}, + 604, + { + "organization_name": "Тестовая организация Ф-4", + "inn": "7100000004", + "ogrn": "1020000000004", + "kpp": "770100004", + "okpo": "11110004", + "revenue_rsbu": 1000000.0, + "net_profit_rsbu": 50000.0, + }, + ), + ( + "f5", + FormF5Parser, + {"report_year": 2026, "report_quarter": 1}, + 605, + { + "organization_name": "Тестовая организация Ф-5", + "inn": "7100000005", + "ogrn": "1020000000005", + "kpp": "770100005", + "okpo": "11110005", + "equipment_id": "EQ-001", + "equipment_name": "Токарный станок", + }, + ), + ( + "f6", + FormF6Parser, + {"report_year": 2026, "report_quarter": 1}, + 606, + { + "organization_name": "Тестовая организация Ф-6", + "inn": "7100000006", + "ogrn": "1020000000006", + "kpp": "770100006", + "okpo": "11110006", + "row_code": "001", + "category": "Металлорежущее", + "total_equipment": 100, + }, + ), + ) + + def test_create_record_creates_missing_organization_from_row_identifiers(self): + for form_name, parser_class, parser_kwargs, batch_id, row_data in self.CASES: + with self.subTest(form=form_name): + parser = parser_class(**parser_kwargs) + parser.load_batch = batch_id + + self.assertFalse( + Organization.objects.filter(inn=row_data["inn"]).exists() + ) + + record = parser.create_record(row_data) + + organization = Organization.objects.get(inn=row_data["inn"]) + self.assertEqual(record.organization_id, organization.id) + self.assertEqual(organization.name, row_data["organization_name"]) + self.assertEqual(organization.ogrn, row_data["ogrn"]) + self.assertEqual(organization.kpp, row_data["kpp"]) + self.assertEqual(organization.okpo, row_data["okpo"]) diff --git a/tests/apps/forms/test_upload_contracts_api.py b/tests/apps/forms/test_upload_contracts_api.py index cbb4d27..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) @@ -193,19 +204,18 @@ class FormUploadContractsApiTest(APITestCase): def test_upload_processing_error_contract(self): for _, case in self.CASES.items(): - with self.subTest(form=case["form"]): - with patch( - case["parse_target"], - side_effect=RuntimeError("parse failed"), - ) as parse_mock: - response = self.client.post( - case["url"], - self._build_payload(case["payload"], file_size=256), - format="multipart", - ) + with self.subTest(form=case["form"]), patch( + case["parse_target"], + side_effect=RuntimeError("parse failed"), + ) as parse_mock: + response = self.client.post( + case["url"], + self._build_payload(case["payload"], file_size=256), + format="multipart", + ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["error_code"], "processing_error") - self.assertEqual(response.data["error_message"], "parse failed") - self.assertEqual(response.data["details"], []) - parse_mock.assert_called_once() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error_code"], "processing_error") + self.assertEqual(response.data["error_message"], "parse failed") + self.assertEqual(response.data["details"], []) + parse_mock.assert_called_once() diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index e6dff33..ce4a918 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -5,11 +5,11 @@ from __future__ import annotations from datetime import date from decimal import Decimal -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase +from tests.apps.external_data.factories import FinancialReportFactory from tests.apps.form_1.factories import FormF1RecordFactory from tests.apps.form_2.factories import FormF2RecordFactory from tests.apps.form_3.factories import FormF3RecordFactory @@ -31,20 +31,10 @@ class OrganizationAnalyticsApiTest(APITestCase): financial_reports_available=True, tax_reports_available=True, bankruptcy_messages_found=False, - ) - register = Register.objects.create(name="Реестр госкорпорации Росатом ОПК") - upload = RegisterUpload.objects.create( - registry=register, - actual_date=date(2026, 4, 1), - file_name="analytics.xlsx", - file_hash="analytics-hash", - rows_count=1, - ) - RegistryMembershipPeriod.objects.create( - registry=register, - organization=self.organization, - started_at=date(2026, 4, 1), - started_by_upload=upload, + gk_code="rosatom", + gk_name="Госкорпорация Росатом", + opk_registry_membership=True, + ropk_razdel_name="Росатом ОПК", ) FormF1RecordFactory.create( @@ -188,9 +178,18 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.data["insurance_contributions"]["amount"], 302000) self.assertEqual(response.data["organization_id"], str(self.organization.id)) self.assertEqual(response.data["report_period"], {"year": 2026, "quarter": 1}) - self.assertEqual(set(response.data["revenue"]), {"amount", "previous_amount", "delta_percent"}) - self.assertEqual(set(response.data["net_profit"]), {"amount", "previous_amount", "delta_percent"}) - self.assertEqual(set(response.data["taxes_paid"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual( + set(response.data["revenue"]), + {"amount", "previous_amount", "delta_percent"}, + ) + self.assertEqual( + set(response.data["net_profit"]), + {"amount", "previous_amount", "delta_percent"}, + ) + self.assertEqual( + set(response.data["taxes_paid"]), + {"amount", "previous_amount", "delta_percent"}, + ) self.assertEqual( set(response.data["insurance_contributions"]), {"amount", "previous_amount", "delta_percent"}, @@ -228,7 +227,22 @@ class OrganizationAnalyticsApiTest(APITestCase): set(response.data["ratio_normatives"]), {"ros", "roa", "roe", "ebitda_margin"}, ) - self.assertTrue(all(value is not None for value in response.data["ratio_normatives"].values())) + self.assertTrue( + all( + value is not None + for value in response.data["ratio_normatives"].values() + ) + ) + + def test_economics_uses_latest_available_year_when_requested_range_is_empty(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/economics/" + "?group=efficiency&from_year=2022&to_year=2024" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["periods"], [2026]) + self.assertEqual(response.data["series"][0]["points"][0]["period"], 2026) def test_personnel_contract(self): personnel_response = self.client.get( @@ -236,7 +250,9 @@ class OrganizationAnalyticsApiTest(APITestCase): "?report_year=2026&history_years=2" ) self.assertEqual(personnel_response.status_code, status.HTTP_200_OK) - self.assertEqual(personnel_response.data["organization_id"], str(self.organization.id)) + self.assertEqual( + personnel_response.data["organization_id"], str(self.organization.id) + ) self.assertEqual(personnel_response.data["report_year"], 2026) self.assertEqual( personnel_response.data["headcount"]["average_employees"], @@ -255,6 +271,29 @@ class OrganizationAnalyticsApiTest(APITestCase): ) self.assertIn("employees_count", personnel_response.data["age_distribution"][0]) + def test_yearly_analytics_use_latest_available_year_when_requested_year_is_empty( + self, + ): + personnel_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" + "?report_year=2024&history_years=2" + ) + equipment_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" + "?report_year=2024" + ) + products_response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/products/" + "?frequency=quarterly&price_mode=actual&report_year=2024" + ) + + self.assertEqual(personnel_response.status_code, status.HTTP_200_OK) + self.assertEqual(equipment_response.status_code, status.HTTP_200_OK) + self.assertEqual(products_response.status_code, status.HTTP_200_OK) + self.assertEqual(personnel_response.data["report_year"], 2026) + self.assertEqual(equipment_response.data["report_year"], 2026) + self.assertEqual(products_response.data["report_year"], 2026) + def test_equipment_contract(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" @@ -312,15 +351,25 @@ class OrganizationAnalyticsApiTest(APITestCase): "?frequency=quarterly&price_mode=actual&report_year=2026" ) self.assertEqual(products_response.status_code, status.HTTP_200_OK) - self.assertEqual(products_response.data["organization_id"], str(self.organization.id)) + self.assertEqual( + products_response.data["organization_id"], str(self.organization.id) + ) self.assertEqual(products_response.data["report_year"], 2026) self.assertEqual(products_response.data["frequency"], "quarterly") self.assertEqual(products_response.data["price_mode"], "actual") - self.assertEqual(products_response.data["summary"]["military_output_amount"], 11000000) - self.assertEqual(products_response.data["summary"]["civilian_output_amount"], 7000000) - self.assertEqual(products_response.data["summary"]["hightech_output_amount"], 1500000) + self.assertEqual( + products_response.data["summary"]["military_output_amount"], 11000000 + ) + self.assertEqual( + products_response.data["summary"]["civilian_output_amount"], 7000000 + ) + self.assertEqual( + products_response.data["summary"]["hightech_output_amount"], 1500000 + ) self.assertEqual(products_response.data["summary"]["rd_volume_amount"], 900000) - self.assertEqual(products_response.data["summary"]["shipped_goods_amount"], 18000000) + self.assertEqual( + products_response.data["summary"]["shipped_goods_amount"], 18000000 + ) self.assertEqual(len(products_response.data["production_series"]), 1) self.assertEqual(len(products_response.data["sales_series"]), 1) self.assertEqual(len(products_response.data["rd_volume_series"]), 1) @@ -381,7 +430,9 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(monthly_response.status_code, status.HTTP_200_OK) self.assertEqual(monthly_response.data["frequency"], "monthly") self.assertEqual(len(monthly_response.data["production_series"]), 6) - self.assertEqual(monthly_response.data["production_series"][0]["period"], "2026-01") + self.assertEqual( + monthly_response.data["production_series"][0]["period"], "2026-01" + ) self.assertEqual( monthly_response.data["production_series"][0]["military_output_amount"], 3666666, @@ -429,6 +480,23 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertIn("risk_level", response.data) self.assertIn("updated_at", response.data) + def test_risk_profile_uses_available_related_report_data(self): + organization = OrganizationFactory.create( + financial_reports_available=False, + tax_reports_available=False, + ) + FinancialReportFactory.create(organization=organization, status="success") + FormF2RecordFactory.create(organization=organization, income_tax="1000.00") + + response = self.client.get( + f"/api/v1/organizations/{organization.id}/risk-profile/" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["financial_reports_available"]) + self.assertTrue(response.data["tax_reports_available"]) + self.assertEqual(response.data["risk_level"], "low") + def test_dashboard_endpoint(self): response = self.client.get( "/api/v1/analytics/dashboard/?corporation_scope=rosatom" @@ -452,6 +520,95 @@ class OrganizationAnalyticsApiTest(APITestCase): ) self.assertIn("growth_percent", response.data["headcount_growth_by_cluster"][0]) + def test_dashboard_endpoint_accepts_roskosmos_alias(self): + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["corporation_scope"], "roscosmos") + + def test_dashboard_endpoint_groups_blank_clusters_by_corporation_scope(self): + OrganizationFactory.create( + cluster="", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["distribution_by_cluster"][0]["cluster"], "roscosmos" + ) + self.assertEqual( + response.data["distribution_by_cluster"][0]["cluster_label"], + "Госкорпорация «Роскосмос»", + ) + + def test_dashboard_endpoint_counts_goz_organizations_when_executors_are_missing( + self, + ): + OrganizationFactory.create( + cluster="", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + goz_participation=True, + ) + OrganizationFactory.create( + cluster="", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + goz_participation=False, + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["executors_by_cluster"][0]["cluster"], "roscosmos" + ) + self.assertEqual(response.data["executors_by_cluster"][0]["executors_count"], 1) + + def test_dashboard_endpoint_uses_latest_available_f3_years_for_growth(self): + organization = OrganizationFactory.create( + cluster="space", + executors_count=0, + gk_code="1", + gk_name='Госкорпорация "Роскосмос"', + ) + FormF3RecordFactory.create( + organization=organization, + report_year=2023, + report_quarter=4, + avg_employees=100, + ) + FormF3RecordFactory.create( + organization=organization, + report_year=2024, + report_quarter=4, + avg_employees=150, + ) + + response = self.client.get( + "/api/v1/analytics/dashboard/?corporation_scope=roskosmos" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["headcount_growth_by_cluster"][0]["cluster"], "space" + ) + self.assertEqual( + response.data["headcount_growth_by_cluster"][0]["growth_percent"], 50.0 + ) + def test_analytics_query_validation(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/economics/" diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index bab26b2..fcfa9bd 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -2,13 +2,12 @@ from __future__ import annotations -from datetime import date - -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase +from tests.apps.external_data.factories import FinancialReportFactory +from tests.apps.form_2.factories import FormF2RecordFactory from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -22,41 +21,11 @@ class OrganizationApiTest(APITestCase): self.client.force_authenticate(self.user) def test_list_includes_only_active_registry_names(self): - organization = OrganizationFactory.create( + OrganizationFactory.create( name="АО Альфа", short_name="АО «Альфа»", organization_type="ao", - ) - active_register = Register.objects.create(name="Реестр ОПК") - closed_register = Register.objects.create(name="Архивный реестр") - active_upload = RegisterUpload.objects.create( - registry=active_register, - actual_date=date(2026, 3, 1), - file_name="active.xlsx", - file_hash="active-api-hash", - rows_count=1, - ) - closed_upload = RegisterUpload.objects.create( - registry=closed_register, - actual_date=date(2026, 2, 1), - file_name="closed.xlsx", - file_hash="closed-api-hash", - rows_count=1, - ) - - RegistryMembershipPeriod.objects.create( - registry=active_register, - organization=organization, - started_at=date(2026, 3, 1), - started_by_upload=active_upload, - ) - RegistryMembershipPeriod.objects.create( - registry=closed_register, - organization=organization, - started_at=date(2026, 2, 1), - ended_at=date(2026, 3, 1), - started_by_upload=closed_upload, - ended_by_upload=closed_upload, + opk_registry_membership=True, ) response = self.client.get("/api/v1/organizations/") @@ -77,24 +46,13 @@ class OrganizationApiTest(APITestCase): def test_detail_includes_active_registries(self): organization = OrganizationFactory.create( short_name="АО «Бета»", - general_director_name="Иванов Иван Иванович", - general_director_inn="123456789012", + general_director="Иванов Иван Иванович", + general_director_tax_id="123456789012", financial_reports_available=True, tax_reports_available=True, - ) - register = Register.objects.create(name="Реестр Роскосмос") - upload = RegisterUpload.objects.create( - registry=register, - actual_date=date(2026, 3, 1), - file_name="detail.xlsx", - file_hash="detail-api-hash", - rows_count=1, - ) - RegistryMembershipPeriod.objects.create( - registry=register, - organization=organization, - started_at=date(2026, 3, 1), - started_by_upload=upload, + gk_code="2", + gk_name="Роскосмос", + in_korp_name="Организация внутри ГК", ) response = self.client.get(f"/api/v1/organizations/{organization.id}/") @@ -102,11 +60,11 @@ class OrganizationApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( response.data["summary"]["active_registry_names"], - ["Реестр Роскосмос"], + ["Роскосмос"], ) self.assertEqual( response.data["active_registries"], - [{"id": str(register.id), "name": "Реестр Роскосмос"}], + [{"id": "2", "name": "Роскосмос"}], ) self.assertEqual(response.data["corporation_scope"], "roscosmos") self.assertEqual( @@ -115,76 +73,29 @@ class OrganizationApiTest(APITestCase): ) self.assertEqual(response.data["registry_category"], "other") self.assertEqual(response.data["registry_category_label"], "Прочие") - self.assertEqual( - response.data["general_director"]["full_name"], - "Иванов Иван Иванович", - ) + self.assertEqual(response.data["general_director"], "Иванов Иван Иванович") + self.assertEqual(response.data["general_director_tax_id"], "123456789012") + self.assertEqual(response.data["gk_name"], "Роскосмос") + self.assertEqual(response.data["in_korp_name"], "Организация внутри ГК") - def test_registry_filter_uses_only_active_memberships(self): - active_organization = OrganizationFactory.create(name="АО Актив") - closed_organization = OrganizationFactory.create(name="АО Закрыт") - register = Register.objects.create(name="Реестр Росатом") - upload = RegisterUpload.objects.create( - registry=register, - actual_date=date(2026, 3, 1), - file_name="filter.xlsx", - file_hash="filter-api-hash", - rows_count=2, + def test_detail_summary_uses_available_related_report_data(self): + organization = OrganizationFactory.create( + financial_reports_available=False, + tax_reports_available=False, ) + FinancialReportFactory.create(organization=organization, status="success") + FormF2RecordFactory.create(organization=organization, income_tax="1200.00") - RegistryMembershipPeriod.objects.create( - registry=register, - organization=active_organization, - started_at=date(2026, 3, 1), - started_by_upload=upload, - ) - RegistryMembershipPeriod.objects.create( - registry=register, - organization=closed_organization, - started_at=date(2026, 2, 1), - ended_at=date(2026, 3, 1), - started_by_upload=upload, - ended_by_upload=upload, - ) - - response = self.client.get(f"/api/v1/organizations/?registry={register.id}") + response = self.client.get(f"/api/v1/organizations/{organization.id}/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["results"]), 1) - self.assertEqual(response.data["results"][0]["id"], str(active_organization.id)) + self.assertTrue(response.data["summary"]["financial_reports_available"]) + self.assertTrue(response.data["summary"]["tax_reports_available"]) - def test_corporation_scope_filter_uses_active_registers(self): - rosatom_org = OrganizationFactory.create(name="АО Росатом") - roscosmos_org = OrganizationFactory.create(name="АО Роскосмос") - rosatom_register = Register.objects.create(name="Реестр госкорпорации Росатом") - roscosmos_register = Register.objects.create( - name="Реестр госкорпорации Роскосмос" - ) - rosatom_upload = RegisterUpload.objects.create( - registry=rosatom_register, - actual_date=date(2026, 4, 1), - file_name="rosatom.xlsx", - file_hash="rosatom-hash", - rows_count=1, - ) - roscosmos_upload = RegisterUpload.objects.create( - registry=roscosmos_register, - actual_date=date(2026, 4, 1), - file_name="roscosmos.xlsx", - file_hash="roscosmos-hash", - rows_count=1, - ) - RegistryMembershipPeriod.objects.create( - registry=rosatom_register, - organization=rosatom_org, - started_at=date(2026, 4, 1), - started_by_upload=rosatom_upload, - ) - RegistryMembershipPeriod.objects.create( - registry=roscosmos_register, - organization=roscosmos_org, - started_at=date(2026, 4, 1), - started_by_upload=roscosmos_upload, + def test_corporation_scope_filter_uses_organization_fields(self): + rosatom_org = OrganizationFactory.create(name="АО Росатом", gk_name="Росатом") + roscosmos_org = OrganizationFactory.create( + name="АО Роскосмос", gk_name="Роскосмос" ) response = self.client.get("/api/v1/organizations/?corporation_scope=rosatom") @@ -193,52 +104,44 @@ class OrganizationApiTest(APITestCase): self.assertEqual(len(response.data["results"]), 1) self.assertEqual(response.data["results"][0]["id"], str(rosatom_org.id)) + for scope_code in ("roscosmos", "roskosmos"): + response = self.client.get( + f"/api/v1/organizations/?corporation_scope={scope_code}" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], str(roscosmos_org.id)) + self.assertEqual( + response.data["results"][0]["corporation_scope"], "roscosmos" + ) + def test_registry_category_filters_support_snake_and_camel_case(self): - opk_org = OrganizationFactory.create(name="АО ОПК") - goz_org = OrganizationFactory.create(name="АО ГОЗ", in_275_fz_registry=True) + opk_org = OrganizationFactory.create( + name="АО ОПК", + opk_registry_membership=True, + ropk_razdel_name="Раздел ОПК", + ) + goz_org = OrganizationFactory.create(name="АО ГОЗ", goz_participation=True) other_org = OrganizationFactory.create(name="АО Прочие") - opk_register = Register.objects.create(name="Реестр предприятий ОПК") - goz_register = Register.objects.create(name="Реестр госкорпорации Росатом ГОЗ") - upload = RegisterUpload.objects.create( - registry=opk_register, - actual_date=date(2026, 4, 1), - file_name="registry-category.xlsx", - file_hash="registry-category-hash", - rows_count=2, - ) - goz_upload = RegisterUpload.objects.create( - registry=goz_register, - actual_date=date(2026, 4, 1), - file_name="registry-category-goz.xlsx", - file_hash="registry-category-goz-hash", - rows_count=1, - ) - - RegistryMembershipPeriod.objects.create( - registry=opk_register, - organization=opk_org, - started_at=date(2026, 4, 1), - started_by_upload=upload, - ) - RegistryMembershipPeriod.objects.create( - registry=goz_register, - organization=goz_org, - started_at=date(2026, 4, 1), - started_by_upload=goz_upload, - ) - response = self.client.get("/api/v1/organizations/?registry_category=goz") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual([item["id"] for item in response.data["results"]], [str(goz_org.id)]) + self.assertEqual( + [item["id"] for item in response.data["results"]], [str(goz_org.id)] + ) response = self.client.get("/api/v1/organizations/?registryCategory=opk") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual([item["id"] for item in response.data["results"]], [str(opk_org.id)]) + self.assertEqual( + [item["id"] for item in response.data["results"]], [str(opk_org.id)] + ) response = self.client.get("/api/v1/organizations/?registry_category=other") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn(str(other_org.id), [item["id"] for item in response.data["results"]]) + self.assertIn( + str(other_org.id), [item["id"] for item in response.data["results"]] + ) class OrganizationDictionaryApiTest(APITestCase): diff --git a/tests/apps/organization/test_models.py b/tests/apps/organization/test_models.py index 3906916..2972c6c 100644 --- a/tests/apps/organization/test_models.py +++ b/tests/apps/organization/test_models.py @@ -1,8 +1,5 @@ """Tests for Organization model.""" -from datetime import date - -from apps.registers.models import Register, RegisterUpload, RegistryMembershipPeriod from django.test import TestCase from .factories import OrganizationFactory @@ -54,73 +51,25 @@ class OrganizationModelTest(TestCase): """Test OGRN has db_index.""" self.assertTrue(self.org._meta.get_field("ogrn").db_index) - def test_active_registry_names_include_only_current_memberships(self): - """Test only active registry memberships are shown on organization.""" - active_register = Register.objects.create(name="Активный реестр") - inactive_register = Register.objects.create(name="Неактуальный реестр") - active_upload = RegisterUpload.objects.create( - registry=active_register, - actual_date=date(2026, 3, 1), - file_name="active.xlsx", - file_hash="active-hash", - rows_count=1, + def test_active_registry_names_are_derived_from_organization_fields(self): + """Test registry display names are derived from organization directory fields.""" + self.org.gk_name = "Росатом" + self.org.goz_participation = True + self.org.opk_registry_membership = True + self.org.ropk_razdel_name = "Росатом ОПК" + + self.assertEqual( + self.org.get_active_registry_names(), + ["Росатом", "Участие в ГОЗ", "Росатом ОПК"], ) - inactive_upload = RegisterUpload.objects.create( - registry=inactive_register, - actual_date=date(2026, 2, 1), - file_name="inactive.xlsx", - file_hash="inactive-hash", - rows_count=1, + self.assertEqual( + self.org.active_registry_names_display(), + "Росатом, Участие в ГОЗ, Росатом ОПК", ) - RegistryMembershipPeriod.objects.create( - registry=active_register, - organization=self.org, - started_at=date(2026, 3, 1), - started_by_upload=active_upload, - ) - RegistryMembershipPeriod.objects.create( - registry=inactive_register, - organization=self.org, - started_at=date(2026, 2, 1), - ended_at=date(2026, 3, 1), - started_by_upload=inactive_upload, - ended_by_upload=inactive_upload, - ) - - self.assertEqual(self.org.get_active_registry_names(), ["Активный реестр"]) - self.assertEqual(self.org.active_registry_names_display(), "Активный реестр") - - def test_corporation_scopes_are_derived_from_active_registers(self): - rosatom_register = Register.objects.create(name="Реестр госкорпорации Росатом") - opk_register = Register.objects.create(name="Реестр предприятий ОПК") - upload = RegisterUpload.objects.create( - registry=rosatom_register, - actual_date=date(2026, 4, 1), - file_name="scopes.xlsx", - file_hash="scopes-hash", - rows_count=2, - ) - opk_upload = RegisterUpload.objects.create( - registry=opk_register, - actual_date=date(2026, 4, 1), - file_name="scopes-opk.xlsx", - file_hash="scopes-opk-hash", - rows_count=2, - ) - - RegistryMembershipPeriod.objects.create( - registry=rosatom_register, - organization=self.org, - started_at=date(2026, 4, 1), - started_by_upload=upload, - ) - RegistryMembershipPeriod.objects.create( - registry=opk_register, - organization=self.org, - started_at=date(2026, 4, 1), - started_by_upload=opk_upload, - ) + def test_corporation_scopes_are_derived_from_organization_fields(self): + self.org.gk_name = "Росатом" + self.org.opk_registry_membership = True self.assertEqual(self.org.get_corporation_scopes(), ["rosatom", "opk"]) self.assertEqual( 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")