diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index 499d455..473e95e 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -187,6 +187,16 @@ CORE_PROFILE_INDUSTRIAL_SOURCES = { ParserLoadLog.Source.INDUSTRIAL_PRODUCTS, ParserLoadLog.Source.MANUFACTURES, } +REGISTRY_ORGANIZATION_SEARCH_FIELDS = [ + "registry_organization__pn_name", + "registry_organization_inn_text", + "registry_organization_ogrn_text", + "registry_organization__mn_okpo", +] +REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION = ( + "Поиск также включает название, ИНН, ОГРН и ОКПО связанной организации " + "из реестров." +) EXISTING_TASK_PARAMS = { "industrial": {"proxies", "requested_by_id"}, "manufactures": {"proxies", "requested_by_id"}, @@ -205,6 +215,30 @@ EXISTING_TASK_PARAMS = { }, "fns_financial": {"requested_by_id"}, } + + +def _with_registry_organization_search_annotations(queryset): + return queryset.annotate( + registry_organization_inn_text=Cast( + "registry_organization__mn_inn", + output_field=CharField(), + ), + registry_organization_ogrn_text=Cast( + "registry_organization__mn_ogrn", + output_field=CharField(), + ), + ) + + +def _registry_organization_search_q(search: str) -> Q: + return ( + Q(registry_organization__pn_name__icontains=search) + | Q(registry_organization_inn_text__icontains=search) + | Q(registry_organization_ogrn_text__icontains=search) + | Q(registry_organization__mn_okpo__icontains=search) + ) + + TRUDVSEM_PARAMS = { "limit", "offset", @@ -580,7 +614,9 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet): Только чтение - добавление через парсер/админку. """ - queryset = IndustrialCertificateRecord.objects.all().order_by("-created_at") + queryset = _with_registry_organization_search_annotations( + IndustrialCertificateRecord.objects.all() + ).order_by("-created_at") serializer_class = IndustrialCertificateSerializer permission_classes = [IsAuthenticated] filterset_fields = [ @@ -590,7 +626,13 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet): "load_batch", "registry_organization", ] - search_fields = ["organisation_name", "certificate_number", "inn", "ogrn"] + search_fields = [ + "organisation_name", + "certificate_number", + "inn", + "ogrn", + *REGISTRY_ORGANIZATION_SEARCH_FIELDS, + ] @swagger_auto_schema( tags=[MINPROMTORG_TAG], @@ -598,7 +640,8 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet): operation_description=( "Возвращает список сертификатов промышленного производства.\n" "Поддерживает фильтрацию по: inn, ogrn, certificate_number, load_batch.\n" - "Поддерживает поиск по: organisation_name, certificate_number, inn, ogrn." + "Поддерживает поиск по: organisation_name, certificate_number, inn, ogrn.\n" + f"{REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION}" ), responses={ 200: IndustrialCertificateSerializer(many=True), @@ -634,11 +677,19 @@ class ManufacturerViewSet(ReadOnlyModelViewSet): Только чтение - добавление через парсер/админку. """ - queryset = ManufacturerRecord.objects.all().order_by("-created_at") + queryset = _with_registry_organization_search_annotations( + ManufacturerRecord.objects.all() + ).order_by("-created_at") serializer_class = ManufacturerSerializer permission_classes = [IsAuthenticated] filterset_fields = ["inn", "ogrn", "load_batch", "registry_organization"] - search_fields = ["full_legal_name", "inn", "ogrn", "address"] + search_fields = [ + "full_legal_name", + "inn", + "ogrn", + "address", + *REGISTRY_ORGANIZATION_SEARCH_FIELDS, + ] @swagger_auto_schema( tags=[MINPROMTORG_TAG], @@ -646,7 +697,8 @@ class ManufacturerViewSet(ReadOnlyModelViewSet): operation_description=( "Возвращает список производителей из реестра Минпромторга.\n" "Поддерживает фильтрацию по: inn, ogrn, load_batch.\n" - "Поддерживает поиск по: full_legal_name, inn, ogrn, address." + "Поддерживает поиск по: full_legal_name, inn, ogrn, address.\n" + f"{REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION}" ), responses={ 200: ManufacturerSerializer(many=True), @@ -677,7 +729,9 @@ class IndustrialProductViewSet(ReadOnlyModelViewSet): Только чтение - добавление через парсер/админку. """ - queryset = IndustrialProductRecord.objects.all().order_by("-created_at") + queryset = _with_registry_organization_search_annotations( + IndustrialProductRecord.objects.all() + ).order_by("-created_at") serializer_class = IndustrialProductSerializer permission_classes = [IsAuthenticated] filterset_fields = [ @@ -694,6 +748,7 @@ class IndustrialProductViewSet(ReadOnlyModelViewSet): "registry_number", "inn", "ogrn", + *REGISTRY_ORGANIZATION_SEARCH_FIELDS, ] @swagger_auto_schema( @@ -704,7 +759,8 @@ class IndustrialProductViewSet(ReadOnlyModelViewSet): "Минпромторга.\n" "Поддерживает фильтрацию по: inn, ogrn, registry_number, load_batch.\n" "Поддерживает поиск по: full_organisation_name, product_name, " - "product_model, registry_number, inn, ogrn." + "product_model, registry_number, inn, ogrn.\n" + f"{REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION}" ), responses={ 200: IndustrialProductSerializer(many=True), @@ -743,7 +799,9 @@ class InspectionViewSet(ReadOnlyModelViewSet): Только чтение - добавление через парсер/админку. """ - queryset = InspectionRecord.objects.all().order_by("-created_at") + queryset = _with_registry_organization_search_annotations( + InspectionRecord.objects.all() + ).order_by("-created_at") serializer_class = InspectionSerializer permission_classes = [IsAuthenticated] filterset_fields = [ @@ -762,6 +820,7 @@ class InspectionViewSet(ReadOnlyModelViewSet): "inn", "ogrn", "control_authority", + *REGISTRY_ORGANIZATION_SEARCH_FIELDS, ] @swagger_auto_schema( @@ -772,7 +831,8 @@ class InspectionViewSet(ReadOnlyModelViewSet): "Поддерживает фильтрацию по: inn, ogrn, registration_number, " "is_federal_law_248, data_year, data_month, load_batch.\n" "Поддерживает поиск по: organisation_name, registration_number, " - "inn, ogrn, control_authority." + "inn, ogrn, control_authority.\n" + f"{REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION}" ), responses={ 200: InspectionSerializer(many=True), @@ -809,7 +869,9 @@ class ProcurementViewSet(ReadOnlyModelViewSet): Только чтение - добавление через парсер/админку. """ - queryset = ProcurementRecord.objects.all().order_by("-created_at") + queryset = _with_registry_organization_search_annotations( + ProcurementRecord.objects.all() + ).order_by("-created_at") serializer_class = ProcurementSerializer permission_classes = [IsAuthenticated] filterset_fields = [ @@ -830,6 +892,7 @@ class ProcurementViewSet(ReadOnlyModelViewSet): "customer_name", "customer_inn", "customer_ogrn", + *REGISTRY_ORGANIZATION_SEARCH_FIELDS, ] @swagger_auto_schema( @@ -841,7 +904,8 @@ class ProcurementViewSet(ReadOnlyModelViewSet): "purchase_number, law_type, status, region_code, " "data_year, data_month, load_batch.\n" "Поддерживает поиск по: purchase_name, purchase_number, " - "customer_name, customer_inn, customer_ogrn." + "customer_name, customer_inn, customer_ogrn.\n" + f"{REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION}" ), responses={ 200: ProcurementSerializer(many=True), @@ -877,9 +941,9 @@ class FinancialReportViewSet(ReadOnlyModelViewSet): Только чтение - добавление через загрузку файлов. """ - queryset = FinancialReport.objects.annotate(lines_count=Count("lines")).order_by( - "-created_at" - ) + queryset = _with_registry_organization_search_annotations( + FinancialReport.objects.annotate(lines_count=Count("lines")) + ).order_by("-created_at") permission_classes = [IsAuthenticated] filterset_fields = [ "ogrn", @@ -888,7 +952,12 @@ class FinancialReportViewSet(ReadOnlyModelViewSet): "load_batch", "registry_organization", ] - search_fields = ["ogrn", "external_id", "file_name"] + search_fields = [ + "ogrn", + "external_id", + "file_name", + *REGISTRY_ORGANIZATION_SEARCH_FIELDS, + ] def get_queryset(self): queryset = super().get_queryset() @@ -915,7 +984,8 @@ class FinancialReportViewSet(ReadOnlyModelViewSet): "Возвращает список финансовых отчетов ФНС.\n" "Поддерживает фильтрацию по: ogrn, external_id, status, " "source, load_batch.\n" - "Поддерживает поиск по: ogrn, external_id, file_name." + "Поддерживает поиск по: ogrn, external_id, file_name.\n" + f"{REGISTRY_ORGANIZATION_SEARCH_DESCRIPTION}" ), responses={ 200: FinancialReportSerializer(many=True), @@ -2206,17 +2276,8 @@ def _generic_search_q(search: str) -> Q: def _apply_native_search(queryset, source: str, search: str): - if source != ParserLoadLog.Source.FNS_REPORTS: - return queryset.filter(_native_search_q(source, search)) - - return queryset.annotate( - registry_organization_inn_text=Cast( - "registry_organization__mn_inn", - output_field=CharField(), - ), - ).filter( - _native_search_q(source, search) - | Q(registry_organization_inn_text__icontains=search) + return _with_registry_organization_search_annotations(queryset).filter( + _native_search_q(source, search) | _registry_organization_search_q(search) ) @@ -2249,7 +2310,9 @@ def _result_sources_for_request(descriptor, params: dict) -> set[str]: def _filter_native_result_queryset(source: str, params: dict, sources: set[str]): - queryset = NATIVE_RECORD_MODELS[source].objects.all() + queryset = _with_registry_organization_search_annotations( + NATIVE_RECORD_MODELS[source].objects.all() + ) if source == ParserLoadLog.Source.FNS_REPORTS: queryset = queryset.select_related("registry_organization") if not sources: @@ -2278,7 +2341,9 @@ def _filter_generic_result_queryset( *, route_source: str, ): - queryset = GenericParserRecord.objects.filter(source__in=sources) + queryset = _with_registry_organization_search_annotations( + GenericParserRecord.objects.filter(source__in=sources) + ) if not sources: queryset = queryset.none() requested_source = params.get("source") @@ -2300,7 +2365,10 @@ def _filter_generic_result_queryset( if params.get("record_date"): queryset = queryset.filter(record_date=params["record_date"]) if params.get("search"): - queryset = queryset.filter(_generic_search_q(params["search"])) + search = params["search"] + queryset = queryset.filter( + _generic_search_q(search) | _registry_organization_search_q(search) + ) field_map = { "id": "id", "load_batch": "load_batch", diff --git a/tests/apps/parsers/test_views.py b/tests/apps/parsers/test_views.py index 857a9bc..0723904 100644 --- a/tests/apps/parsers/test_views.py +++ b/tests/apps/parsers/test_views.py @@ -769,6 +769,166 @@ class ParsersViewSetTest(APITestCase): self.assertEqual(unified_response.data["data"][0]["id"], report.id) self.assertEqual(unified_response.data["data"][0]["title"], report.file_name) + def test_financial_reports_list_searches_registry_organization_name(self): + organization = RegisterOrganizationFactory( + pn_name="тест-организация-фнс", + mn_ogrn=1111111111111, + mn_inn=1111111111, + ) + matching_report = FinancialReport.objects.create( + external_id="fns-test-organization", + ogrn=str(organization.mn_ogrn), + registry_organization=organization, + file_name=f"fin_fns-test-organization_{organization.mn_ogrn}.xlsx", + file_hash=fake.sha256(raw_output=False), + load_batch=1, + status=FinancialReport.Status.SUCCESS, + source=FinancialReport.SourceType.API, + ) + FinancialReport.objects.create( + external_id="fns-other-organization", + ogrn="1027700000002", + file_name="fin_fns-other-organization_1027700000002.xlsx", + file_hash=fake.sha256(raw_output=False), + load_batch=1, + status=FinancialReport.Status.SUCCESS, + source=FinancialReport.SourceType.API, + ) + self.client.force_authenticate(self.user) + + response = self.client.get( + reverse("api_v1:fns:fns-reports-list"), + {"search": "тест-организация", "page_size": 20}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["data"]], + [matching_report.id], + ) + + def test_native_source_lists_search_registry_organization_identity_fields(self): + organization = RegisterOrganizationFactory( + pn_name="тест-организация-источники", + mn_ogrn=1111111111111, + mn_inn=1111111111, + mn_okpo="11111111", + ) + certificate = IndustrialCertificateRecordFactory( + registry_organization=organization, + inn="9000000001", + ogrn="1090000000001", + ) + manufacturer = ManufacturerRecordFactory( + registry_organization=organization, + inn="9000000002", + ogrn="1090000000002", + ) + product = IndustrialProductRecordFactory( + registry_organization=organization, + inn="9000000003", + ogrn="1090000000003", + ) + inspection = InspectionRecordFactory( + registry_organization=organization, + inn="9000000004", + ogrn="1090000000004", + ) + procurement = ProcurementRecord.objects.create( + load_batch=1, + purchase_number="TST-PURCHASE-IDENTITY", + purchase_name="Поставка тестовой продукции", + customer_inn="9000000005", + customer_kpp="900000001", + customer_ogrn="1090000000005", + customer_name='ООО "Не тестовая организация"', + max_price="1000.00", + status="active", + law_type="44-FZ", + href="https://zakupki.example.test/purchase", + region_code="77", + registry_organization=organization, + ) + financial_report = FinancialReport.objects.create( + external_id="fns-native-source-identity", + ogrn="1090000000006", + registry_organization=organization, + file_name="fin_native_source_identity_1090000000006.xlsx", + file_hash=fake.sha256(raw_output=False), + load_batch=1, + status=FinancialReport.Status.SUCCESS, + source=FinancialReport.SourceType.API, + ) + self.client.force_authenticate(self.user) + + cases = [ + (reverse("api_v1:minpromtorg:certificates-list"), certificate.id), + (reverse("api_v1:minpromtorg:manufacturers-list"), manufacturer.id), + (reverse("api_v1:minpromtorg:industrial-products-list"), product.id), + (reverse("api_v1:proverki:inspections-list"), inspection.id), + (reverse("api_v1:zakupki:procurements-list"), procurement.id), + (reverse("api_v1:fns:fns-reports-list"), financial_report.id), + ] + + for url, expected_id in cases: + with self.subTest(url=url): + response = self.client.get( + url, + {"search": organization.mn_okpo, "page_size": 20}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["data"]], + [expected_id], + ) + + def test_generic_source_results_search_registry_organization_identity_fields(self): + organization = RegisterOrganizationFactory( + pn_name="тест-организация-generic", + mn_ogrn=2222222222222, + mn_inn=2222222222, + mn_okpo="22222222", + ) + matching_record = GenericParserRecord.objects.create( + load_batch=1, + source=ParserLoadLog.Source.FAS_GOZ, + external_id="goz-linked-organization", + inn="9000000010", + ogrn="1090000000010", + organisation_name='ООО "Другое имя"', + title="Уклонение от ГОЗ", + registry_organization=organization, + ) + GenericParserRecord.objects.create( + load_batch=1, + source=ParserLoadLog.Source.FAS_GOZ, + external_id="goz-unlinked-organization", + inn="9000000011", + ogrn="1090000000011", + organisation_name='ООО "Не подходит"', + title="Уклонение от ГОЗ", + ) + self.client.force_authenticate(self.user) + + for search_query in ( + organization.pn_name, + str(organization.mn_inn), + str(organization.mn_ogrn), + organization.mn_okpo, + ): + with self.subTest(search_query=search_query): + response = self.client.get( + "/api/v1/fas/goz/", + {"search": search_query, "page_size": 20}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["data"]], + [matching_record.id], + ) + def test_fns_financial_results_searches_and_orders_by_registry_organization(self): alpha_org = RegisterOrganizationFactory( pn_name='alpha "ФНС"',