diff --git a/apps/accounting_exports/models.py b/apps/accounting_exports/models.py index c146fec5..e6843db9 100644 --- a/apps/accounting_exports/models.py +++ b/apps/accounting_exports/models.py @@ -47,7 +47,7 @@ def _group_expenses(expenses: List[Expense], export_setting: ExportSetting, fund reimbursable_expense_date = export_setting.reimbursable_expense_date default_fields = ['employee_email', 'fund_source'] - report_grouping_fields = ['report_id', 'claim_number'] + report_grouping_fields = ['report_id', 'claim_number', 'corporate_card_id'] expense_grouping_fields = ['expense_id', 'expense_number'] # Define a mapping for fund sources and their associated group fields diff --git a/apps/fyle/signals.py b/apps/fyle/signals.py index 71d6f365..b18abf3f 100644 --- a/apps/fyle/signals.py +++ b/apps/fyle/signals.py @@ -3,15 +3,13 @@ """ import logging -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import pre_save from django.dispatch import receiver from fyle_integrations_platform_connector import PlatformConnector from apps.workspaces.models import FyleCredential from apps.fyle.models import DependentFieldSetting from apps.sage300.dependent_fields import create_dependent_custom_field_in_fyle -from apps.mappings.imports.schedules import schedule_or_delete_dependent_field_tasks - logger = logging.getLogger(__name__) logger.level = logging.INFO @@ -51,13 +49,3 @@ def run_pre_save_dependent_field_settings_triggers(sender, instance: DependentFi parent_field_id=instance.cost_code_field_id, ) instance.cost_category_field_id = cost_category['data']['id'] - - -@receiver(post_save, sender=DependentFieldSetting) -def run_post_save_dependent_field_settings_triggers(sender, instance: DependentFieldSetting, **kwargs): - """ - :param sender: Sender Class - :param instance: Row instance of Sender Class - :return: None - """ - schedule_or_delete_dependent_field_tasks(instance.workspace_id) diff --git a/apps/mappings/imports/queues.py b/apps/mappings/imports/queues.py index acadcb6d..232cc356 100644 --- a/apps/mappings/imports/queues.py +++ b/apps/mappings/imports/queues.py @@ -1,6 +1,7 @@ from django_q.tasks import Chain from fyle_accounting_mappings.models import MappingSetting from apps.workspaces.models import ImportSetting +from apps.fyle.models import DependentFieldSetting def chain_import_fields_to_fyle(workspace_id): @@ -11,6 +12,9 @@ def chain_import_fields_to_fyle(workspace_id): mapping_settings = MappingSetting.objects.filter(workspace_id=workspace_id, import_to_fyle=True) custom_field_mapping_settings = MappingSetting.objects.filter(workspace_id=workspace_id, is_custom=True, import_to_fyle=True) import_settings = ImportSetting.objects.get(workspace_id=workspace_id) + dependent_field_settings = DependentFieldSetting.objects.filter(workspace_id=workspace_id, is_import_enabled=True).first() + project_mapping = MappingSetting.objects.filter(workspace_id=workspace_id, source_field='PROJECT', import_to_fyle=True).first() + chain = Chain() if import_settings.import_categories: @@ -47,5 +51,11 @@ def chain_import_fields_to_fyle(workspace_id): True ) + if project_mapping and dependent_field_settings: + chain.append( + 'apps.mappings.imports.tasks.auto_import_and_map_fyle_fields', + workspace_id + ) + if chain.length() > 0: chain.run() diff --git a/apps/mappings/imports/schedules.py b/apps/mappings/imports/schedules.py index 2de4bb55..698f13d6 100644 --- a/apps/mappings/imports/schedules.py +++ b/apps/mappings/imports/schedules.py @@ -1,41 +1,9 @@ from datetime import datetime from django_q.models import Schedule from fyle_accounting_mappings.models import MappingSetting - -from apps.fyle.models import DependentFieldSetting from apps.workspaces.models import ImportSetting -def schedule_or_delete_dependent_field_tasks(workspace_id: int): - """ - :param configuration: Workspace Configuration Instance - :return: None - """ - project_mapping = MappingSetting.objects.filter( - source_field='PROJECT', - workspace_id=workspace_id, - import_to_fyle=True - ).first() - dependent_fields = DependentFieldSetting.objects.filter(workspace_id=workspace_id, is_import_enabled=True).first() - - if project_mapping and dependent_fields: - start_datetime = datetime.now() - Schedule.objects.update_or_create( - func='apps.mappings.imports.tasks.auto_import_and_map_fyle_fields', - args='{}'.format(workspace_id), - defaults={ - 'schedule_type': Schedule.MINUTES, - 'minutes': 24 * 60, - 'next_run': start_datetime - } - ) - elif not (project_mapping and dependent_fields): - Schedule.objects.filter( - func='apps.mappings.imports.tasks.auto_import_and_map_fyle_fields', - args='{}'.format(workspace_id) - ).delete() - - def schedule_or_delete_fyle_import_tasks(import_settings: ImportSetting, mapping_setting_instance: MappingSetting = None): """ Schedule or delete Fyle import tasks based on the import settingss. @@ -78,6 +46,3 @@ def schedule_or_delete_fyle_import_tasks(import_settings: ImportSetting, mapping func='apps.mappings.imports.queues.chain_import_fields_to_fyle', args='{}'.format(import_settings.workspace_id) ).delete() - - # Schedule or delete dependent field tasks - schedule_or_delete_dependent_field_tasks(import_settings.workspace_id) diff --git a/apps/mappings/imports/tasks.py b/apps/mappings/imports/tasks.py index 6166cb6b..c3e0a102 100644 --- a/apps/mappings/imports/tasks.py +++ b/apps/mappings/imports/tasks.py @@ -1,14 +1,15 @@ +import logging from django_q.tasks import Chain -from fyle_accounting_mappings.models import MappingSetting - from apps.mappings.models import ImportLog from apps.mappings.imports.modules.categories import Category from apps.mappings.imports.modules.projects import Project from apps.mappings.imports.modules.cost_centers import CostCenter from apps.mappings.imports.modules.merchants import Merchant from apps.mappings.imports.modules.expense_custom_fields import ExpenseCustomField -from apps.fyle.models import DependentFieldSetting + +logger = logging.getLogger(__name__) +logger.level = logging.INFO SOURCE_FIELD_CLASS_MAP = { 'CATEGORY': Category, @@ -41,20 +42,20 @@ def auto_import_and_map_fyle_fields(workspace_id): """ Auto import and map fyle fields """ - project_mapping = MappingSetting.objects.filter( - source_field='PROJECT', + import_log = ImportLog.objects.filter( workspace_id=workspace_id, - import_to_fyle=True + attribute_type = 'PROJECT' ).first() - dependent_fields = DependentFieldSetting.objects.filter(workspace_id=workspace_id, is_import_enabled=True).first() chain = Chain() - if project_mapping and dependent_fields: - chain.append('apps.mappings.tasks.sync_sage300_attributes', 'JOB', workspace_id) - chain.append('apps.mappings.tasks.sync_sage300_attributes', 'COST_CODE', workspace_id) - chain.append('apps.mappings.tasks.sync_sage300_attributes', 'COST_CATEGORY', workspace_id) - chain.append('apps.sage300.dependent_fields.import_dependent_fields_to_fyle', workspace_id) + chain.append('apps.mappings.tasks.sync_sage300_attributes', 'JOB', workspace_id) + chain.append('apps.mappings.tasks.sync_sage300_attributes', 'COST_CODE', workspace_id) + chain.append('apps.mappings.tasks.sync_sage300_attributes', 'COST_CATEGORY', workspace_id) + chain.append('apps.sage300.dependent_fields.import_dependent_fields_to_fyle', workspace_id) + + if import_log and import_log.status != 'COMPLETE': + logger.error(f"Project Import is in {import_log.status} state in WORKSPACE_ID: {workspace_id} with error {str(import_log.error_log)}") if chain.length() > 0: chain.run() diff --git a/apps/sage300/dependent_fields.py b/apps/sage300/dependent_fields.py index 6820c615..0cdc0c00 100644 --- a/apps/sage300/dependent_fields.py +++ b/apps/sage300/dependent_fields.py @@ -68,8 +68,7 @@ def create_dependent_custom_field_in_fyle(workspace_id: int, fyle_attribute_type return platform.expense_custom_fields.post(expense_custom_field_payload) -def post_dependent_cost_code(dependent_field_setting: DependentFieldSetting, platform: PlatformConnector, filters: Dict) -> List[str]: - +def post_dependent_cost_code(dependent_field_setting: DependentFieldSetting, platform: PlatformConnector, filters: Dict, is_enabled: bool = True) -> List[str]: projects = CostCategory.objects.filter(**filters).values('job_name').annotate(cost_codes=ArrayAgg('cost_code_name', distinct=True)) projects_from_categories = [project['job_name'] for project in projects] posted_cost_codes = [] @@ -91,20 +90,24 @@ def post_dependent_cost_code(dependent_field_setting: DependentFieldSetting, pla 'parent_expense_field_value': project['job_name'], 'expense_field_id': dependent_field_setting.cost_code_field_id, 'expense_field_value': cost_code, - 'is_enabled': True + 'is_enabled': is_enabled }) cost_code_names.append(cost_code) if payload: sleep(0.2) - platform.dependent_fields.bulk_post_dependent_expense_field_values(payload) - posted_cost_codes.extend(cost_code_names) + try: + platform.dependent_fields.bulk_post_dependent_expense_field_values(payload) + posted_cost_codes.extend(cost_code_names) + except Exception as exception: + logger.error(f'Exception while posting dependent cost code | Error: {exception} | Payload: {payload}') + raise return posted_cost_codes def post_dependent_cost_type(dependent_field_setting: DependentFieldSetting, platform: PlatformConnector, filters: Dict): - cost_categories = CostCategory.objects.filter(**filters).values('cost_code_name').annotate(cost_categories=ArrayAgg('name', distinct=True)) + cost_categories = CostCategory.objects.filter(is_imported=False, **filters).values('cost_code_name').annotate(cost_categories=ArrayAgg('name', distinct=True)) for category in cost_categories: payload = [ @@ -119,7 +122,12 @@ def post_dependent_cost_type(dependent_field_setting: DependentFieldSetting, pla if payload: sleep(0.2) - platform.dependent_fields.bulk_post_dependent_expense_field_values(payload) + try: + platform.dependent_fields.bulk_post_dependent_expense_field_values(payload) + CostCategory.objects.filter(cost_code_name=category['cost_code_name']).update(is_imported=True) + except Exception as exception: + logger.error(f'Exception while posting dependent cost type | Error: {exception} | Payload: {payload}') + raise def post_dependent_expense_field_values(workspace_id: int, dependent_field_setting: DependentFieldSetting, platform: PlatformConnector = None): diff --git a/apps/sage300/exports/base_model.py b/apps/sage300/exports/base_model.py index c6575a52..f76f4e7e 100644 --- a/apps/sage300/exports/base_model.py +++ b/apps/sage300/exports/base_model.py @@ -3,7 +3,7 @@ from django.db import models from django.db.models import Sum -from fyle_accounting_mappings.models import ExpenseAttribute, Mapping, MappingSetting, EmployeeMapping, DestinationAttribute +from fyle_accounting_mappings.models import ExpenseAttribute, Mapping, MappingSetting, EmployeeMapping from apps.accounting_exports.models import AccountingExport from apps.fyle.models import DependentFieldSetting, Expense @@ -60,7 +60,6 @@ def get_expense_purpose(workspace_id, lineitem: Expense, category: str, advance_ def get_vendor_id(accounting_export: AccountingExport): # Retrieve export settings for the given workspace export_settings = ExportSetting.objects.get(workspace_id=accounting_export.workspace_id) - # Extract the description from the accounting export description = accounting_export.description @@ -81,22 +80,22 @@ def get_vendor_id(accounting_export: AccountingExport): # Check if the fund source is 'CCC' elif accounting_export.fund_source == 'CCC': # Retrieve the vendor from the first expense - expense_vendor = accounting_export.expenses.first().vendor + vendor_id = None + corporate_card_id = accounting_export.expenses.first().corporate_card_id - # Query DestinationAttribute for the vendor with case-insensitive search - if expense_vendor: - vendor = DestinationAttribute.objects.filter( + if corporate_card_id: + vendor_mapping = Mapping.objects.filter( workspace_id=accounting_export.workspace_id, - value__icontains=expense_vendor, - attribute_type='VENDOR' - ).values_list('destination_id', flat=True).first() - if not vendor: - vendor = export_settings.default_vendor_id - else: - vendor = export_settings.default_vendor_id + source_type='CORPORATE_CARD', + destination_type='VENDOR', + source__source_id=corporate_card_id + ).first() - # Update vendor_id with the retrieved vendor or default to export settings - vendor_id = vendor + if vendor_mapping: + vendor_id = vendor_mapping.destination.destination_id + + if not vendor_id: + vendor_id = export_settings.default_vendor_id # Return the determined vendor_id return vendor_id diff --git a/apps/sage300/helpers.py b/apps/sage300/helpers.py index df69c299..a6dd2d11 100644 --- a/apps/sage300/helpers.py +++ b/apps/sage300/helpers.py @@ -2,11 +2,16 @@ from datetime import datetime, timezone import logging +from typing import Dict from django.utils.module_loading import import_string -from apps.workspaces.models import Workspace, Sage300Credential +from apps.workspaces.models import Workspace, Sage300Credential, FyleCredential from apps.mappings.models import Version - +from fyle_accounting_mappings.models import ExpenseAttribute +from fyle_integrations_platform_connector import PlatformConnector +from apps.sage300.models import CostCategory +from apps.fyle.models import DependentFieldSetting +from apps.sage300.dependent_fields import post_dependent_cost_code logger = logging.getLogger(__name__) logger.level = logging.INFO @@ -62,3 +67,96 @@ def sync_dimensions(sage300_credential: Sage300Credential, workspace_id: int) -> except Exception as exception: # Log any exceptions that occur during synchronization logger.info(exception) + + +def disable_projects(workspace_id: int, projects_to_disable: Dict): + """ + Disable projects in Fyle when the projects are updated in Sage 300. + This is a callback function that is triggered from accounting_mappings. + projects_to_disable object format: + { + 'destination_id': { + 'value': 'old_project_name', + 'updated_value': 'new_project_name' + } + } + + """ + fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id) + platform = PlatformConnector(fyle_credentials=fyle_credentials) + + filters = { + 'workspace_id': workspace_id, + 'attribute_type': 'PROJECT', + 'value__in': [projects_map['value'] for projects_map in projects_to_disable.values()] + } + + # Expense attribute value map is as follows: {old_project_name: destination_id} + expense_attribute_value_map = {v['value']: k for k, v in projects_to_disable.items()} + + expense_attributes = ExpenseAttribute.objects.filter(**filters) + + bulk_payload = [] + for expense_attribute in expense_attributes: + code = expense_attribute_value_map.get(expense_attribute.value, None) + if code: + payload = { + 'name': expense_attribute.value, + 'code': code, + 'description': 'Sage 300 Project - {0}, Id - {1}'.format( + expense_attribute.value, + code + ), + 'is_enabled': False, + 'id': expense_attribute.source_id + } + else: + logger.error(f"Project with value {expense_attribute.value} not found | WORKSPACE_ID: {workspace_id}") + + bulk_payload.append(payload) + + sync_after = datetime.now(timezone.utc) + + if bulk_payload: + logger.info(f"Disabling Projects in Fyle | WORKSPACE_ID: {workspace_id} | COUNT: {len(bulk_payload)}") + platform.projects.post_bulk(bulk_payload) + platform.projects.sync(sync_after=sync_after) + else: + logger.info(f"No Projects to Disable in Fyle | WORKSPACE_ID: {workspace_id}") + + update_and_disable_cost_code(workspace_id, projects_to_disable, platform) + + +def update_and_disable_cost_code(workspace_id: int, cost_codes_to_disable: Dict, platform: PlatformConnector): + """ + Update the job_name in CostCategory and disable the old cost code in Fyle + """ + dependent_field_setting = DependentFieldSetting.objects.filter(is_import_enabled=True, workspace_id=workspace_id).first() + + if dependent_field_setting: + filters = { + 'job_id__in':list(cost_codes_to_disable.keys()), + 'workspace_id': workspace_id + } + + # This call will disable the cost codes in Fyle that has old project name + posted_cost_codes = post_dependent_cost_code(dependent_field_setting, platform, filters, is_enabled=False) + + logger.info(f"Disabled Cost Codes in Fyle | WORKSPACE_ID: {workspace_id} | COUNT: {len(posted_cost_codes)}") + + # here we are updating the CostCategory with the new project name + bulk_update_payload = [] + for destination_id, value in cost_codes_to_disable.items(): + cost_categories = CostCategory.objects.filter( + workspace_id=workspace_id, + job_id=destination_id + ).exclude(job_name=value['updated_value']) + + for cost_category in cost_categories: + cost_category.job_name = value['updated_value'] + cost_category.updated_at = datetime.now(timezone.utc) + bulk_update_payload.append(cost_category) + + if bulk_update_payload: + logger.info(f"Updating Cost Categories | WORKSPACE_ID: {workspace_id} | COUNT: {len(bulk_update_payload)}") + CostCategory.objects.bulk_update(bulk_update_payload, ['job_name', 'updated_at'], batch_size=50) diff --git a/apps/sage300/migrations/0003_costcategory_is_imported.py b/apps/sage300/migrations/0003_costcategory_is_imported.py new file mode 100644 index 00000000..740ef690 --- /dev/null +++ b/apps/sage300/migrations/0003_costcategory_is_imported.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2024-06-06 08:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sage300', '0002_purchaseinvoicelineitems_commitment_item_id'), + ] + + operations = [ + migrations.AddField( + model_name='costcategory', + name='is_imported', + field=models.BooleanField(default=False, help_text='Is Imported'), + ), + ] diff --git a/apps/sage300/models.py b/apps/sage300/models.py index 8dd7dd43..a9d20763 100644 --- a/apps/sage300/models.py +++ b/apps/sage300/models.py @@ -30,6 +30,7 @@ class CostCategory(BaseForeignWorkspaceModel): name = StringNotNullField(help_text='Sage300 Cost Type Name') cost_category_id = StringNotNullField(help_text='Sage300 Category Id') status = BooleanFalseField(help_text='Sage300 Cost Type Status') + is_imported = models.BooleanField(default=False, help_text='Is Imported') class Meta: db_table = 'cost_category' @@ -39,7 +40,6 @@ def bulk_create_or_update(categories: List[Dict], workspace_id: int): """ Bulk create or update cost types """ - list_of_categories = [] for data in categories: list_of_categories.append(Category.from_dict(data)) diff --git a/apps/sage300/utils.py b/apps/sage300/utils.py index 51dcfa1b..9d0ab55e 100644 --- a/apps/sage300/utils.py +++ b/apps/sage300/utils.py @@ -141,8 +141,18 @@ def _sync_data(self, data_gen, attribute_type, display_name, workspace_id, field if destination_attr: destination_attributes.append(destination_attr) - DestinationAttribute.bulk_create_or_update_destination_attributes( - destination_attributes, attribute_type, workspace_id, True) + if attribute_type == 'JOB': + project_disable_callback_path = 'apps.sage300.helpers.disable_projects' + DestinationAttribute.bulk_create_or_update_destination_attributes( + destination_attributes, + attribute_type, + workspace_id, + True, + project_disable_callback_path=project_disable_callback_path + ) + else: + DestinationAttribute.bulk_create_or_update_destination_attributes( + destination_attributes, attribute_type, workspace_id, True) else: destination_attributes = [] for item in data_gen: @@ -266,8 +276,12 @@ def sync_cost_categories(self): """ Synchronize categories from Sage Desktop SDK to your application """ - cost_categories_generator = self.connection.categories.get_all_categories() + version = Version.objects.get(workspace_id=self.workspace_id) + cost_categories_generator = self.connection.categories.get_all_categories(version=version.cost_category) for cost_categories in cost_categories_generator: for categories in cost_categories: + latest_version = max([int(category['Version']) for category in categories]) CostCategory.bulk_create_or_update(categories, self.workspace_id) + version.cost_category = latest_version + version.save() diff --git a/apps/workspaces/serializers.py b/apps/workspaces/serializers.py index 2aa114df..8199f1de 100644 --- a/apps/workspaces/serializers.py +++ b/apps/workspaces/serializers.py @@ -158,6 +158,17 @@ def create(self, validated_data): # Update workspace onboarding state workspace = export_settings.workspace + if export_settings.credit_card_expense_export_type == 'PURCHASE_INVOICE': + MappingSetting.objects.update_or_create( + workspace_id = workspace_id, + defaults={ + 'source_field':'CORPORATE_CARD', + 'destination_field':'VENDOR', + 'import_to_fyle': False, + 'is_custom': False + } + ) + if workspace.onboarding_state == 'EXPORT_SETTINGS': workspace.onboarding_state = 'IMPORT_SETTINGS' workspace.save() diff --git a/requirements.txt b/requirements.txt index 7597ac15..b8f181c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,8 +28,9 @@ fyle==0.36.1 # Reusable Fyle Packages fyle-rest-auth==1.7.2 -fyle-accounting-mappings==1.32.3 -fyle-integrations-platform-connector==1.37.4 +fyle-accounting-mappings==1.33.0 +fyle-integrations-platform-connector==1.36.3 + # Postgres Dependincies psycopg2-binary==2.9.9 diff --git a/scripts/001_delete_dep_fields_schedule.sql b/scripts/001_delete_dep_fields_schedule.sql new file mode 100644 index 00000000..0d318597 --- /dev/null +++ b/scripts/001_delete_dep_fields_schedule.sql @@ -0,0 +1,4 @@ +rollback; +begin; + +delete from django_q_schedule where func = 'apps.mappings.imports.tasks.auto_import_and_map_fyle_fields'; \ No newline at end of file diff --git a/scripts/002_add_vendor_fyle_card_mapping.sql b/scripts/002_add_vendor_fyle_card_mapping.sql new file mode 100644 index 00000000..a39f2db2 --- /dev/null +++ b/scripts/002_add_vendor_fyle_card_mapping.sql @@ -0,0 +1,20 @@ +rollback; +begin; + +insert into mapping_settings (source_field, destination_field, import_to_fyle, is_custom, workspace_id, created_at, updated_at) +select 'CORPORATE_CARD', 'VENDOR', 'f', 'f', ws.id, now(), now() +from workspaces ws +where not exists ( + select 1 + from mapping_settings ms + inner join export_settings es + on es.workspace_id = ms.workspace_id + where ms.source_field = 'CORPORATE_CARD' + and ms.destination_field = 'VENDOR' + and ms.workspace_id = ws.id + and es.credit_card_expense_export_type = 'PURCHASE_INVOICE' +); + +select COUNT(*) from export_settings where credit_card_expense_export_type = 'PURCHASE_INVOICE'; + +select COUNT(distinct(workspace_id)) from mapping_settings where workspace_id not in (select distinct(workspace_id) from mapping_settings where source_field = 'CORPORATE_CARD' and destination_field = 'VENDOR'); diff --git a/scripts/003_update_is_imported_flag.sql b/scripts/003_update_is_imported_flag.sql new file mode 100644 index 00000000..3389cfe6 --- /dev/null +++ b/scripts/003_update_is_imported_flag.sql @@ -0,0 +1,15 @@ +rollback; +begin; + +select COUNT(*) from cost_category cs +inner join dependent_field_settings dfs +on cs.workspace_id = dfs.workspace_id +where cs.updated_at <= dfs.last_successful_import_at +and cs.is_imported = 'f'; + +update cost_category cs +set is_imported = 't' +from dependent_field_settings dfs +where cs.workspace_id = dfs.workspace_id +and cs.updated_at <= dfs.last_successful_import_at +and cs.is_imported = 'f'; \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5e7aab11..e061b2d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -624,6 +624,7 @@ def add_dependent_field_setting(create_temp_workspace): workspace_ids = [ 1, 2, 3 ] + for workspace_id in workspace_ids: DependentFieldSetting.objects.create( is_import_enabled=True, diff --git a/tests/test_sage300/test_exports/test_base_model.py b/tests/test_sage300/test_exports/test_base_model.py index 2927ce06..22969b16 100644 --- a/tests/test_sage300/test_exports/test_base_model.py +++ b/tests/test_sage300/test_exports/test_base_model.py @@ -4,10 +4,13 @@ from apps.fyle.models import Expense, DependentFieldSetting from fyle_accounting_mappings.models import ( ExpenseAttribute, + DestinationAttribute, Mapping, MappingSetting, EmployeeMapping ) +from apps.workspaces.models import ExportSetting +from apps.accounting_exports.models import _group_expenses def test_base_model_get_invoice_date( @@ -186,8 +189,10 @@ def test_get_vendor_id_2( accounting_export=accounting_export ) + export_settings = ExportSetting.objects.filter(workspace_id=accounting_export.workspace_id).first() + assert return_value is not None - assert return_value == 'dest_vendor123' + assert return_value == export_settings.default_vendor_id def test_get_vendor_id_3( @@ -220,6 +225,145 @@ def test_get_vendor_id_3( assert return_value == '1' +def test_get_vendor_id_4( + db, + create_temp_workspace, + create_expense_objects, + add_export_settings, + add_accounting_export_expenses, + create_employee_mapping_with_vendor +): + workspace_id = 1 + base_model = PurchaseInvoice + + corporate_card, _ = ExpenseAttribute.objects.update_or_create( + workspace_id=workspace_id, + defaults = { + 'attribute_type':'CORPORATE_CARD', + 'display_name':'Corporate Card', + 'value':'Bank of Fyle - T1711', + 'source_id':'bankoffyle123', + 'detail': {'cardholder_name': None} + } + ) + + vendor = DestinationAttribute.objects.filter(workspace_id=workspace_id, attribute_type='VENDOR').first() + + Mapping.objects.update_or_create( + workspace_id=workspace_id, + defaults = { + 'source_type':'CORPORATE_CARD', + 'destination_type':'VENDOR', + 'source':corporate_card, + 'destination':vendor + } + ) + + expense = Expense.objects.filter(workspace_id=workspace_id).first() + expense.fund_source = 'CCC' + expense.corporate_card_id = corporate_card.source_id + expense.save() + + accounting_export = AccountingExport.objects.filter(workspace_id=workspace_id).first() + accounting_export.expenses.set([expense]) + accounting_export.fund_source = 'CCC' + accounting_export.description = {'employee_email': 'ashwin.t@fyle.in'} + accounting_export.save() + + return_value = base_model.get_vendor_id( + accounting_export=accounting_export + ) + + assert return_value is not None + assert return_value == vendor.destination_id + + +def test_group_expenses( + db, + create_temp_workspace, + create_expense_objects, + add_export_settings, + add_accounting_export_expenses, + create_employee_mapping_with_vendor +): + workspace_id = 1 + + corporate_card_x, _ = ExpenseAttribute.objects.update_or_create( + workspace_id=workspace_id, + defaults = { + 'attribute_type':'CORPORATE_CARD', + 'display_name':'Corporate Card', + 'value':'Bank of Fyle - X', + 'source_id':'bankoffyle123X', + 'detail': {'cardholder_name': None} + } + ) + + corporate_card_y, _ = ExpenseAttribute.objects.update_or_create( + workspace_id=workspace_id, + defaults = { + 'attribute_type':'CORPORATE_CARD', + 'display_name':'Corporate Card', + 'value':'Bank of Fyle - Y', + 'source_id':'bankoffyle123Y', + 'detail': {'cardholder_name': None} + } + ) + + vendors = DestinationAttribute.objects.filter(workspace_id=workspace_id, attribute_type='VENDOR')[0:2] + + Mapping.objects.update_or_create( + workspace_id=workspace_id, + defaults = { + 'source_type':'CORPORATE_CARD', + 'destination_type':'VENDOR', + 'source':corporate_card_x, + 'destination':vendors[0] + } + ) + + Mapping.objects.update_or_create( + workspace_id=workspace_id, + defaults = { + 'source_type':'CORPORATE_CARD', + 'destination_type':'VENDOR', + 'source':corporate_card_y, + 'destination':vendors[0] + } + ) + + expenses = [] + for _ in range(3): + expense = Expense.objects.filter(workspace_id=workspace_id).first() + expense.pk = None + expenses.append(expense) + + expenses[0].fund_source = 'CCC' + expenses[0].expense_id = 'tx4ziVSA124' + expenses[0].corporate_card_id = None + expenses[0].save() + + expenses[1].fund_source = 'CCC' + expenses[1].expense_id = 'tx4ziVSAsfsf' + expenses[1].corporate_card_id = corporate_card_x.source_id + expenses[1].save() + + expenses[2].fund_source = 'CCC' + expenses[2].expense_id = 'tx4zisdAssda' + expenses[2].corporate_card_id = corporate_card_y.source_id + expenses[2].save() + + export_settings = ExportSetting.objects.filter(workspace_id=workspace_id).first() + export_settings.credit_card_expense_grouped_by = 'REPORT' + export_settings.credit_card_expense_date = 'POSTED_AT' + export_settings.reimbursable_expense_grouped_by = 'REPORT' + export_settings.reimbursable_expense_date = 'PAYMENT_PROCESSING' + export_settings.save() + + accounting_export = _group_expenses(expenses=expenses, export_setting=export_settings, fund_source='CCC') + assert len(accounting_export) == 3 + + def test_get_job_id( db, mocker, diff --git a/tests/test_sage300/test_helpers.py b/tests/test_sage300/test_helpers.py index 5f700082..60c46b5c 100644 --- a/tests/test_sage300/test_helpers.py +++ b/tests/test_sage300/test_helpers.py @@ -1,9 +1,14 @@ from datetime import datetime, timedelta, timezone from apps.sage300.helpers import ( check_interval_and_sync_dimension, - sync_dimensions + sync_dimensions, + disable_projects, + update_and_disable_cost_code ) from apps.workspaces.models import Workspace, Sage300Credential +from fyle_accounting_mappings.models import ExpenseAttribute +from apps.fyle.models import DependentFieldSetting +from apps.sage300.models import CostCategory def test_check_interval_and_sync_dimension( @@ -83,4 +88,102 @@ def test(): ) except Exception as e: assert str(e) == 'Error' - continue + + +def test_disable_projects( + db, + mocker, + create_temp_workspace, + add_fyle_credentials +): + workspace_id = 1 + + projects_to_disable = { + 'destination_id': { + 'value': 'old_project', + 'updated_value': 'new_project' + } + } + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='PROJECT', + display_name='Project', + value='old_project', + source_id='source_id' + ) + + mock_platform = mocker.patch('apps.sage300.helpers.PlatformConnector') + bulk_post_call = mocker.patch.object(mock_platform.return_value.projects, 'post_bulk') + sync_call = mocker.patch.object(mock_platform.return_value.projects, 'sync') + + disable_cost_code_call = mocker.patch('apps.sage300.helpers.update_and_disable_cost_code') + + disable_projects(workspace_id, projects_to_disable) + + assert bulk_post_call.call_count == 1 + assert sync_call.call_count == 1 + disable_cost_code_call.assert_called_once() + + projects_to_disable = { + 'destination_id': { + 'value': 'old_project_2', + 'updated_value': 'new_project' + } + } + + disable_projects(workspace_id, projects_to_disable) + assert bulk_post_call.call_count == 1 + assert sync_call.call_count == 1 + disable_cost_code_call.call_count == 2 + + +def test_update_and_disable_cost_code( + db, + mocker, + create_temp_workspace, + add_fyle_credentials, + add_dependent_field_setting, + add_cost_category +): + workspace_id = 1 + + projects_to_disable = { + 'destination_id': { + 'value': 'old_project', + 'updated_value': 'new_project' + } + } + + cost_category = CostCategory.objects.filter(workspace_id=workspace_id).first() + cost_category.job_name = 'old_project' + cost_category.job_id = 'destination_id' + cost_category.save() + + ExpenseAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='PROJECT', + display_name='Project', + value='old_project', + source_id='source_id' + ) + + mock_platform = mocker.patch('apps.sage300.helpers.PlatformConnector') + mocker.patch.object(mock_platform.return_value.cost_centers, 'post_bulk') + mocker.patch.object(mock_platform.return_value.cost_centers, 'sync') + mocker.patch.object(mock_platform.return_value.dependent_fields, 'bulk_post_dependent_expense_field_values') + + update_and_disable_cost_code(workspace_id, projects_to_disable, mock_platform) + + updated_cost_category = CostCategory.objects.filter(workspace_id=workspace_id, job_id='destination_id').first() + assert updated_cost_category.job_name == 'new_project' + + updated_cost_category.job_name = 'old_project' + updated_cost_category.save() + + DependentFieldSetting.objects.get(workspace_id=workspace_id).delete() + + update_and_disable_cost_code(workspace_id, projects_to_disable, mock_platform) + + updated_cost_category = CostCategory.objects.filter(workspace_id=workspace_id, job_id='destination_id').first() + assert updated_cost_category.job_name == 'old_project' diff --git a/tests/test_sage300/test_utils.py b/tests/test_sage300/test_utils.py index 4d7db76e..c51c9bf9 100644 --- a/tests/test_sage300/test_utils.py +++ b/tests/test_sage300/test_utils.py @@ -458,21 +458,32 @@ def test_sync_cost_categories( "JobId": "10064", "CostCodeId": "10064", "Name": "Test Category 1", - "IsActive": True + "IsActive": True, + "Version": 1, },{ "Id": 2, "JobId": "10081", "CostCodeId": "10064", "Name": "Test Category 2", - "IsActive": False + "IsActive": False, + "Version": '2' }] + Version.objects.update_or_create( + workspace_id=workspace_id, + defaults={ + 'cost_category': 1 + } + ) + categories_generator = [[mock_category]] sage_connector.connection.categories.get_all_categories.return_value = categories_generator sage_connector.sync_cost_categories() + assert Version.objects.get(workspace_id=workspace_id).cost_category == 2 + def test_sync_cost_codes( db,