diff --git a/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py b/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py index 20963a00e0..08407ea77d 100644 --- a/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py +++ b/backend/kesaseteli/applications/tests/test_security_with_non_staff_user.py @@ -862,6 +862,7 @@ def test_company_openly_accessible_to_non_staff_user(user_client): street_address="Test street 1", postcode="00100", city="Test city", + organization_type="company", ) set_company_business_id_to_client(company, user_client) @@ -877,6 +878,7 @@ def test_company_openly_accessible_to_non_staff_user(user_client): "name": "Test company", "postcode": "00100", "street_address": "Test street 1", + "organization_type": "company", } diff --git a/backend/kesaseteli/companies/api/v1/serializers.py b/backend/kesaseteli/companies/api/v1/serializers.py index 8312c18b29..f2f353b031 100644 --- a/backend/kesaseteli/companies/api/v1/serializers.py +++ b/backend/kesaseteli/companies/api/v1/serializers.py @@ -10,6 +10,7 @@ class Meta: "id", "name", "business_id", + "organization_type", "company_form", "industry", "street_address", diff --git a/backend/kesaseteli/companies/services.py b/backend/kesaseteli/companies/services.py index b718d8607a..c5fe709c75 100644 --- a/backend/kesaseteli/companies/services.py +++ b/backend/kesaseteli/companies/services.py @@ -9,6 +9,7 @@ from companies.models import Company from companies.tests.data.company_data import DUMMY_ORG_ROLES from shared.oidc.utils import get_organization_roles +from shared.ytj.exceptions import YTJNotFoundError, YTJParseError from shared.ytj.ytj_client import YTJClient LOGGER = logging.getLogger(__name__) @@ -21,9 +22,15 @@ def get_or_create_company_using_company_data( Get or create a company instance using a dict of the company data and attach the ytj_data json for the instance. """ - company, _ = Company.objects.get_or_create( + company, created = Company.objects.get_or_create( **company_data, defaults={"ytj_json": ytj_data} ) + if created: + LOGGER.info( + f"Created company {company.name} ({company.business_id}) using YTJ data" + ) + else: + LOGGER.info(f"Found company {company.name} ({company.business_id})") return company @@ -44,9 +51,13 @@ def get_or_create_company_with_name_and_business_id( business_id: str, ) -> Company: """ - Get or create a company instance using a dict of the company data and - attach the ytj_data json for the instance. + Get or create a company instance using the company name and business_id. + This is used as a fallback when YTJ data is not available. """ + LOGGER.info( + f"Creating/getting company {name} ({business_id}) " + "using fallback method (no YTJ data)" + ) company, _ = Company.objects.get_or_create( name=name, business_id=business_id, @@ -78,6 +89,10 @@ def handle_mock_company(request: HttpRequest): if not company: company = create_mock_company_and_store_org_roles_in_session(request) + LOGGER.info( + f"Using mock company data: {company.name} ({company.business_id}). " + f"Organization roles: {org_roles}" + ) return company @@ -105,7 +120,10 @@ def get_or_create_company_using_organization_roles(request: HttpRequest) -> Comp try: organization_roles = get_organization_roles(request) - except RequestException: + except RequestException as e: + LOGGER.error( + f"Unable to fetch organization roles from eauthorizations API: {e}" + ) raise NotFound( detail="Unable to fetch organization roles from eauthorizations API" ) @@ -117,13 +135,15 @@ def get_or_create_company_using_organization_roles(request: HttpRequest) -> Comp if not company: try: company = get_or_create_company_from_ytj_api(business_id) - except ValueError: + except YTJNotFoundError as e: + LOGGER.warning(f"YTJ API error for business_id {business_id}: {str(e)}") raise NotFound(detail="Could not handle the response from YTJ API") - except RequestException: - LOGGER.warning( - "YTJ API is under heavy load or no company found with the given" - f" business id: {business_id}" - ) + except RequestException as e: + LOGGER.error(f"YTJ API connection error for business_id {business_id}: {e}") + name = organization_roles.get("name") + company = get_or_create_company_with_name_and_business_id(name, business_id) + except YTJParseError as e: + LOGGER.error(f"YTJ API parsing error for business_id {business_id}: {e}") name = organization_roles.get("name") company = get_or_create_company_with_name_and_business_id(name, business_id) diff --git a/backend/kesaseteli/companies/tests/data/company_data.py b/backend/kesaseteli/companies/tests/data/company_data.py index 31e63cc134..a2f3478bbc 100644 --- a/backend/kesaseteli/companies/tests/data/company_data.py +++ b/backend/kesaseteli/companies/tests/data/company_data.py @@ -2,7 +2,7 @@ "id": "8c0c7a56-cb98-4c31-87ca-6f1853253986", "name": "I. Haanpää Oy", "business_id": "0877830-0", - "company_form": "OY", + "company_form": "Osakeyhtiö", "industry": "Taloustavaroiden vähittäiskauppa", "street_address": "Vasaratie 4 A 3", "postcode": "65350", @@ -19,714 +19,85 @@ DUMMY_YTJ_RESPONSE = { - "type": "fi.prh.opendata.tr", - "version": "1", - "totalResults": -1, - "resultsFrom": 0, - "previousResultsUri": None, - "nextResultsUri": None, - "exceptionNoticeUri": None, - "results": [ + "companies": [ { - "businessId": "0877830-0", - "name": "I. Haanpää Oy", - "registrationDate": "1992-01-29", - "companyForm": "OY", - "detailsUri": "http://avoindata.prh.fi/opendata/tr/v1/0877830-0", - "bisDetailsUri": "http://avoindata.prh.fi/opendata/bis/v1/0877830-0", - "language": "FI", - "latestRegistrationDate": "2021-01-12", - "checkDate": None, - "names": [ + "businessId": { + "value": "0877830-0", + "registrationDate": "1992-01-29", + "source": "3", + }, + "companyForms": [ { - "order": 0, - "name": "I. Haanpää Oy", + "type": "OY", + "descriptions": [ + {"languageCode": "1", "description": "Osakeyhtiö"}, + {"languageCode": "2", "description": "Aktiebolag"}, + {"languageCode": "3", "description": "Limited company"}, + ], "registrationDate": "1999-03-31", - "endDate": None, - "language": None, - } - ], - "auxiliaryNames": [], - "companyForms": [{"type": "OY", "registrationDate": "1999-03-31"}], - "addresses": [ - { - "street": "Vasaratie 4 A 3", - "postCode": "65350", - "type": 2, - "city": "Vaasa", - "country": "FI", - "website": None, - "phone": "0400 665254", - "fax": None, - "registrationDate": "2020-07-22", - "endDate": None, + "version": 1, + "source": "1", } ], - "publicNotices": [ - { - "recordNumber": "2020/55730X", - "registrationDate": "2021-01-01", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2020/55730X" - ), - }, - { - "recordNumber": "2020/290401", - "registrationDate": "2020-06-15", - "typeOfRegistration": "M", - "entryCodes": [""], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2020/290401" - ), - }, - { - "recordNumber": "2019/53986T", - "registrationDate": "2019-12-05", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2019/53986T" - ), - }, - { - "recordNumber": "2019/250695", - "registrationDate": "2019-07-09", - "typeOfRegistration": "M", - "entryCodes": ["HAL"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2019/250695" - ), - }, - { - "recordNumber": "2018/50210V", - "registrationDate": "2018-10-19", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2018/50210V" - ), - }, - { - "recordNumber": "2017/46683V", - "registrationDate": "2017-10-21", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2017/46683V" - ), - }, - { - "recordNumber": "2016/86280V", - "registrationDate": "2016-11-03", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2016/86280V" - ), - }, - { - "recordNumber": "2015/739492", - "registrationDate": "2015-11-04", - "typeOfRegistration": "M", - "entryCodes": ["TILTAR"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2015/739492" - ), - }, - { - "recordNumber": "2015/84509U", - "registrationDate": "2015-10-08", - "typeOfRegistration": "TA", - "entryCodes": ["TASE"], - "detailsUri": ( - "http://avoindata.prh.fi/opendata/tr/v1/publicnotices/2015/84509U" - ), - }, - ], - "registeredOffices": [ - { - "registeredOffice": "Vaasa", - "language": "EN", - "registrationDate": "2005-01-25", - "endDate": None, - }, - { - "registeredOffice": "Vasa", - "language": "SE", - "registrationDate": "2005-01-25", - "endDate": None, - }, - { - "registeredOffice": "Vaasa", - "language": "FI", - "registrationDate": "2005-01-25", - "endDate": None, - }, - ], - } - ], -} - - -DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE = { - "type": "fi.prh.opendata.bis", - "version": "1", - "totalResults": -1, - "resultsFrom": 0, - "previousResultsUri": None, - "nextResultsUri": None, - "exceptionNoticeUri": None, - "results": [ - { - "businessId": "0877830-0", - "name": "I. Haanpää Oy", - "registrationDate": "1992-03-03", - "companyForm": "OY", - "detailsUri": None, - "liquidations": [], "names": [ { - "order": 0, - "version": 1, "name": "I. Haanpää Oy", + "type": "1", "registrationDate": "1999-03-31", - "endDate": None, - "source": 1, + "version": 1, + "source": "1", }, { - "order": 0, - "version": 2, "name": "I. Haanpää Ky", + "type": "2", "registrationDate": "1992-01-29", "endDate": "1999-03-30", - "source": 1, + "version": 2, + "source": "1", }, ], - "auxiliaryNames": [], "addresses": [ { - "careOf": None, + "type": 1, "street": "Vasaratie 4 A 3", "postCode": "65350", - "type": 1, - "version": 2, - "city": "VAASA", - "country": None, - "registrationDate": "2013-12-16", - "endDate": "2013-12-18", - "language": "FI", - "source": 0, + "postOffices": [ + {"city": "Vaasa", "languageCode": "1"}, + {"city": "Vasa", "languageCode": "2"}, + ], + "registrationDate": "2020-07-22", + "source": "1", }, { - "careOf": None, + "type": 2, "street": "PL 327", "postCode": "65101", - "type": 2, - "version": 2, - "city": "VAASA", - "country": None, + "postOffices": [ + {"city": "Vaasa", "languageCode": "1"}, + ], "registrationDate": "2013-12-16", "endDate": "2020-07-21", - "language": "FI", - "source": 0, - }, - { - "careOf": None, - "street": "", - "postCode": None, - "type": 1, - "version": 1, - "city": None, - "country": None, - "registrationDate": "2013-12-18", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "careOf": None, - "street": "Vasaratie 4 A 3", - "postCode": "65350", - "type": 2, - "version": 1, - "city": "VAASA", - "country": None, - "registrationDate": "2020-07-22", - "endDate": None, - "language": "FI", - "source": 0, - }, - ], - "companyForms": [ - { - "version": 1, - "name": "Osakeyhtiö", - "type": "OY", - "registrationDate": "1999-03-31", - "endDate": None, - "language": "FI", - "source": 1, - }, - { - "version": 1, - "name": "Aktiebolag", - "type": "AB", - "registrationDate": "1999-03-31", - "endDate": None, - "language": "SE", - "source": 1, - }, - { - "version": 1, - "name": "Limited company", - "type": None, - "registrationDate": "1999-03-31", - "endDate": None, - "language": "EN", - "source": 1, - }, - ], - "businessLines": [ - { - "order": 0, - "version": 1, - "code": "47594", - "name": "Retail sale of household articles", - "registrationDate": "2007-12-31", - "endDate": None, - "language": "EN", - "source": 2, - }, - { - "order": 0, - "version": 1, - "code": "47594", - "name": "Taloustavaroiden vähittäiskauppa", - "registrationDate": "2007-12-31", - "endDate": None, - "language": "FI", - "source": 2, - }, - { - "order": 0, - "version": 1, - "code": "47594", - "name": "Specialiserad butikshandel med hushållsartiklar", - "registrationDate": "2007-12-31", - "endDate": None, - "language": "SE", - "source": 2, - }, - ], - "languages": [ - { - "version": 1, - "name": "Finnish", - "registrationDate": "1992-03-03", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "version": 1, - "name": "Suomi", - "registrationDate": "1992-03-03", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "name": "Finska", - "registrationDate": "1992-03-03", - "endDate": None, - "language": "SE", - "source": 0, - }, - ], - "registedOffices": [ - { - "order": 0, - "version": 1, - "name": "VAASA", - "registrationDate": "2005-01-25", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "order": 0, - "version": 1, - "name": "VAASA", - "registrationDate": "2005-01-25", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "order": 0, - "version": 1, - "name": "VASA", - "registrationDate": "2005-01-25", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "order": 0, - "version": 2, - "name": "VÄHÄKYRÖ", - "registrationDate": "1999-03-31", - "endDate": "2005-01-24", - "language": "EN", - "source": 0, - }, - { - "order": 0, - "version": 2, - "name": "VÄHÄKYRÖ", - "registrationDate": "1999-03-31", - "endDate": "2005-01-24", - "language": "FI", - "source": 0, - }, - { - "order": 0, - "version": 2, - "name": "LILLKYRO", - "registrationDate": "1999-03-31", - "endDate": "2005-01-24", - "language": "SE", - "source": 0, - }, - ], - "contactDetails": [ - { - "version": 1, - "value": "0400 665254", - "type": "Matkapuhelin", - "registrationDate": "2005-01-11", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "value": "0400 665254", - "type": "Mobiltelefon", - "registrationDate": "2005-01-11", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "version": 1, - "value": "0400 665254", - "type": "Mobile phone", - "registrationDate": "2005-01-11", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "version": 2, - "value": "06 3129811", - "type": "Puhelin", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "FI", - "source": 0, - }, - { - "version": 2, - "value": "06 3129811", - "type": "Telefon", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "SE", - "source": 0, - }, - { - "version": 2, - "value": "06 3129811", - "type": "Telephone", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "EN", - "source": 0, - }, - { - "version": 2, - "value": "06 9129812", - "type": "Faksi", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "FI", - "source": 0, - }, - { - "version": 2, - "value": "06 9129812", - "type": "Fax", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "SE", - "source": 0, - }, - { - "version": 2, - "value": "06 9129812", - "type": "Fax", - "registrationDate": "2005-01-11", - "endDate": "2020-07-21", - "language": "EN", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Puhelin", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Telefon", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Telephone", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "EN", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Faksi", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "FI", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Fax", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "SE", - "source": 0, - }, - { - "version": 1, - "value": "", - "type": "Fax", - "registrationDate": "2020-07-22", - "endDate": None, - "language": "EN", - "source": 0, - }, - ], - "registeredEntries": [ - { - "authority": 1, - "register": 4, - "status": 1, - "registrationDate": "1992-03-03", - "endDate": None, - "statusDate": "2020-06-03", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 1, - "register": 4, - "status": 1, - "registrationDate": "1992-03-03", - "endDate": None, - "statusDate": "2020-06-03", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 1, - "register": 4, - "status": 1, - "registrationDate": "1992-03-03", - "endDate": None, - "statusDate": "2020-06-03", - "language": "EN", - "description": "Registered", - }, - { - "authority": 1, - "register": 5, - "status": 1, - "registrationDate": "1995-03-01", - "endDate": None, - "statusDate": "2017-03-16", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 1, - "register": 5, - "status": 1, - "registrationDate": "1995-03-01", - "endDate": None, - "statusDate": "2017-03-16", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 1, - "register": 5, - "status": 1, - "registrationDate": "1995-03-01", - "endDate": None, - "statusDate": "2017-03-16", - "language": "EN", - "description": "Registered", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "1994-06-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "FI", - "description": "Liiketoiminnasta arvonlisäverovelvollinen", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "1994-06-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "SE", - "description": "Momsskyldig för rörelseverksamhet", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "1994-06-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "EN", - "description": "VAT-liable for business activity", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "2008-08-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "FI", - "description": "Kiinteistön käyttöoikeuden luovuttamisesta", - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "2008-08-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "SE", - "description": ( - "För överlåtelse av nyttjanderätten till en fastighet" - ), - }, - { - "authority": 1, - "register": 6, - "status": 1, - "registrationDate": "2008-08-01", - "endDate": None, - "statusDate": "2008-07-21", - "language": "EN", - "description": ( - "VAT-obliged for the transfer of rights to use immovable" - " property" - ), - }, - { - "authority": 1, - "register": 7, - "status": 1, - "registrationDate": "1992-03-01", - "endDate": None, - "statusDate": "2001-03-30", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 1, - "register": 7, - "status": 1, - "registrationDate": "1992-03-01", - "endDate": None, - "statusDate": "2001-03-30", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 1, - "register": 7, - "status": 1, - "registrationDate": "1992-03-01", - "endDate": None, - "statusDate": "2001-03-30", - "language": "EN", - "description": "Registered", - }, - { - "authority": 2, - "register": 1, - "status": 1, - "registrationDate": "1992-01-29", - "endDate": None, - "statusDate": "1992-01-29", - "language": "FI", - "description": "Rekisterissä", - }, - { - "authority": 2, - "register": 1, - "status": 1, - "registrationDate": "1992-01-29", - "endDate": None, - "statusDate": "1992-01-29", - "language": "SE", - "description": "Registrerad", - }, - { - "authority": 2, - "register": 1, - "status": 1, - "registrationDate": "1992-01-29", - "endDate": None, - "statusDate": "1992-01-29", - "language": "EN", - "description": "Registered", + "source": "1", }, ], - "businessIdChanges": [], + "mainBusinessLine": { + "descriptions": [ + { + "languageCode": "1", + "description": "Taloustavaroiden vähittäiskauppa", + }, + { + "languageCode": "2", + "description": "Specialiserad butikshandel med hushållsartiklar", + }, + { + "languageCode": "3", + "description": "Retail sale of household articles", + }, + ], + "registrationDate": "2007-12-31", + "source": "2", + }, } - ], + ] } diff --git a/backend/kesaseteli/companies/tests/test_company_api.py b/backend/kesaseteli/companies/tests/test_company_api.py index 5d2f410876..c82c0114c0 100644 --- a/backend/kesaseteli/companies/tests/test_company_api.py +++ b/backend/kesaseteli/companies/tests/test_company_api.py @@ -10,7 +10,6 @@ from companies.models import Company from companies.tests.data.company_data import ( DUMMY_COMPANY_DATA, - DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE, DUMMY_YTJ_RESPONSE, ) @@ -19,18 +18,11 @@ def get_company_api_url(): return "/v1/company/" -def set_up_mock_requests( - ytj_response: dict, business_details_response: dict, requests_mock -): +def set_up_mock_requests(ytj_response: dict, requests_mock): """ Set up the mock responses. """ - business_id = ytj_response["results"][0]["businessId"] - ytj_url = f"{settings.YTJ_BASE_URL}/{business_id}" - business_details_url = ytj_response["results"][0]["bisDetailsUri"] - - requests_mock.get(ytj_url, json=ytj_response) - requests_mock.get(business_details_url, json=business_details_response) + requests_mock.get(f"{settings.YTJ_BASE_URL}/companies", json=ytj_response) @pytest.mark.django_db @@ -56,9 +48,10 @@ def test_get_mock_company_not_found_from_ytj(api_client): for field in [ f for f in Company._meta.fields - if f.name not in ["id", "name", "business_id", "organization_type", "ytj_json"] + if f.name + not in ["id", "name", "business_id", "ytj_json", "created_at", "modified_at"] ]: - assert response.data[field.name] == "" + assert response.data.get(field.name, "") == "" @pytest.mark.django_db @@ -68,7 +61,7 @@ def test_get_mock_company_not_found_from_ytj(api_client): EAUTHORIZATIONS_CLIENT_ID="test", EAUTHORIZATIONS_CLIENT_SECRET="test", ) -def test_get_company_organization_roles_error(api_client, requests_mock, user): +def test_get_company_organization_roles_error(api_client, requests_mock, user, caplog): session = api_client.session session.pop("organization_roles") session.save() @@ -83,12 +76,13 @@ def test_get_company_organization_roles_error(api_client, requests_mock, user): response.data["detail"] == "Unable to fetch organization roles from eauthorizations API" ) + assert "Unable to fetch organization roles from eauthorizations API" in caplog.text @pytest.mark.django_db @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, - YTJ_BASE_URL="http://example.com", + YTJ_BASE_URL="http://example.com/v3", ) def test_get_company_from_ytj(api_client, requests_mock): session = api_client.session @@ -97,9 +91,7 @@ def test_get_company_from_ytj(api_client, requests_mock): Company.objects.all().delete() - set_up_mock_requests( - DUMMY_YTJ_RESPONSE, DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE, requests_mock - ) + set_up_mock_requests(DUMMY_YTJ_RESPONSE, requests_mock) org_roles_json = { "name": "Activenakusteri Oy", @@ -120,7 +112,8 @@ def test_get_company_from_ytj(api_client, requests_mock): assert response.data == company_data assert ( - response.data["business_id"] == DUMMY_YTJ_RESPONSE["results"][0]["businessId"] + response.data["business_id"] + == DUMMY_YTJ_RESPONSE["companies"][0]["businessId"]["value"] ) for field in ["company_form", "industry", "street_address", "postcode", "city"]: assert response.data[field] @@ -129,7 +122,7 @@ def test_get_company_from_ytj(api_client, requests_mock): @pytest.mark.django_db @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, - YTJ_BASE_URL="http://example.com", + YTJ_BASE_URL="http://example.com/v3", ) def test_get_company_not_found_from_ytj(api_client, requests_mock, user): matcher = re.compile(re.escape(settings.YTJ_BASE_URL)) @@ -154,23 +147,23 @@ def test_get_company_not_found_from_ytj(api_client, requests_mock, user): for field in [ f for f in Company._meta.fields - if f.name not in ["id", "name", "business_id", "organization_type", "ytj_json"] + if f.name + not in ["id", "name", "business_id", "ytj_json", "created_at", "modified_at"] ]: - assert response.data[field.name] == "" + assert response.data.get(field.name, "") == "" @pytest.mark.django_db @override_settings( NEXT_PUBLIC_MOCK_FLAG=False, - YTJ_BASE_URL="http://example.com", + YTJ_BASE_URL="http://example.com/v3", ) -def test_get_company_from_ytj_invalid_response(api_client, requests_mock, user): +def test_get_company_from_ytj_invalid_response(api_client, requests_mock, user, caplog): ytj_reponse = copy.deepcopy(DUMMY_YTJ_RESPONSE) - ytj_reponse["results"][0]["addresses"] = [] + # Simulate invalid data: empty companies list or no addresses + ytj_reponse["companies"][0]["addresses"] = [] - set_up_mock_requests( - ytj_reponse, DUMMY_YTJ_BUSINESS_DETAILS_RESPONSE, requests_mock - ) + set_up_mock_requests(ytj_reponse, requests_mock) org_roles_json = { "name": "Activenakusteri Oy", @@ -184,5 +177,15 @@ def test_get_company_from_ytj_invalid_response(api_client, requests_mock, user): ): response = api_client.get(get_company_api_url()) - assert response.status_code == 404 - assert response.data["detail"] == "Could not handle the response from YTJ API" + assert response.status_code == 200 + assert response.data["name"] == org_roles_json["name"] + assert response.data["business_id"] == org_roles_json["identifier"] + assert "YTJ API parsing error for business_id" in caplog.text + + for field in [ + f + for f in Company._meta.fields + if f.name + not in ["id", "name", "business_id", "ytj_json", "created_at", "modified_at"] + ]: + assert response.data.get(field.name, "") == "" diff --git a/backend/kesaseteli/kesaseteli/settings.py b/backend/kesaseteli/kesaseteli/settings.py index 90cbe34a73..d059be17b1 100644 --- a/backend/kesaseteli/kesaseteli/settings.py +++ b/backend/kesaseteli/kesaseteli/settings.py @@ -58,7 +58,7 @@ CSRF_COOKIE_DOMAIN=(str, "localhost"), CSRF_TRUSTED_ORIGINS=(list, []), CSRF_COOKIE_NAME=(str, "yjdhcsrftoken"), - YTJ_BASE_URL=(str, "http://avoindata.prh.fi/opendata/tr/v1"), + YTJ_BASE_URL=(str, "https://avoindata.prh.fi/opendata-ytj-api/v3"), YTJ_TIMEOUT=(int, 30), NEXT_PUBLIC_MOCK_FLAG=(bool, False), SESSION_COOKIE_AGE=(int, 60 * 60 * 2), diff --git a/backend/shared/shared/ytj/README.md b/backend/shared/shared/ytj/README.md new file mode 100644 index 0000000000..ea82e0e1b4 --- /dev/null +++ b/backend/shared/shared/ytj/README.md @@ -0,0 +1,52 @@ +# YTJ Client (V3) + +This module provides a pythonic interface to the [Finnish Trade Register (YTJ) Open Data API V3](https://avoindata.prh.fi/opendata-ytj-api/v3/). + +## Overview + +The client allows fetching company details using a Business ID (Y-tunnus). It handles the complexity of the V3 API response structure, parsing it into strongly-typed dataclasses and providing helper properties to access preferred values (e.g., prioritizing Finnish names and addresses). + +## Key Components + +- **`YTJClient`** (`ytj_client.py`): The main entry point. Use `get_company_info_with_business_id(business_id)` to get raw data or `get_company_data_from_ytj_data(data)` to extract a simplified dictionary for the application. +- **`YTJCompany`** (`ytj_dataclasses.py`): The root dataclass representing the API response. It contains logic for: + - Language prioritization (FI > SV > EN). + - Address prioritization (Visiting > Postal). + - Data parsing from JSON. +- **`ytj_schema.yaml`**: A local copy of the OpenAPI V3 schema used for reference and validation during development. + +## Usage + +```python +from shared.ytj.ytj_client import YTJClient + +client = YTJClient() + +# 1. Fetch data +# Returns a dict matching the JSON response +data = client.get_company_info_with_business_id("1234567-8") + +# 2. Extract simplified company info +# Returns: +# { +# "name": "Company Oy", +# "business_id": "1234567-8", +# "company_form": "Osakeyhtiö", +# "industry": "Ohjelmistokehitys", +# "street_address": "Katu 1", +# "postcode": "00100", +# "city": "HELSINKI" +# } +company_info = YTJClient.get_company_data_from_ytj_data(data) +``` + +## Configuration + +Required `django.conf.settings`: +- `YTJ_BASE_URL`: Base URL for the V3 API (e.g., `https://avoindata.prh.fi/opendata-ytj-api/v3`). +- `YTJ_TIMEOUT`: Request timeout in seconds. + +## Development + +The dataclasses in `ytj_dataclasses.py` are based on the OpenAPI specification. +See `ytj_schema.yaml` in this directory for the full schema definition. diff --git a/backend/shared/shared/ytj/exceptions.py b/backend/shared/shared/ytj/exceptions.py new file mode 100644 index 0000000000..4b8fd3816a --- /dev/null +++ b/backend/shared/shared/ytj/exceptions.py @@ -0,0 +1,10 @@ +class YTJNotFoundError(ValueError): + """ + Exception raised when no company is found in YTJ. + """ + + +class YTJParseError(ValueError): + """ + Exception raised when YTJ data cannot be parsed (missing required fields). + """ diff --git a/backend/shared/shared/ytj/tests/test_ytj_client_v3.py b/backend/shared/shared/ytj/tests/test_ytj_client_v3.py new file mode 100644 index 0000000000..96aee1d537 --- /dev/null +++ b/backend/shared/shared/ytj/tests/test_ytj_client_v3.py @@ -0,0 +1,317 @@ +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings + +from shared.ytj.exceptions import YTJNotFoundError +from shared.ytj.ytj_client import YTJClient +from shared.ytj.ytj_dataclasses import ( + YTJAddressType, + YTJCompany, + YTJLanguageCode, + YTJNameType, +) + +# Sample Nokia Data (partial, reconstructed/mocked based on investigation) +MOCK_YTJ_RESPONSE = { + "totalResults": 1, + "companies": [ + { + "businessId": { + "value": "0112038-9", + "registrationDate": "1978-03-15", + "source": "3", + }, + "companyForms": [ + { + "type": "OYJ", + "descriptions": [ + {"languageCode": "1", "description": "Julkinen osakeyhtiö"}, + {"languageCode": "3", "description": "Public limited company"}, + ], + } + ], + "names": [ + { + "name": "Nokia Oyj", + "type": "1", + "registrationDate": "1997-09-01", + "version": 1, + "source": "1", + }, + { + "name": "Oy Nokia Ab", + "type": "1", + "registrationDate": "1966-06-10", + "endDate": "1997-08-31", + "version": 2, + "source": "1", + }, + ], + "mainBusinessLine": { + "descriptions": [ + {"languageCode": "3", "description": "Activities of head offices"}, + {"languageCode": "1", "description": "Pääkonttorien toiminta"}, + ] + }, + "addresses": [ + { + "type": 1, + "street": "Karakaari 7", + "postCode": "02610", + "postOffices": [ + {"city": "ESBO", "languageCode": "2"}, + {"city": "ESPOO", "languageCode": "1"}, + ], + } + ], + } + ], +} + + +@pytest.fixture +def ytj_client(): + return YTJClient() + + +@patch("requests.get") +def test_get_company_info_with_business_id(mock_get, ytj_client): + mock_response = Mock() + mock_response.json.return_value = MOCK_YTJ_RESPONSE + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + business_id = "0112038-9" + result = ytj_client.get_company_info_with_business_id(business_id) + + # Check that requests.get was called with correct URL and params + mock_get.assert_called_once() + args, kwargs = mock_get.call_args + assert args[0] == f"{settings.YTJ_BASE_URL}/companies" + assert kwargs["params"] == {"businessId": business_id} + assert kwargs["timeout"] == settings.YTJ_TIMEOUT + + assert result == MOCK_YTJ_RESPONSE + + +@patch("requests.get") +def test_get_company_info_not_found(mock_get, ytj_client): + mock_response = Mock() + mock_response.json.return_value = {"companies": []} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + business_id = "0000000-0" + with pytest.raises( + YTJNotFoundError, + match=f"No company found in YTJ for business ID: {business_id}", + ): + ytj_client.get_company_info_with_business_id(business_id) + + +def test_get_company_data_from_ytj_data(): + data = YTJClient.get_company_data_from_ytj_data(MOCK_YTJ_RESPONSE) + + assert data["name"] == "Nokia Oyj" + assert data["business_id"] == "0112038-9" + assert data["company_form"] == "Julkinen osakeyhtiö" # FI description + assert data["industry"] == "Pääkonttorien toiminta" # FI description + assert data["street_address"] == "Karakaari 7" + assert data["postcode"] == "02610" + assert data["city"] == "ESPOO" + + +def test_get_company_data_missing_fields(): + # Test minimal data or missing fields handling + minimal_data = { + "companies": [ + { + "businessId": {"value": "1234567-8"}, + "names": [{"name": "Test Company", "type": YTJNameType.MAIN_NAME}], + "addresses": [ + { + "type": YTJAddressType.VISITING_ADDRESS, + "street": "Test street", + "postCode": "00100", + "postOffices": [ + {"city": "Helsinki", "languageCode": YTJLanguageCode.FI} + ], + } + ], + "mainBusinessLine": { + "descriptions": [ + { + "languageCode": YTJLanguageCode.FI, + "description": "Test Industry", + } + ] + }, + } + ] + } + + data = YTJClient.get_company_data_from_ytj_data(minimal_data) + assert data["name"] == "Test Company" + + +def test_get_company_data_no_companies(): + with pytest.raises(YTJNotFoundError, match="No companies found"): + YTJClient.get_company_data_from_ytj_data({}) + + +class TestYTJCompany: + @pytest.fixture + def company_data(self): + return { + "businessId": {"value": "1234567-8"}, + "names": [{"name": "Test Oy", "type": YTJNameType.MAIN_NAME}], + "companyForms": [ + { + "type": "OY", + "descriptions": [ + { + "languageCode": YTJLanguageCode.FI, + "description": "Osakeyhtiö", + }, + { + "languageCode": YTJLanguageCode.SV, + "description": "Aktiebolag", + }, + ], + } + ], + "mainBusinessLine": { + "descriptions": [ + { + "languageCode": YTJLanguageCode.FI, + "description": "Ohjelmistokehitys", + }, + { + "languageCode": YTJLanguageCode.SV, + "description": "Programvaruutveckling", + }, + ] + }, + "addresses": [ + { + "type": YTJAddressType.VISITING_ADDRESS, + "street": "Katu 1", + "postCode": "00100", + "postOffices": [ + {"city": "Helsinki", "languageCode": YTJLanguageCode.FI}, + {"city": "Helsingfors", "languageCode": YTJLanguageCode.SV}, + ], + }, + { + "type": YTJAddressType.POSTAL_ADDRESS, + "street": "Box 123", + "postCode": "00101", + "postOffices": [ + {"city": "Helsinki", "languageCode": YTJLanguageCode.FI} + ], + }, + ], + } + + def test_company_form_language_priority(self, company_data): + # Default (FI) + company = YTJCompany.from_json(company_data) + assert company.company_form == "Osakeyhtiö" + + # SV fallback + company_data["companyForms"][0]["descriptions"] = [ + {"languageCode": YTJLanguageCode.SV, "description": "Aktiebolag"} + ] + company = YTJCompany.from_json(company_data) + assert company.company_form == "Aktiebolag" + + # Type fallback + company_data["companyForms"][0]["descriptions"] = [] + company = YTJCompany.from_json(company_data) + assert company.company_form == "OY" + + def test_industry_language_priority(self, company_data): + # Default (FI) + company = YTJCompany.from_json(company_data) + assert company.industry == "Ohjelmistokehitys" + + # SV fallback + company_data["mainBusinessLine"]["descriptions"] = [ + {"languageCode": YTJLanguageCode.SV, "description": "Programvaruutveckling"} + ] + company = YTJCompany.from_json(company_data) + assert company.industry == "Programvaruutveckling" + + # Fail if no FI/SV + company_data["mainBusinessLine"]["descriptions"] = [ + {"languageCode": YTJLanguageCode.EN, "description": "Software Development"} + ] + company = YTJCompany.from_json(company_data) + with pytest.raises(ValueError, match="Company industry missing"): + _ = company.industry + + def test_address_priority(self, company_data): + # Default (Visiting Address - Type 1) + company = YTJCompany.from_json(company_data) + assert company.address["street_address"] == "Katu 1" + + # Fallback to Postal Address (Type 2) + company_data["addresses"] = [ + d + for d in company_data["addresses"] + if d["type"] == YTJAddressType.POSTAL_ADDRESS + ] + company = YTJCompany.from_json(company_data) + assert company.address["street_address"] == "Box 123" + + # Fail if no valid address + company_data["addresses"] = [] + company = YTJCompany.from_json(company_data) + with pytest.raises(ValueError, match="Company address missing"): + _ = company.address + + def test_city_language_priority(self, company_data): + # Default (FI) + company = YTJCompany.from_json(company_data) + assert company.address["city"] == "Helsinki" + + # SV Fallback (simulate only SV city available) + company_data["addresses"][0]["postOffices"] = [ + {"city": "Helsingfors", "languageCode": YTJLanguageCode.SV} + ] + company = YTJCompany.from_json(company_data) + assert company.address["city"] == "Helsingfors" + + def test_name_priority(self, company_data): + # Default (Main Name - Type 1) + company = YTJCompany.from_json(company_data) + assert company.name == "Test Oy" + + # Fallback to any name if type 1 missing + company_data["names"] = [{"name": "Secondary Name", "type": "2"}] + company = YTJCompany.from_json(company_data) + assert company.name == "Secondary Name" + + # Fail if no names + company_data["names"] = [] + company = YTJCompany.from_json(company_data) + with pytest.raises(ValueError, match="Company name missing"): + _ = company.name + + def test_parsing_with_undocumented_fields(self, company_data): + # Add a field that is NOT in our dataclass + company_data["addresses"][0]["extra_metadata_from_api"] = "some-value" + company_data["mainBusinessLine"]["new_api_feature"] = 123 + + # This should NOT raise a TypeError + company = YTJCompany.from_json(company_data) + assert company.business_id_value == "1234567-8" + + def test_root_fields_mapping(self, company_data): + company_data["status"] = "Registered" + company_data["endDate"] = "2025-01-01" + + company = YTJCompany.from_json(company_data) + assert company.status == "Registered" + assert company.endDate == "2025-01-01" diff --git a/backend/shared/shared/ytj/ytj_client.py b/backend/shared/shared/ytj/ytj_client.py index 0f62d8f8e4..d50866faea 100644 --- a/backend/shared/shared/ytj/ytj_client.py +++ b/backend/shared/shared/ytj/ytj_client.py @@ -1,10 +1,13 @@ import requests from django.conf import settings +from shared.ytj.exceptions import YTJNotFoundError +from shared.ytj.ytj_dataclasses import YTJCompany + class YTJClient: """ - https://avoindata.prh.fi/ + https://avoindata.prh.fi/opendata-ytj-api/v3/ """ def __init__(self): @@ -21,50 +24,31 @@ def get_company_data_from_ytj_data(ytj_data: dict) -> dict: """ Get the required company fields from YTJ data. """ - # Address of type 1 is the visiting address - company_address = next( - (x for x in ytj_data["addresses"] if x["type"] == 1), None - ) - - if not company_address: - # A fallback if address of type 1 does not exist - company_address = next( - (x for x in ytj_data["addresses"] if x["type"] == 2), None - ) - - if not company_address: - raise ValueError("Company address missing from YTJ data") - - # Get the Finnish name of the business line - company_industry = next( - (x for x in ytj_data["businessLines"] if x["language"] == "FI"), None - ) - - if not company_industry: - raise ValueError("Company industry missing from YTJ data") - - company_data = { - "name": ytj_data["name"], - "business_id": ytj_data["businessId"], - "company_form": ytj_data["companyForm"], - "industry": company_industry["name"], - "street_address": company_address["street"], - "postcode": company_address["postCode"], - "city": company_address["city"], + if not ytj_data or "companies" not in ytj_data or not ytj_data["companies"]: + raise YTJNotFoundError("No companies found in YTJ data") + + # Parse first company + company = YTJCompany.from_json(ytj_data["companies"][0]) + + return { + "name": company.name, + "business_id": company.business_id_value, + "company_form": company.company_form, + "industry": company.industry, + **company.address, } - return company_data - def get_company_info_with_business_id(self, business_id: str, **kwargs) -> dict: - company_info_url = f"{settings.YTJ_BASE_URL}/{business_id}" + # V3 uses query parameters + company_info_url = f"{settings.YTJ_BASE_URL}/companies" + params = {"businessId": business_id} + kwargs.update({"params": params}) ytj_data = self._get(company_info_url, **kwargs) - company_result = ytj_data["results"][0] - business_details = self._get(company_result["bisDetailsUri"]) - - company_result["businessLines"] = business_details["results"][0][ - "businessLines" - ] + if not ytj_data.get("companies"): + raise YTJNotFoundError( + f"No company found in YTJ for business ID: {business_id}" + ) - return company_result + return ytj_data diff --git a/backend/shared/shared/ytj/ytj_dataclasses.py b/backend/shared/shared/ytj/ytj_dataclasses.py new file mode 100644 index 0000000000..36cfd3e363 --- /dev/null +++ b/backend/shared/shared/ytj/ytj_dataclasses.py @@ -0,0 +1,342 @@ +""" +Dataclasses representing the company data structure returned by the YTJ V3 API. +Includes logic for parsing the API response and extracting preferred fields +(e.g., Finnish names/addresses). + +Full API documentation: https://avoindata.prh.fi/fi/ytj/swagger-ui +Local Schema Reference: ytj_schema.yaml (OpenAPI 3.0) +""" + +# flake8: noqa: N815 +from dataclasses import dataclass, field +from enum import IntEnum, StrEnum +from typing import List, Optional + +from shared.ytj.exceptions import YTJParseError + + +class YTJLanguageCode(StrEnum): + FI = "1" + SV = "2" + EN = "3" + + +class YTJNameType(StrEnum): + MAIN_NAME = "1" + + +class YTJAddressType(IntEnum): + VISITING_ADDRESS = 1 + POSTAL_ADDRESS = 2 + + +@dataclass +class YTJDescription: + """Description object with language code.""" + + languageCode: str + description: str + + +@dataclass +class YTJName: + """Company name details.""" + + name: str + type: str + registrationDate: Optional[str] = None + version: Optional[int] = None + source: Optional[str] = None + endDate: Optional[str] = None + + +@dataclass +class YTJBusinessId: + """Business ID details.""" + + value: str + registrationDate: Optional[str] = None + source: Optional[str] = None + + +@dataclass +class YTJCompanyForm: + """Company form (legal entity type) details.""" + + type: Optional[str] = None + version: Optional[int] = None + source: Optional[str] = None + registrationDate: Optional[str] = None + endDate: Optional[str] = None + descriptions: List[YTJDescription] = field(default_factory=list) + + +@dataclass +class YTJPostOffice: + """Post office details including city name.""" + + city: str + languageCode: Optional[str] = None + municipalityCode: Optional[str] = None + + +@dataclass +class YTJAddress: + """Address details.""" + + type: int + registrationDate: Optional[str] = None + source: Optional[str] = None + street: Optional[str] = None + postCode: Optional[str] = None + postOffices: List[YTJPostOffice] = field(default_factory=list) + buildingNumber: Optional[str] = None + entrance: Optional[str] = None + apartmentNumber: Optional[str] = None + apartmentIdSuffix: Optional[str] = None + co: Optional[str] = None + postOfficeBox: Optional[str] = None + country: Optional[str] = None + freeAddressLine: Optional[str] = None + + +@dataclass +class YTJBusinessLine: + """Main business line (industry) details.""" + + source: Optional[str] = None + registrationDate: Optional[str] = None + type: Optional[str] = None + descriptions: List[YTJDescription] = field(default_factory=list) + typeCodeSet: Optional[str] = None + + +@dataclass +class YTJRegisteredEntry: + """Registered entry details.""" + + type: str + register: str + authority: str + registrationDate: Optional[str] = None + endDate: Optional[str] = None + descriptions: List[YTJDescription] = field(default_factory=list) + + +def _parse_descriptions(d_list: list) -> List[YTJDescription]: + return [YTJDescription(**d) for d in d_list] if d_list else [] + + +def _parse_company_forms(data: list) -> List[YTJCompanyForm]: + company_forms = [] + for cf in data: + # Create a copy to avoid mutating original dict if reused + cf = cf.copy() + desc = _parse_descriptions(cf.pop("descriptions", [])) + company_forms.append(YTJCompanyForm(descriptions=desc, **cf)) + return company_forms + + +def _parse_addresses(data: list) -> List[YTJAddress]: + addresses = [] + for addr in data: + addr = addr.copy() + po_list = [YTJPostOffice(**po) for po in addr.pop("postOffices", [])] + + # Safe unpacking helper + known_fields = { + "type", + "registrationDate", + "source", + "street", + "postCode", + "buildingNumber", + "entrance", + "apartmentNumber", + "apartmentIdSuffix", + "co", + "postOfficeBox", + "country", + "freeAddressLine", + } + addr_data = {k: v for k, v in addr.items() if k in known_fields} + + addresses.append(YTJAddress(postOffices=po_list, **addr_data)) + return addresses + + +def _parse_registered_entries(data: list) -> List[YTJRegisteredEntry]: + registered_entries = [] + for re in data: + re = re.copy() + desc = _parse_descriptions(re.pop("descriptions", [])) + registered_entries.append(YTJRegisteredEntry(descriptions=desc, **re)) + return registered_entries + + +def _parse_main_business_line(data: dict) -> Optional[YTJBusinessLine]: + if not data: + return None + data = data.copy() + desc = _parse_descriptions(data.pop("descriptions", [])) + + # Whitelist fields for BusinessLine to prevent TypeError from undocumented API + # fields + known_fields = {"source", "registrationDate", "type", "typeCodeSet"} + line_data = {k: v for k, v in data.items() if k in known_fields} + + return YTJBusinessLine(descriptions=desc, **line_data) + + +@dataclass +class YTJCompany: + """ + Root object for a company in the YTJ API response. + Contains methods to parse JSON and properties to extract preferred display values. + """ + + businessId: YTJBusinessId + names: List[YTJName] = field(default_factory=list) + addresses: List[YTJAddress] = field(default_factory=list) + companyForms: List[YTJCompanyForm] = field(default_factory=list) + mainBusinessLine: Optional[YTJBusinessLine] = None + # Add other fields as optional if needed (euId, website, etc) + euId: Optional[dict] = None + website: Optional[dict] = None + companySituations: List[dict] = field(default_factory=list) + registeredEntries: List[YTJRegisteredEntry] = field(default_factory=list) + tradeRegisterStatus: Optional[str] = None + status: Optional[str] = None + registrationDate: Optional[str] = None + endDate: Optional[str] = None + lastModified: Optional[str] = None + + @classmethod + def from_json(cls, data: dict) -> "YTJCompany": + """ + Parse raw JSON data from YTJ V3 API into a YTJCompany object. + """ + processed = { + "businessId": YTJBusinessId(**data.get("businessId", {})), + "names": [YTJName(**n) for n in data.get("names", [])], + "companyForms": _parse_company_forms(data.get("companyForms", [])), + "addresses": _parse_addresses(data.get("addresses", [])), + "mainBusinessLine": _parse_main_business_line(data.get("mainBusinessLine")), + "registeredEntries": _parse_registered_entries( + data.get("registeredEntries", []) + ), + } + return cls(**{**data, **processed}) + + def _get_preferred_translation( + self, + items: list, + attr_name: str, + preferred_langs: Optional[List[YTJLanguageCode]] = None, + ) -> Optional[str]: + """ + Returns the first available translation for the given attribute name + based on the provided language priority. + """ + if preferred_langs is None: + preferred_langs = [YTJLanguageCode.FI, YTJLanguageCode.SV] + + for lang in preferred_langs: + match = next( + (getattr(i, attr_name) for i in items if i.languageCode == lang), + None, + ) + if match: + return match + return None + + @property + def business_id_value(self) -> str: + """Returns the business ID string.""" + if not self.businessId or not self.businessId.value: + raise YTJParseError("Business ID missing from YTJ data") + return self.businessId.value + + @property + def name(self) -> str: + """ + Returns the preferred company name. + Prioritizes the main name (type '1'). Falls back to the first available name. + """ + # Name: prefer type 1 (main name?), fallback to first available + name = next( + (x.name for x in self.names if x.type == YTJNameType.MAIN_NAME), None + ) + if not name and self.names: + name = self.names[0].name + + if not name: + raise YTJParseError("Company name missing from YTJ data") + return name + + @property + def company_form(self) -> str: + """ + Returns the company form as a string description (e.g., "Osakeyhtiö"). + Prioritizes Finnish description. Falls back to type code. + """ + company_form_obj = self.companyForms[0] if self.companyForms else None + company_form = company_form_obj.type if company_form_obj else None + + # Try to find description in preferred languages + if company_form_obj: + translation = self._get_preferred_translation( + company_form_obj.descriptions, "description" + ) + if translation: + company_form = translation + return company_form + + @property + def industry(self) -> str: + """ + Returns the main industry/business line description. + Prioritizes Finnish, then Swedish. + """ + mbl = self.mainBusinessLine + if not mbl: + raise YTJParseError("Company industry missing from YTJ data") + + descriptions = mbl.descriptions + industry = self._get_preferred_translation(descriptions, "description") + + if not industry: + raise YTJParseError("Company industry missing from YTJ data") + return industry + + @property + def address(self) -> dict: + """ + Returns a dict with street_address, postcode, and city. + Prioritizes Visiting Address (type 1), then Postal Address (type 2). + City is selected from postOffices list (prioritizing Finnish). + """ + addresses = self.addresses + # Type 1 is visiting address, Type 2 is postal + company_address = next( + (x for x in addresses if x.type == YTJAddressType.VISITING_ADDRESS), + next( + (x for x in addresses if x.type == YTJAddressType.POSTAL_ADDRESS), None + ), + ) + + if not company_address: + raise YTJParseError("Company address missing from YTJ data") + + # Safely extract city: Prioritize preferred languages, then anything available. + city = None + if company_address.postOffices: + city = self._get_preferred_translation(company_address.postOffices, "city") + if not city: + city = company_address.postOffices[0].city + + return { + "street_address": company_address.street, + "postcode": company_address.postCode, + "city": city, + } diff --git a/backend/shared/shared/ytj/ytj_schema.yaml b/backend/shared/shared/ytj/ytj_schema.yaml new file mode 100644 index 0000000000..2b0349544e --- /dev/null +++ b/backend/shared/shared/ytj/ytj_schema.yaml @@ -0,0 +1,727 @@ +--- +openapi: 3.0.1 +info: + title: Opendata YTJ-Api + version: 2.0 + license: + name: Creative Commons Nimeä 4.0 + url: https://creativecommons.org/licenses/by/4.0/ +servers: + - url: https://avoindata.prh.fi/opendata-ytj-api/v3 +paths: + /companies: + get: + summary: "Hae yrityksiä toiminimen, postitoimipaikan Y-tunnuksen tai yritysmuodon perusteella" + operationId: GetCompanies + parameters: + - in: query + name: name + description: "Toiminimi (haku tehdään nykyiseen ja edelliseen toiminimeen, rinnakkaistoiminimeen ja aputoiminimeen)" + schema: + type: string + - in: query + name: location + description: "Postitoimipaikka" + schema: + type: string + - in: query + name: businessId + description: "Y-tunnus" + schema: + type: string + - in: query + name: companyForm + description: > + Yritysmuoto + schema: + type: string + enum: [ "AOY", "ASH", "ASY", "AY", "AYH", "ETS", "ETY", "SCE", "SCP", "HY", "KOY", "KVJ", "KVY", "KY", "OK", + "OP", "OY", "OYJ", "SE", "SL", "SP", "SÄÄ", "TYH", "VOJ", "VOY", "VY", "VALTLL"] + - in: query + name: mainBusinessLine + description: > + Päätoimiala (Hae joko Tilastokeskuksen toimialakoodilla tai tekstillä) + schema: + type: string + - in: query + name: registrationDateStart + description: "Yrityksen rekisteröintipäivä aikavälinä (kirjoita tähän alkamispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: registrationDateEnd + description: "Yrityksen rekisteröintipäivä aikavälinä (kirjoita tähän päättymispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: postCode + description: "Käynti- tai postiosoitteen postinumero" + schema: + type: string + - in: query + name: businessIdRegistrationStart + description: "Y-tunnuksen antamispäivä aikavälinä (Kirjoita tähän alkamispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: businessIdRegistrationEnd + description: "Y-tunnuksen antamispäivä aikavälinä (kirjoita tähän päättymispäivä muodossa vvvv-kk-pp)" + schema: + type: string + format: date + minLength: 10 + maxLength: 10 + - in: query + name: page + description: "Jos haku palauttaa yli 100 tulosta, tulokset palautetaan sivutettuina. Page -parametrilla voit kertoa haluamasi tulossivun. Jos page -parametria ei ole annettu tai se osoittaa sivulle, jota tuloksista ei löydy, palautetaan ensimmäisen sivun tulokset (esimerkiksi jos haet sivua 5 ja tuloksissa on vain 3 sivua)." + schema: + type: integer + format: int32 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/CompanyResult' + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /all_companies: + get: + summary: "Hae kaikki kaupparekisterissä olevat ja vireillä olevat yritykset JSON tiedostona (päivitetään kerran päivässä)" + operationId: GetAllCompanies + responses: + 200: + description: "OK" + content: + application/zip: + schema: + type: string + format: binary + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /description: + get: + summary: "Hae koodiston tiedot" + operationId: GetDescription + parameters: + - in: query + name: code + required: true + description: "Koodi" + schema: + type: string + enum: [ "YRMU", "REK_KDI", "TLAJI", "SELTILA", "REK", "VIRANOM", "TLAHDE", "KIELI", "TOIMI", "TOIMI2", "TOIMI3", "TOIMI4", "KONK", "SANE", "STATUS3", "SELTILA,SANE,KONK" ] + - in: query + name: lang + required: true + description: "Kielitunnus" + schema: + type: string + enum: [ "en", "fi", "sv" ] + responses: + 200: + description: "OK" + content: + text/plain: + schema: + type: string + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /post_codes: + get: + summary: "Hae postikoodiston tiedot" + operationId: GetPostCodes + parameters: + - in: query + name: lang + required: true + description: "Kielitunnus" + schema: + type: string + enum: [ "en", "fi", "sv" ] + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PostOfficeEntry' + 400: + description: "Virheellinen pyyntö" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 429: + description: "Liian monta pyyntöä" + content: + text/plain: + schema: + type: string + 500: + description: "Sisäinen palvelinvirhe" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 503: + description: "Palvelin ei voi käsitellä pyyntöä" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + CompanyResult: + type: object + description: "Yrityksen tai yhteisön tulokset" + required: ["totalResults", "companies"] + properties: + totalResults: + type: integer + format: int64 + description: "Kokonaistulosten määrä" + companies: + type: array + description: "Yrityksen tai yhteisön tiedot" + items: + $ref: '#/components/schemas/Company' + Company: + type: object + description: "Yrityksen tai yhteisön tiedot" + required: ["businessId", "registeredEntries", "tradeRegisterStatus", "lastModified"] + properties: + businessId: + type: object + required: ["value", "source"] + description: "Y-tunnus, esim 0116297-6" + properties: + value: + type: string + description: "Y-tunnus" + minLength: 9 + maxLength: 9 + registrationDate: + type: string + nullable: true + format: date + description: "Y-tunnuksen antamispäivä" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + euId: + type: object + required: ["value", "source"] + description: "EUID-tunnus, esim FIFPRO.0116297-6" + properties: + value: + type: string + description: "EUID tunnus" + minLength: 16 + maxLength: 16 + source: + $ref: '#/components/schemas/Source' + names: + type: array + description: "Yrityksen nimet; päätoiminimi, rinnakkaistoiminimet ja aputoiminimet" + items: + $ref: '#/components/schemas/RegisterName' + minItems: 0 + mainBusinessLine: + type: object + required: ["type", "source"] + description: "Päätoimiala" + properties: + type: + type: string + description: "Toimialakoodi" + minLength: 2 + maxLength: 5 + descriptions: + type: array + description: "Toimialan kuvaukset" + items: + $ref: '#/components/schemas/DescriptionEntry' + minItems: 0 + typeCodeSet: + type: string + description: "Toimialaluokituksen koodistot TOIMI, TOIMI2, TOIMI3, TOIMI4" + minLength: 5 + maxLength: 6 + registrationDate: + type: string + nullable: true + format: date + description: "Toimialakoodin alkupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + website: + type: object + required: ["url", "source"] + description: "Yrityksen verkkosivut" + properties: + url: + type: string + description: "Verkkosivut" + minLength: 0 + maxLength: 255 + registrationDate: + type: string + nullable: true + format: date + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + companyForms: + type: array + description: "Yritysmuoto ja edellinen tieto, jos se on olemassa" + items: + $ref: '#/components/schemas/CompanyForm' + minItems: 0 + maxItems: 2 + companySituations: + type: array + description: "Yrityksen tilanne (mahdollinen saneeraus, selvitystila tai konkurssi)" + items: + $ref: '#/components/schemas/CompanySituation' + minItems: 0 + registeredEntries: + type: array + description: "Yrityksen rekisterimerkinnät" + items: + $ref: '#/components/schemas/RegisteredEntry' + minItems: 0 + addresses: + type: array + description: "Yrityksen käynti- tai postiosoite" + items: + $ref: '#/components/schemas/Address' + minItems: 0 + maxItems: 2 + tradeRegisterStatus: + type: string + description: > + [Yrityksen kaupparekisterin tilatieto](/opendata-ytj-api/v3/description?code=REK_KDI&lang=fi) + status: + type: string + description: > + [Yrityksen y-tunnuksen tila](/opendata-ytj-api/v3/description?code=STATUS3&lang=fi) + registrationDate: + type: string + nullable: true + format: date + description: "Yrityksen rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + minLength: 10 + maxLength: 10 + format: date + description: "Lakkaamispäivä muodossa vvvv-kk-pp" + lastModified: + type: string + minLength: 19 + maxLength: 19 + format: date-time + x-field-extra-annotation: "@com.fasterxml.jackson.annotation.JsonFormat(shape = com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING, pattern = \"yyyy-MM-ddTHH:mm:ss\")" + description: "Viimeksi muokattu sekuntitasolla ilman aikavyöhykettä muodossa 'vvvv-kk-ppT00:00:00'" + Address: + type: object + description: "Osoitetiedot" + required: ["type", "source"] + properties: + type: + type: integer + format: int32 + description: "Osoitteen laji, käyntiosoite: 1, postiosoite: 2. " + street: + type: string + nullable: true + description: "Katuosoite" + minLength: 0 + maxLength: 50 + postCode: + type: string + nullable: true + description: "Postinumero" + minLength: 0 + maxLength: 5 + postOffices: + type: array + description: "Postitoimipaikka eri kielillä" + items: + $ref: '#/components/schemas/PostOffice' + minItems: 0 + postOfficeBox: + type: string + nullable: true + description: "Postilokero" + minLength: 0 + maxLength: 5 + buildingNumber: + type: string + nullable: true + description: "Talo" + minLength: 0 + maxLength: 13 + entrance: + type: string + nullable: true + description: "Porras" + minLength: 0 + maxLength: 13 + apartmentNumber: + type: string + nullable: true + description: "Huoneisto" + minLength: 0 + maxLength: 4 + apartmentIdSuffix: + type: string + nullable: true + description: "Jakokirjain" + minLength: 0 + maxLength: 1 + co: + type: string + nullable: true + description: "Osoitteen c/o-tieto" + minLength: 0 + maxLength: 34 + country: + type: string + nullable: true + description: "Kaksikirjaiminen maakoodi" + minLength: 0 + maxLength: 2 + freeAddressLine: + type: string + nullable: true + description: "Vapaamuotoinen osoite esim. ulkomaalaiselle osoitteelle. Rivinvaihdot korvattu välilyönnillä, välilyönnit korvattu alaviivalla esim Norgårdsvägen_3 _ SE-451_75 Uddevalla" + minLength: 0 + maxLength: 1000 + registrationDate: + type: string + nullable: true + format : date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + RegisterName: + type: object + description: "Nimitiedot" + required: ["name", "type", "version", "source"] + properties: + name: + type: string + description: "Toiminimi" + minLength: 0 + maxLength: 1000 + type: + type: string + description: > + [Nimen tyyppi](/opendata-ytj-api/v3/description?code=TLAJI&lang=fi) + minLength: 1 + maxLength: 8 + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + version: + type: integer + format: int32 + description: "Versio, 1 nykyinen versio, muut numerot aiempia versioita" + source: + $ref: '#/components/schemas/Source' + CompanyForm: + type: object + required: ["type", "version", "source"] + description: "Yritysmuoto" + properties: + type: + type: string + description: > + [Yritysmuodon koodi](/opendata-ytj-api/v3/description?code=YRMU&lang=fi) + minLength: 1 + maxLength: 8 + descriptions: + type: array + description: "Yritysmuodon kuvaukset" + items: + $ref: '#/components/schemas/DescriptionEntry' + minItems: 0 + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + version: + type: integer + format: int32 + description: "Versio, 1 nykyinen versio, muut numerot aiempia versioita" + source: + $ref: '#/components/schemas/Source' + CompanySituation: + type: object + required: ["type", "source"] + description: "Yrityksen tilanne (mahdollinen saneeraus, selvitystila tai konkurssi)" + properties: + type: + type: string + description: > + [Yrityksen tilanteen koodi](/opendata-ytj-api/v3/description?code=SELTILA,SANE,KONK&lang=fi) + enum: ["SANE", "SELTILA", "KONK"] + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + source: + $ref: '#/components/schemas/Source' + RegisteredEntry: + type: object + required: ["type", "register", "authority"] + description: "Rekisterimerkinnät" + properties: + type: + type: string + description: > + [Yrityksen rekisteröintitilan koodi](/opendata-ytj-api/v3/description?code=REK_KDI&lang=fi). Tulkittava Koodistosta REK_KDI yhdistämällä rekisterimerkinnän koodi rekisterikoodiin erotettuna alaviivalla, esim '1_0' on rekisteröimätön kaupparekisterissä + minLength: 1 + maxLength: 8 + descriptions: + type: array + description: "Rekisteröintitilan kuvaukset" + items: + $ref: '#/components/schemas/DescriptionEntry' + minItems: 0 + registrationDate: + type: string + nullable: true + format: date + description: "Rekisteröintipäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + endDate: + type: string + nullable: true + format: date + description: "Rekisteröinnin loppupäivämäärä muodossa vvvv-kk-pp" + minLength: 10 + maxLength: 10 + register: + type: string + description: > + [Rekisterin koodi](/opendata-ytj-api/v3/description?code=REK&lang=fi) + minLength: 1 + maxLength: 8 + authority: + type: string + description: > + [Viranomaisen koodi](/opendata-ytj-api/v3/description?code=VIRANOM&lang=fi) + minLength: 1 + maxLength: 8 + Source: + type: string + description: > + Tietolähde + DescriptionEntry: + type: object + required: ["languageCode"] + description: "Tiedon kuvaus" + properties: + languageCode: + type: string + description: "Kielikoodi, 1 - Suomi, 2 - Ruotsi, 3 - Englanti" + maxLength: 2 + description: + type: string + description: "Koodiseloste" + nullable: true + maxLength: 255 + PostOffice: + type: object + required: ["city", "languageCode"] + description: "Postiosoitteet" + properties: + city: + type: string + description: "Postitoimipaikka" + maxLength: 50 + languageCode: + type: string + description: > + [Kielikoodi](/opendata-ytj-api/v3/description?code=KIELI&lang=fi) + maxLength: 8 + municipalityCode: + type: string + description: "Kuntakoodi" + nullable: true + minLength: 3 + maxLength: 3 + PostOfficeEntry: + type: object + required: ["postCode", "city", "active", "languageCode"] + description: "Postiosoitteet" + properties: + postCode: + type: string + description: "Postinumero" + minLength: 5 + maxLength: 5 + city: + type: string + description: "Postitoimipaikka" + maxLength: 50 + active: + type: boolean + description: "Onko tieto aktiivinen" + languageCode: + type: string + description: > + [Kielikoodi](/opendata-ytj-api/v3/description?code=KIELI&lang=fi) + maxLength: 8 + municipalityCode: + type: string + description: "Kuntakoodi" + nullable: true + minLength: 3 + maxLength: 3 + ErrorResponse: + type: object + required: [ "timestamp", "code" ] + description: "Virheilmoitus" + properties: + timestamp: + type: string + minLength: 19 + maxLength: 19 + format: date-time + x-field-extra-annotation: "@com.fasterxml.jackson.annotation.JsonFormat(shape = com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING, pattern = \"yyyy-MM-dd HH:mm:ss\")" + description: "Aika sekuntitasolla ilman aikavyöhykettä muodossa 'vvvv-kk-pp 00:00:00'" + message: + type: string + description: "Virheviesti" + maxLength: 1000 + errorcode: + type: integer + format: int32 + description: "Virheen koodi" + +externalDocs: + description: "Lisätiedot Swagger-ohjelmistosta" + url: 'http://swagger.io' \ No newline at end of file