diff --git a/backend/benefit/applications/api/v1/application_views.py b/backend/benefit/applications/api/v1/application_views.py index c1580744f9..264f16ea24 100755 --- a/backend/benefit/applications/api/v1/application_views.py +++ b/backend/benefit/applications/api/v1/application_views.py @@ -623,16 +623,9 @@ def get_queryset(self): ) @action(methods=["GET"], detail=True, url_path="clone_as_draft") @transaction.atomic - def clone_as_draft(self, request, pk=None) -> HttpResponse: + def clone_as_draft(self, request, pk) -> HttpResponse: application_base = self.get_object() - - clone_employee = request.query_params.get("employee") or None - clone_work = request.query_params.get("work") or None - clone_subsidies = request.query_params.get("pay_subsidy") or None - - cloned_application = clone_application_based_on_other( - application_base, clone_employee, clone_work, clone_subsidies - ) + cloned_application = clone_application_based_on_other(application_base) return Response( {"id": cloned_application.id}, @@ -667,7 +660,7 @@ def clone_as_draft(self, request, pk=None) -> HttpResponse: ) @action(methods=["GET"], detail=False, url_path="clone_latest") @transaction.atomic - def clone_latest(self, request, pk=None) -> HttpResponse: + def clone_latest(self, request) -> HttpResponse: company = get_company_from_request(request) try: @@ -688,13 +681,7 @@ def clone_latest(self, request, pk=None) -> HttpResponse: status=status.HTTP_404_NOT_FOUND, ) - clone_employee = request.query_params.get("employee") or None - clone_work = request.query_params.get("work") or None - clone_subsidies = request.query_params.get("pay_subsidy") or None - - cloned_application = clone_application_based_on_other( - application_base, clone_employee, clone_work, clone_subsidies - ) + cloned_application = clone_application_based_on_other(application_base) return Response( {"id": cloned_application.id}, @@ -831,6 +818,28 @@ def change_handler(self, request, pk=None) -> HttpResponse: application.save() return Response(status=status.HTTP_200_OK) + @action(methods=["GET"], detail=True, url_path="clone_as_draft") + @transaction.atomic + def clone_as_draft(self, request, pk) -> HttpResponse: + application_base = self.get_object() + cloned_application = clone_application_based_on_other(application_base, True) + + try: + cloned_application.full_clean() + cloned_application.employee.full_clean() + cloned_application.company.full_clean() + cloned_application.calculation.full_clean() + + except exceptions.ValidationError as e: + return Response( + {"detail": e.message_dict}, status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + {"id": cloned_application.id}, + status=status.HTTP_201_CREATED, + ) + def _create_application_batch(self, status) -> QuerySet[Application]: """ Create a new application batch out of the existing applications in the given status diff --git a/backend/benefit/applications/api/v1/serializers/application.py b/backend/benefit/applications/api/v1/serializers/application.py index 353d6fd80c..b0dfbf9da8 100755 --- a/backend/benefit/applications/api/v1/serializers/application.py +++ b/backend/benefit/applications/api/v1/serializers/application.py @@ -709,7 +709,7 @@ def _validate_association_immediate_manager_check( ) } ) - elif association_immediate_manager_check is not None: + elif association_immediate_manager_check not in [None, False]: raise serializers.ValidationError( { "association_immediate_manager_check": _( @@ -907,11 +907,12 @@ def _validate_non_draft_required_fields(self, data): def _validate_association_has_business_activities( self, company, association_has_business_activities ): - if ( - OrganizationType.resolve_organization_type(company.company_form_code) - == OrganizationType.COMPANY - and association_has_business_activities is not None - ): + if OrganizationType.resolve_organization_type( + company.company_form_code + ) == OrganizationType.COMPANY and association_has_business_activities not in [ + None, + False, + ]: raise serializers.ValidationError( { "association_has_business_activities": _( diff --git a/backend/benefit/applications/services/clone_application.py b/backend/benefit/applications/services/clone_application.py index 41742b131d..443b6ac33b 100644 --- a/backend/benefit/applications/services/clone_application.py +++ b/backend/benefit/applications/services/clone_application.py @@ -1,25 +1,37 @@ -from applications.enums import ApplicationStep -from applications.models import Application, DeMinimisAid, Employee +from applications.enums import ApplicationStatus, ApplicationStep +from applications.models import ( + Application, + ApplicationLogEntry, + Attachment, + DeMinimisAid, + Employee, +) +from calculator.models import Calculation from companies.models import Company +from helsinkibenefit.settings import MEDIA_ROOT def clone_application_based_on_other( application_base, - clone_employee=False, - clone_work=False, - clone_subsidies=False, + clone_all_data=False, ): company = Company.objects.get(id=application_base.company.id) + company.street_address = ( + company.street_address if len(company.street_address) > 0 else "Testikatu 123" + ) cloned_application = Application( **{ "alternative_company_city": application_base.alternative_company_city, "alternative_company_postcode": application_base.alternative_company_postcode, "alternative_company_street_address": application_base.alternative_company_street_address, "applicant_language": "fi", + "application_origin": application_base.application_origin, "application_step": ApplicationStep.STEP_1, "archived": False, - "association_has_business_activities": application_base.association_has_business_activities, - "association_immediate_manager_check": application_base.association_immediate_manager_check, + "association_has_business_activities": application_base.association_has_business_activities + or False, + "association_immediate_manager_check": application_base.association_immediate_manager_check + or False, "benefit_type": "salary_benefit", "co_operation_negotiations": application_base.co_operation_negotiations, "co_operation_negotiations_description": application_base.co_operation_negotiations_description, @@ -29,58 +41,126 @@ def clone_application_based_on_other( "company_contact_person_last_name": application_base.company_contact_person_last_name, "company_contact_person_phone_number": application_base.company_contact_person_phone_number, "company_department": application_base.company_department, + "company_form": application_base.company_form, "company_form_code": company.company_form_code, + "company_name": application_base.company_name, "de_minimis_aid": application_base.de_minimis_aid, - "status": "draft", + "status": ApplicationStatus.DRAFT, + "official_company_street_address": company.street_address, + "official_company_city": application_base.official_company_city, + "official_company_postcode": application_base.official_company_postcode, "use_alternative_address": application_base.use_alternative_address, } ) + company.save() cloned_application.company = company - if clone_employee or clone_work: - employee = Employee.objects.get(id=application_base.employee.id) - employee.pk = None - employee.application = cloned_application + de_minimis_aids = application_base.de_minimis_aid_set.all() + if de_minimis_aids.exists(): + cloned_application.de_minimis_aid = True + + last_order = DeMinimisAid.objects.last().ordering + 1 + for index, aid in enumerate(de_minimis_aids): + aid.pk = None + aid.ordering = last_order + index + aid.application = cloned_application + aid.save() + + if clone_all_data: + cloned_application = _clone_handler_data(application_base, cloned_application) else: employee = Employee.objects.create(application=cloned_application) - - if not clone_employee: employee.first_name = "" employee.last_name = "" employee.social_security_number = "" employee.is_living_in_helsinki = False - - if not clone_work: employee.job_title = "" employee.monthly_pay = None employee.vacation_money = None employee.other_expenses = None employee.working_hours = None employee.collective_bargaining_agreement = "" + employee.save() + cloned_application.save() + return cloned_application + + +def _clone_handler_data(application_base, cloned_application): + employee = Employee.objects.get(id=application_base.employee.id) + employee.pk = None + employee.application = cloned_application employee.save() - if clone_subsidies: - cloned_application.pay_subsidy_granted = application_base.pay_subsidy_granted - cloned_application.apprenticeship_program = ( - application_base.apprenticeship_program - ) - cloned_application.pay_subsidy_percent = application_base.pay_subsidy_percent + cloned_application.handled_by_ahjo_automation = ( + application_base.handled_by_ahjo_automation + ) + cloned_application.paper_application_date = application_base.paper_application_date + cloned_application.applicant_language = application_base.applicant_language + cloned_application.pay_subsidy_granted = application_base.pay_subsidy_granted + cloned_application.apprenticeship_program = ( + application_base.apprenticeship_program or False + ) + cloned_application.pay_subsidy_percent = application_base.pay_subsidy_percent + cloned_application.additional_pay_subsidy_percent = ( + application_base.additional_pay_subsidy_percent + ) - de_minimis_aids = DeMinimisAid.objects.filter( - application__pk=application_base.id - ).all() + # Create fake image to be used as attachment's body + from PIL import Image - if de_minimis_aids.exists(): - cloned_application.de_minimis_aid = True + attachment_name = f"test-application-{cloned_application.id}" + temp_image = Image.new("RGB", (1, 1)) + temp_image.save( + format="PNG", + fp=f"{MEDIA_ROOT}/{attachment_name}.png", + ) - last_order = DeMinimisAid.objects.last().ordering + 1 - for index, aid in enumerate(de_minimis_aids): - aid.pk = None - aid.ordering = last_order + index - aid.application = cloned_application - aid.save() + # Mimick the attachments by retaining attachment type + for base_attachment in application_base.attachments.all(): + Attachment.objects.create( + attachment_type=base_attachment.attachment_type, + application=cloned_application, + attachment_file=f"{attachment_name}.png", + content_type="image/png", + ) + # Clone calculation with compensations and pay subsidies + cloned_application.start_date = application_base.start_date + cloned_application.end_date = application_base.end_date cloned_application.save() + + calculation_base = application_base.calculation + Calculation.objects.create_for_application( + cloned_application, + start_date=calculation_base.start_date, + end_date=calculation_base.end_date, + state_aid_max_percentage=calculation_base.state_aid_max_percentage, + override_monthly_benefit_amount=calculation_base.override_monthly_benefit_amount, + override_monthly_benefit_amount_comment=calculation_base.override_monthly_benefit_amount_comment, + ) + + cloned_application.calculation.calculate(override_status=True) + cloned_application.calculation.save() + + for compensation in application_base.training_compensations.all(): + compensation.pk = None + compensation.application = cloned_application + compensation.save() + + # Remove pay subsidies made by create_for_application, then clone the old ones + cloned_application.pay_subsidies.filter(start_date__isnull=True).delete() + for pay_subsidy in application_base.pay_subsidies.all(): + pay_subsidy.pk = None + pay_subsidy.application = cloned_application + pay_subsidy.save() + + cloned_application.status = ApplicationStatus.RECEIVED + ApplicationLogEntry.objects.create( + application=cloned_application, + from_status=ApplicationStatus.DRAFT, + to_status=cloned_application.status, + comment="", + ) return cloned_application diff --git a/backend/benefit/applications/tests/test_application_clone.py b/backend/benefit/applications/tests/test_application_clone.py new file mode 100755 index 0000000000..04ad6561af --- /dev/null +++ b/backend/benefit/applications/tests/test_application_clone.py @@ -0,0 +1,414 @@ +import uuid +from datetime import date, datetime + +from freezegun import freeze_time +from freezegun.api import FakeDate +from rest_framework.reverse import reverse + +from applications.enums import ( + ApplicationBatchStatus, + ApplicationOrigin, + ApplicationStatus, + ApplicationStep, + ApplicationTalpaStatus, + BenefitType, + PaySubsidyGranted, +) +from applications.tests.conftest import Application +from applications.tests.factories import ( + ApplicationBatchFactory, + AttachmentFactory, + DecidedApplicationFactory, +) +from shared.common.tests.factories import UserFactory + +# from common.tests.conftest import * # noqa +# from companies.tests.conftest import * # noqa +# from helsinkibenefit.tests.conftest import * # noqa +# from terms.tests.conftest import * # noqa + +application_fields = { + "copied": [ + "additional_pay_subsidy_percent", + "alternative_company_city", + "alternative_company_postcode", + "alternative_company_street_address", + "applicant_language", + "application_origin", + "apprenticeship_program", + "association_has_business_activities", + "association_immediate_manager_check", + "benefit_type", + "co_operation_negotiations", + "co_operation_negotiations_description", + "company", + "company_bank_account_number", + "company_contact_person_email", + "company_contact_person_first_name", + "company_contact_person_last_name", + "company_contact_person_phone_number", + "company_department", + "company_form", + "company_form_code", + "company_name", + "de_minimis_aid", + "de_minimis_aid_set", + "end_date", + "handled_by_ahjo_automation", + "official_company_city", + "official_company_postcode", + "official_company_street_address", + "paper_application_date", + "pay_subsidy_granted", + "pay_subsidy_percent", + "start_date", + "use_alternative_address", + ], + "not_copied": [ + "ahjo_case_guid", + "ahjo_case_id", + "application_number", + "application_step", + "archived", + "batch", + "created_at", + "handler", + "id", + "modified_at", + "status", + "talpa_status", + ], +} + +attachment_fields = { + "copied": [ + "attachment_type", + ], + "not_copied": [ + "id", + "created_at", + "modified_at", + "application", + "content_type", + "attachment_file_name", + "attachment_file", + "ahjo_version_series_id", + ], + "skipped": ["ahjo_version_series_id", "downloaded_by_ahjo", "ahjo_hash_value"], + "attachment_types": [ + "employment_contract", + "pay_subsidy_decision", + "education_contract", + "helsinki_benefit_voucher", + "employee_consent", + ], +} + +calculator_fields = { + "not_copied": [ + "id", + "created_at", + "modified_at", + "handler", + "application", + ], + "copied": [ + "monthly_pay", + "vacation_money", + "other_expenses", + "start_date", + "end_date", + "state_aid_max_percentage", + "calculated_benefit_amount", + "override_monthly_benefit_amount", + "granted_as_de_minimis_aid", + "target_group_check", + "override_monthly_benefit_amount_comment", + ], +} + +pay_subsidy_fields = { + "copied": [ + "start_date", + "end_date", + "pay_subsidy_percent", + "work_time_percent", + "ordering", + "disability_or_illness", + ], + "not_copied": [ + "created_at", + "modified_at", + "id", + "application", + ], +} + +training_compensation_fields = { + "copied": ["start_date", "end_date", "monthly_amount", "ordering"], + "not_copied": [ + "created_at", + "modified_at", + "id", + "application", + ], +} + +de_minimis_aid_fields = { + "copied": [ + "amount", + "granted_at", + "granter", + ], + "not_copied": [ + "created_at", + "modified_at", + "id", + "ordering", + "application", + ], +} + +employee_fields = { + "copied": [ + "first_name", + "last_name", + "social_security_number", + "is_living_in_helsinki", + "job_title", + "monthly_pay", + "vacation_money", + "other_expenses", + "working_hours", + "collective_bargaining_agreement", + "encrypted_social_security_number", + "encrypted_first_name", + "encrypted_last_name", + "phone_number", + "email", + "employee_language", + "commission_amount", + "commission_description", + ], + "not_copied": ["created_at", "modified_at", "id", "application"], +} + + +def test_application_full_clone(api_client, handler_api_client): + application = _set_up_decided_application() + # Endpoint should only be available for handlers + response = api_client.get( + reverse("v1:handler-application-clone-as-draft", kwargs={"pk": application.id}), + ) + assert response.status_code == 403 + + # Access endpoint as a handler and a bit later than the original application created at + with freeze_time("2024-10-23"): + response = handler_api_client.get( + reverse( + "v1:handler-application-clone-as-draft", kwargs={"pk": application.id} + ), + ) + + assert response.status_code == 201 + data = response.json() + assert len(data["id"]) == 36 # Has to return a valid UUID + + cloned_application = Application.objects.get(id=data["id"]) + assert cloned_application + + _check_application_fields(application, cloned_application) + _check_company(application, cloned_application) + _check_employee_fields(application, cloned_application) + _check_calculation_fields(application, cloned_application) + _check_pay_subsidies(application, cloned_application) + _check_training_compensations(application, cloned_application) + _check_de_minimis_aids(application, cloned_application) + _check_attachments(application, cloned_application) + + +def _set_up_decided_application(): + application = DecidedApplicationFactory( + benefit_type=BenefitType.SALARY_BENEFIT, + ahjo_case_id="HEL 2024-123456", + ahjo_case_guid=uuid.uuid4(), + archived=True, + apprenticeship_program=True, + association_has_business_activities=False, + handled_by_ahjo_automation=True, + co_operation_negotiations=True, + co_operation_negotiations_description="Very co-operation. Much contract.", + paper_application_date="2024-10-23", + pay_subsidy_granted=PaySubsidyGranted.GRANTED, + application_origin=ApplicationOrigin.HANDLER, + application_step=ApplicationStep.STEP_6, + batch=ApplicationBatchFactory( + proposal_for_decision=ApplicationStatus.ACCEPTED, + status=ApplicationBatchStatus.COMPLETED, + ), + handler=UserFactory(is_staff=True, is_active=True, is_superuser=True), + talpa_status=ApplicationTalpaStatus.SUCCESSFULLY_SENT_TO_TALPA, + use_alternative_address=True, + ) + application.official_company_street_address = application.company.street_address + application.calculation.monthly_pay = application.employee.monthly_pay + application.calculation.vacation_money = application.employee.vacation_money + application.calculation.other_expenses = application.employee.other_expenses + application.calculation.save() + application.calculation.init_calculator() + application.calculation.calculate() + application.association_immediate_manager_check = False + application.pay_subsidies.all().update(disability_or_illness=True) + + for attachment_type in attachment_fields["attachment_types"]: + AttachmentFactory( + application=application, + attachment_type=attachment_type, + ) + + application.save() + + return application + + +def _check_fields(original, cloned, fields): + types_to_compare_as_string = (datetime, date, uuid.UUID, FakeDate) + + for field in original._meta.fields: + if fields.get("skipped") and field.name in fields["skipped"]: + continue + + original_value = ( + str(getattr(original, field.name)) + if isinstance(getattr(original, field.name), types_to_compare_as_string) + else getattr(original, field.name) + ) + cloned_value = ( + str(getattr(cloned, field.name)) + if isinstance(getattr(cloned, field.name), types_to_compare_as_string) + else getattr(cloned, field.name) + ) + if field.name in fields["copied"]: + assert ( + original_value == cloned_value + ), f"Field {field.name} should match, {original_value} != {cloned_value}" + + if field.name in fields["not_copied"]: + assert ( + original_value != cloned_value + ), f"Field {field.name} should not match, {original_value} != {cloned_value}" + + if field.name not in fields["copied"] + fields["not_copied"]: + assert False, f"Unidentified field '{field.name}', please take this " + "field into account when renaming, removing, or adding fields" + + +def _check_application_fields(application, cloned_application): + _check_fields(application, cloned_application, application_fields) + + +def _check_company(application, cloned_application): + assert application.company.id == cloned_application.company.id + + +def _check_calculation_fields(application, cloned_application): + _check_fields( + application.calculation, cloned_application.calculation, calculator_fields + ) + + +def _check_pay_subsidies(application, cloned_application): + original_pay_subsidies = list(application.pay_subsidies.all()) + cloned_pay_subsidies = list(cloned_application.pay_subsidies.all()) + + assert len(original_pay_subsidies) == len( + cloned_pay_subsidies + ), "Number of pay subsidies should match" + + for original_pay_subsidy in original_pay_subsidies: + matched_subsidy = next( + ( + cloned_pay_subsidy + for cloned_pay_subsidy in cloned_pay_subsidies + if cloned_pay_subsidy.start_date == original_pay_subsidy.start_date + and cloned_pay_subsidy.end_date == original_pay_subsidy.end_date + and cloned_pay_subsidy.pay_subsidy_percent + == original_pay_subsidy.pay_subsidy_percent + and cloned_pay_subsidy.work_time_percent + == original_pay_subsidy.work_time_percent + ), + None, + ) + assert ( + matched_subsidy is not None + ), "Matching pay subsidy not found in cloned subsidies" + _check_fields(original_pay_subsidy, matched_subsidy, pay_subsidy_fields) + + +def _check_training_compensations(application, cloned_application): + original_training_compensations = list(application.training_compensations.all()) + cloned_training_compensations = list( + cloned_application.training_compensations.all() + ) + + assert len(cloned_training_compensations) == len( + original_training_compensations + ), "Number of training compensations should match" + + for original_compensation in original_training_compensations: + matched_compensation = next( + ( + cloned_compensation + for cloned_compensation in cloned_training_compensations + if cloned_compensation.start_date == original_compensation.start_date + and cloned_compensation.end_date == original_compensation.end_date + and cloned_compensation.monthly_amount + == original_compensation.monthly_amount + and cloned_compensation.ordering == original_compensation.ordering + ), + None, + ) + assert ( + matched_compensation is not None + ), "Matching compensation not found in cloned compensations" + _check_fields( + original_compensation, matched_compensation, training_compensation_fields + ) + + +def _check_de_minimis_aids(application, cloned_application): + original_de_minimis_aids = list(application.de_minimis_aid_set.all()) + cloned_de_minimis_aids = list(cloned_application.de_minimis_aid_set.all()) + + assert len(cloned_de_minimis_aids) == len( + original_de_minimis_aids + ), "Number of de minimis aids should match" + + for original_aid in original_de_minimis_aids: + matched_aid = next( + ( + cloned_aid + for cloned_aid in cloned_de_minimis_aids + if cloned_aid.amount == original_aid.amount + and cloned_aid.granted_at == original_aid.granted_at + and cloned_aid.granter == original_aid.granter + ), + None, + ) + assert matched_aid is not None, "Matching aid not found in cloned aids" + _check_fields(original_aid, matched_aid, de_minimis_aid_fields) + + +def _check_attachments(application, cloned_application): + for attachment_type in attachment_fields["attachment_types"]: + original_attachment = application.attachments.filter( + attachment_type=attachment_type + ).first() + cloned_attachment = cloned_application.attachments.filter( + attachment_type=attachment_type + ).first() + _check_fields(original_attachment, cloned_attachment, attachment_fields) + + +def _check_employee_fields(application, cloned_application): + _check_fields(application.employee, cloned_application.employee, employee_fields) diff --git a/backend/benefit/applications/tests/test_applications_api.py b/backend/benefit/applications/tests/test_applications_api.py index a8ed5c5de7..1417b5ab63 100755 --- a/backend/benefit/applications/tests/test_applications_api.py +++ b/backend/benefit/applications/tests/test_applications_api.py @@ -992,16 +992,6 @@ def test_application_edit_benefit_type_non_business_invalid( assert response.status_code == 400 -def test_association_immediate_manager_check_invalid(api_client, application): - data = ApplicantApplicationSerializer(application).data - data["association_immediate_manager_check"] = False # invalid value - response = api_client.put( - get_detail_url(application), - data, - ) - assert response.status_code == 400 - - def test_association_immediate_manager_check_valid(api_client, association_application): data = ApplicantApplicationSerializer(association_application).data data["association_immediate_manager_check"] = True # valid value for associations diff --git a/backend/benefit/calculator/models.py b/backend/benefit/calculator/models.py index f58c546c1f..6b8f974a19 100644 --- a/backend/benefit/calculator/models.py +++ b/backend/benefit/calculator/models.py @@ -199,9 +199,11 @@ def init_calculator(self): self.calculator = HelsinkiBenefitCalculator.get_calculator(self) return self.calculator - def calculate(self): + def calculate(self, override_status=False): + """Do the calculation again. Override status is used to force the calculation when cloning an application""" + try: - return self.init_calculator().calculate() + return self.init_calculator().calculate(override_status=override_status) finally: # Do not leave the calculator instance around. If parameters are changed, # a different calculator may be needed in the next run diff --git a/backend/benefit/calculator/rules.py b/backend/benefit/calculator/rules.py index 3d0800f893..fb1bdd411d 100644 --- a/backend/benefit/calculator/rules.py +++ b/backend/benefit/calculator/rules.py @@ -156,8 +156,11 @@ def can_calculate(self): return True @transaction.atomic - def calculate(self): - if self.calculation.application.status in self.CALCULATION_ALLOWED_STATUSES: + def calculate(self, override_status=False): + if ( + self.calculation.application.status in self.CALCULATION_ALLOWED_STATUSES + or override_status + ): self.calculation.rows.all().delete() if self.can_calculate(): self.create_rows() diff --git a/backend/benefit/companies/migrations/0005_alter_company_bank_account_number.py b/backend/benefit/companies/migrations/0005_alter_company_bank_account_number.py new file mode 100644 index 0000000000..021604b66d --- /dev/null +++ b/backend/benefit/companies/migrations/0005_alter_company_bank_account_number.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.11 on 2024-09-26 12:09 + +import common.localized_iban_field +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('companies', '0004_localized_iban_field'), + ] + + operations = [ + migrations.AlterField( + model_name='company', + name='bank_account_number', + field=common.localized_iban_field.LocalizedIBANField(blank=True, include_countries=('FI',), max_length=34, use_nordea_extensions=False, verbose_name='bank account number'), + ), + ] diff --git a/backend/benefit/companies/models.py b/backend/benefit/companies/models.py index c4cbaa9530..b41d794e21 100644 --- a/backend/benefit/companies/models.py +++ b/backend/benefit/companies/models.py @@ -7,7 +7,7 @@ class Company(AbstractCompany): bank_account_number = LocalizedIBANField( - include_countries=("FI",), verbose_name=_("bank account number") + include_countries=("FI",), verbose_name=_("bank account number"), blank=True ) company_form_code = models.IntegerField( verbose_name=_("YTJ type code for company form") diff --git a/frontend/benefit/handler/public/locales/en/common.json b/frontend/benefit/handler/public/locales/en/common.json index c65443badb..89c1718755 100644 --- a/frontend/benefit/handler/public/locales/en/common.json +++ b/frontend/benefit/handler/public/locales/en/common.json @@ -1016,6 +1016,7 @@ "handlingPanel": "Käsittelypaneeli", "search": "Hae arkistosta", "cancel": "Peruuta hakemus", + "clone": "Kloonaa hakemus", "addAttachment": "Liitä uusi tiedosto", "addPreviouslyGrantedBenefit": "Lisää aikaisempi lisä", "targetGroupCheck": "Kohderyhmätarkistus", diff --git a/frontend/benefit/handler/public/locales/fi/common.json b/frontend/benefit/handler/public/locales/fi/common.json index d6f0763eb7..f058ef82f5 100644 --- a/frontend/benefit/handler/public/locales/fi/common.json +++ b/frontend/benefit/handler/public/locales/fi/common.json @@ -1016,6 +1016,7 @@ "handlingPanel": "Käsittelypaneeli", "search": "Hae arkistosta", "cancel": "Peruuta hakemus", + "clone": "Kloonaa hakemus", "addAttachment": "Liitä uusi tiedosto", "addPreviouslyGrantedBenefit": "Lisää aikaisempi lisä", "targetGroupCheck": "Kohderyhmätarkistus", diff --git a/frontend/benefit/handler/public/locales/sv/common.json b/frontend/benefit/handler/public/locales/sv/common.json index c65443badb..89c1718755 100644 --- a/frontend/benefit/handler/public/locales/sv/common.json +++ b/frontend/benefit/handler/public/locales/sv/common.json @@ -1016,6 +1016,7 @@ "handlingPanel": "Käsittelypaneeli", "search": "Hae arkistosta", "cancel": "Peruuta hakemus", + "clone": "Kloonaa hakemus", "addAttachment": "Liitä uusi tiedosto", "addPreviouslyGrantedBenefit": "Lisää aikaisempi lisä", "targetGroupCheck": "Kohderyhmätarkistus", diff --git a/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx b/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx index 46b4203fbc..dcfbd6bd53 100644 --- a/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx @@ -1,11 +1,19 @@ +import EditAction from 'benefit/handler/components/applicationReview/actions/editAction/EditAction'; import Sidebar from 'benefit/handler/components/sidebar/Sidebar'; import { APPLICATION_LIST_TABS } from 'benefit/handler/constants'; +import useDecisionProposalDraftMutation from 'benefit/handler/hooks/applicationHandling/useDecisionProposalDraftMutation'; +import { + StepActionType, + StepStateType, +} from 'benefit/handler/hooks/applicationHandling/useHandlingStepper'; +import useCloneApplicationMutation from 'benefit/handler/hooks/useCloneApplicationMutation'; import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { Application } from 'benefit-shared/types/application'; import { Button, IconArrowLeft, IconArrowRight, + IconCopy, IconInfoCircle, IconLock, IconPen, @@ -22,12 +30,6 @@ import { focusAndScrollToSelector, } from 'shared/utils/dom.utils'; -import useDecisionProposalDraftMutation from '../../../../hooks/applicationHandling/useDecisionProposalDraftMutation'; -import { - StepActionType, - StepStateType, -} from '../../../../hooks/applicationHandling/useHandlingStepper'; -import EditAction from '../editAction/EditAction'; import CancelModalContent from './CancelModalContent/CancelModalContent'; import DoneModalContent from './DoneModalContent/DoneModalContent'; import { @@ -256,12 +258,25 @@ const HandlingApplicationActions: React.FC = ({ const handleClose = (): void => navigateToIndex(); + const { data: clonedData, mutate: cloneApplication } = + useCloneApplicationMutation(); + + React.useEffect(() => { + if (clonedData?.id) void router.push(`/application?id=${clonedData.id}`); + }, [clonedData?.id, router]); + + const handleClone = (): void => + // eslint-disable-next-line no-alert + window.confirm('Haluatko varmasti kloonata tämän hakemuksen?') && + cloneApplication(application.id); + return ( <$Wrapper data-testid={dataTestId}> <$Column> + {application.status === APPLICATION_STATUSES.HANDLING && ( + {process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== 'production' && + [ + APPLICATION_STATUSES.ACCEPTED, + APPLICATION_STATUSES.REJECTED, + APPLICATION_STATUSES.HANDLING, + APPLICATION_STATUSES.RECEIVED, + ].includes(application.status) && ( + + )} + {![ APPLICATION_STATUSES.CANCELLED, APPLICATION_STATUSES.ACCEPTED, diff --git a/frontend/benefit/handler/src/hooks/useCloneApplicationMutation.tsx b/frontend/benefit/handler/src/hooks/useCloneApplicationMutation.tsx new file mode 100644 index 0000000000..22431c5d31 --- /dev/null +++ b/frontend/benefit/handler/src/hooks/useCloneApplicationMutation.tsx @@ -0,0 +1,64 @@ +import { AxiosError } from 'axios'; +import { HandlerEndpoint } from 'benefit-shared/backend-api/backend-api'; +import { prettyPrintObject } from 'benefit-shared/utils/errors'; +import camelcaseKeys from 'camelcase-keys'; +import { useTranslation } from 'next-i18next'; +import React from 'react'; +import { useMutation, UseMutationResult } from 'react-query'; +import showErrorToast from 'shared/components/toast/show-error-toast'; +import useBackendAPI from 'shared/hooks/useBackendAPI'; + +type Response = { + id?: string; +}; + +const useCloneApplicationMutation = (): UseMutationResult< + Response, + unknown, + string +> => { + const { axios, handleResponse } = useBackendAPI(); + const { t } = useTranslation(); + + return useMutation( + 'clone_application', + (id?: string) => + handleResponse( + axios.get(HandlerEndpoint.HANDLER_APPLICATIONS_CLONE_AS_DRAFT(id)) + ), + { + onError: (error: AxiosError) => { + if (error?.response?.status === 404) + showErrorToast( + t('common:applications.errors.cloneError.title'), + t('common:applications.errors.cloneError.message') + ); + else { + const errorData = camelcaseKeys(error.response?.data ?? {}); + + const isContentTypeHTML = typeof errorData === 'string'; + const errorString = isContentTypeHTML + ? t('common:error.generic.text') + : null; + if (errorString) { + showErrorToast('Error cloning application', errorString); + return; + } + + const errorElements = Object.entries(errorData).map(([key, value]) => + typeof value === 'string' ? ( + {value} + ) : ( + prettyPrintObject({ + data: value as Record, + }) + ) + ); + showErrorToast('Error cloning application', errorElements); + } + }, + } + ); +}; + +export default useCloneApplicationMutation; diff --git a/frontend/benefit/shared/src/backend-api/backend-api.ts b/frontend/benefit/shared/src/backend-api/backend-api.ts index 74551ed7f4..a82a3adec3 100644 --- a/frontend/benefit/shared/src/backend-api/backend-api.ts +++ b/frontend/benefit/shared/src/backend-api/backend-api.ts @@ -30,28 +30,34 @@ export const BackendEndpoint = { AHJO_SETTINGS: 'v1/ahjosettings/decision-maker/', APPLICATIONS_CLONE_AS_DRAFT: 'v1/applications/clone_as_draft/', APPLICATIONS_CLONE_LATEST: 'v1/applications/clone_latest/', + HANDLER_APPLICATIONS_CLONE_AS_DRAFT: 'v1/handlerapplications/clone_as_draft/', } as const; -const singleBatchBase = (id: string): string => +const batchBase = (id: string): string => `${BackendEndpoint.APPLICATION_BATCHES}${id}/`; +const handlerApplicationsBase = (id: string): string => + `${BackendEndpoint.HANDLER_APPLICATIONS}${id}/`; + export const HandlerEndpoint = { BATCH_APP_ASSIGN: `${BackendEndpoint.APPLICATION_BATCHES}assign_applications/`, BATCH_APP_DEASSIGN: (id: string): string => - `${singleBatchBase(id)}deassign_applications/`, - BATCH_STATUS_CHANGE: (id: string): string => `${singleBatchBase(id)}status/`, + `${batchBase(id)}deassign_applications/`, + BATCH_STATUS_CHANGE: (id: string): string => `${batchBase(id)}status/`, BATCH_DOWNLOAD_PDF_FILES: (id: string): string => `${BackendEndpoint.HANDLER_APPLICATIONS}batch_pdf_files?batch_id=${id}`, BATCH_DOWNLOAD_P2P_FILE: (id: string): string => `${BackendEndpoint.HANDLER_APPLICATIONS}batch_p2p_file?batch_id=${id}`, + HANDLER_APPLICATIONS_CLONE_AS_DRAFT: (id: string) => + `${handlerApplicationsBase(id)}clone_as_draft/`, } as const; -const singleApplicationBase = (id: string): string => +const applicationsBase = (id: string): string => `${BackendEndpoint.APPLICATIONS}${id}/`; export const ApplicantEndpoint = { APPLICATIONS_CLONE_AS_DRAFT: (id: string) => - `${singleApplicationBase(id)}clone_as_draft/`, + `${applicationsBase(id)}clone_as_draft/`, } as const; export const BackendEndPoints = Object.values(BackendEndpoint); diff --git a/frontend/shared/src/components/toast/show-error-toast.ts b/frontend/shared/src/components/toast/show-error-toast.ts index 86dd350906..3914488bbf 100644 --- a/frontend/shared/src/components/toast/show-error-toast.ts +++ b/frontend/shared/src/components/toast/show-error-toast.ts @@ -2,7 +2,7 @@ import Toast from 'shared/components/toast/Toast'; const showErrorToast = ( title: string, - message: string, + message: string | JSX.Element | Array, autoDismissTime = 5000 ): void => void Toast({