diff --git a/apps/sage300/exports/accounting_export.py b/apps/sage300/exports/accounting_export.py index 33b03a00..9f30de3d 100644 --- a/apps/sage300/exports/accounting_export.py +++ b/apps/sage300/exports/accounting_export.py @@ -2,7 +2,7 @@ from django.db import transaction from apps.accounting_exports.models import AccountingExport -from apps.workspaces.models import ExportSetting +from apps.workspaces.models import AdvancedSetting class AccountingDataExporter: @@ -11,10 +11,11 @@ class AccountingDataExporter: Subclasses should implement the 'post' method for posting data. """ - body_model = None - lineitem_model = None + def __init__(self): + self.body_model = None + self.lineitem_model = None - def post(self, body, lineitems): + def post(self, workspace_id, body, lineitems): """ Implement this method to post data to the external accounting system. """ @@ -31,8 +32,8 @@ def create_sage300_object(self, accounting_export: AccountingExport): NotImplementedError: If the method is not implemented in the subclass. """ - # Retrieve export settings for the current workspace - export_settings = ExportSetting.objects.filter(workspace_id=accounting_export.workspace_id) + # Retrieve advance settings for the current workspace + advance_settings = AdvancedSetting.objects.filter(workspace_id=accounting_export.workspace_id).first() # Check and update the status of the accounting export if accounting_export.status not in ['IN_PROGRESS', 'COMPLETE']: @@ -49,11 +50,11 @@ def create_sage300_object(self, accounting_export: AccountingExport): # Create or update line items for the accounting object lineitems_model_objects = self.lineitem_model.create_or_update_object( - accounting_export, export_settings + accounting_export, advance_settings ) # Post the data to the external accounting system - created_object = self.post(body_model_object, lineitems_model_objects) + created_object = self.post(accounting_export.workspace_id, body_model_object, lineitems_model_objects) # Update the accounting export details accounting_export.detail = created_object diff --git a/apps/sage300/exports/base_model.py b/apps/sage300/exports/base_model.py index a2ccded5..effe3c77 100644 --- a/apps/sage300/exports/base_model.py +++ b/apps/sage300/exports/base_model.py @@ -1,10 +1,16 @@ +from typing import Optional from datetime import datetime from django.db import models +from django.db.models import Sum + +from fyle_accounting_mappings.models import MappingSetting, ExpenseAttribute, Mapping from apps.accounting_exports.models import AccountingExport -from apps.fyle.models import Expense +from apps.fyle.models import Expense, DependentFieldSetting from apps.workspaces.models import Workspace, FyleCredential, AdvancedSetting +from apps.sage300.exports.helpers import get_filtered_mapping + class BaseExportModel(models.Model): """ @@ -12,6 +18,10 @@ class BaseExportModel(models.Model): """ created_at = models.DateTimeField(auto_now_add=True, help_text='Created at') updated_at = models.DateTimeField(auto_now=True, help_text='Updated at') + workspace = models.ForeignKey(Workspace, on_delete=models.PROTECT, help_text='Reference to Workspace model') + + class Meta: + abstract = True def get_expense_purpose(workspace_id, lineitem: Expense, category: str, advance_setting: AdvancedSetting) -> str: workspace = Workspace.objects.get(id=workspace_id) @@ -49,25 +59,245 @@ def get_expense_purpose(workspace_id, lineitem: Expense, category: str, advance_ return purpose def get_vendor_id(accounting_export: AccountingExport): - return '124' + return '3a3485d9-5cc7-4668-9557-b06100a3e8c9' def get_total_amount(accounting_export: AccountingExport): - return '123123' + """ + Calculate the total amount of expenses associated with a given AccountingExport + + Parameters: + - accounting_export (AccountingExport): The AccountingExport instance for which to calculate the total amount. + + Returns: + - float: The total amount of expenses associated with the provided AccountingExport. + """ - def get_invoice_date(accounting_export: AccountingExport): - return datetime.now() + # Using the related name 'expenses' to access the expenses associated with the given AccountingExport + total_amount = accounting_export.expenses.aggregate(Sum('amount'))['amount__sum'] + + # If there are no expenses for the given AccountingExport, 'total_amount' will be None + # Handle this case by returning 0 or handling it as appropriate for your application + return total_amount or 0.0 + + def get_invoice_date(accounting_export: AccountingExport) -> str: + """ + Get the invoice date from the provided AccountingExport. + + Parameters: + - accounting_export (AccountingExport): The AccountingExport instance containing the description field. + + Returns: + - str: The invoice date as a string in the format '%Y-%m-%dT%H:%M:%S'. + """ + # Check for specific keys in the 'description' field and return the corresponding value + if 'spent_at' in accounting_export.description and accounting_export.description['spent_at']: + return accounting_export.description['spent_at'] + elif 'approved_at' in accounting_export.description and accounting_export.description['approved_at']: + return accounting_export.description['approved_at'] + elif 'verified_at' in accounting_export.description and accounting_export.description['verified_at']: + return accounting_export.description['verified_at'] + elif 'last_spent_at' in accounting_export.description and accounting_export.description['last_spent_at']: + return accounting_export.description['last_spent_at'] + elif 'posted_at' in accounting_export.description and accounting_export.description['posted_at']: + return accounting_export.description['posted_at'] + + # If none of the expected keys are present or if the values are empty, return the current date and time + return datetime.now().strftime('%Y-%m-%dT%H:%M:%S') def get_job_id(accounting_export: AccountingExport, expense: Expense): - return '2312' + """ + Get the job ID based on the provided AccountingExport and Expense. + + Parameters: + - accounting_export (AccountingExport): The AccountingExport instance containing workspace information. + - expense (Expense): The Expense instance containing information for job ID retrieval. + + Returns: + - Optional[str]: The job ID as a string if found, otherwise None. + """ + + job_id = None + + # Retrieve mapping settings for job + job_settings: MappingSetting = MappingSetting.objects.filter( + workspace_id=accounting_export.workspace_id, + destination_field='JOB' + ).first() + + if job_settings: + # Determine the source value based on the configured source field + if job_settings.source_field == 'PROJECT': + source_value = expense.project + elif job_settings.source_field == 'COST_CENTER': + source_value = expense.cost_center + else: + attribute = ExpenseAttribute.objects.filter(attribute_type=job_settings.source_field).first() + source_value = expense.custom_properties.get(attribute.display_name, None) + + # Check for a mapping based on the source value + mapping: Mapping = Mapping.objects.filter( + source_type=job_settings.source_field, + destination_type='JOB', + source__value=source_value, + workspace_id=accounting_export.workspace_id + ).first() + + # If a mapping is found, retrieve the destination job ID + if mapping: + job_id = mapping.destination.destination_id + + return job_id def get_commitment_id(accounting_export: AccountingExport, expense: Expense): - return '12312' + """ + Get the commitment ID based on the provided AccountingExport and Expense. + + Parameters: + - accounting_export (AccountingExport): The AccountingExport instance containing workspace information. + - expense (Expense): The Expense instance containing information for job ID retrieval. + + Returns: + - Optional[str]: The commitment ID as a string if found, otherwise None. + """ + + commitment_setting: MappingSetting = MappingSetting.objects.filter( + workspace_id=accounting_export.workspace_id, + destination_field='COMMITMENT' + ).first() + + commitment_id = None + source_id = None + + if accounting_export and commitment_setting: + if expense: + if commitment_setting.source_field == 'PROJECT': + source_id = expense.project_id + source_value = expense.project + elif commitment_setting.source_field == 'COST_CENTER': + source_value = expense.cost_center + else: + attribute = ExpenseAttribute.objects.filter(attribute_type=expense.source_field).first() + source_value = expense.custom_properties.get(attribute.display_name, None) + else: + source_value = accounting_export.description[accounting_export.source_field.lower()] + + mapping: Mapping = get_filtered_mapping( + commitment_setting.source_field, 'COMMITMENT', accounting_export.workspace_id, source_value, source_id + ) - def get_standard_category_id(accounting_export: AccountingExport, expense: Expense): - return '123123' + if mapping: + commitment_id = mapping.destination.destination_id + return commitment_id + + def get_cost_code_id(accounting_export: AccountingExport, lineitem: Expense, dependent_field_setting: DependentFieldSetting, job_id: str): + from apps.sage300.models import CostCategory + cost_code_id = None + + selected_cost_code = lineitem.custom_properties.get(dependent_field_setting.cost_code_field_name, None) + cost_code = CostCategory.objects.filter( + workspace_id=accounting_export.workspace_id, + cost_code_name=selected_cost_code, + project_id=job_id + ).first() + + if cost_code: + cost_code_id = cost_code.cost_code_id + + return cost_code_id + + def get_cost_category_id(expense_group: AccountingExport, lineitem: Expense, dependent_field_setting: DependentFieldSetting, project_id: str, cost_code_id: str): + from apps.sage300.models import CostCategory + cost_category_id = None + + selected_cost_category = lineitem.custom_properties.get(dependent_field_setting.cost_type_field_name, None) + cost_category = CostCategory.objects.filter( + workspace_id=expense_group.workspace_id, + cost_code_id=cost_code_id, + project_id=project_id, + name=selected_cost_category + ).first() + + if cost_category: + cost_category_id = cost_category.cost_category_id + + return cost_category_id + + def get_standard_category_id(accounting_export: AccountingExport, expense: Expense) -> Optional[str]: + """ + Get the standard category ID based on the provided AccountingExport and Expense. + + Parameters: + - accounting_export (AccountingExport): The AccountingExport instance containing workspace information. + - expense (Expense): The Expense instance containing information for standard category ID retrieval. + + Returns: + - Optional[str]: The standard category ID as a string if found, otherwise None. + """ + standard_category_id = None + + # Retrieve mapping settings for standard category + standard_category_setting: MappingSetting = MappingSetting.objects.filter( + workspace_id=accounting_export.workspace_id, + destination_field='STANDARD_CATEGORY' + ).first() + + if standard_category_setting: + # Retrieve the attribute corresponding to the source field + attribute = ExpenseAttribute.objects.filter(attribute_type=standard_category_setting.source_field).first() + + # Determine the source value based on the configured source field + source_value = expense.custom_properties.get(attribute.display_name, None) + + # Check for a mapping based on the source value + mapping: Mapping = Mapping.objects.filter( + source_type=standard_category_setting.source_field, + destination_type='STANDARD_CATEGORY', + source__value=source_value, + workspace_id=accounting_export.workspace_id + ).first() + + # If a mapping is found, retrieve the destination standard category ID + if mapping: + standard_category_id = mapping.destination.destination_id + + return standard_category_id def get_standard_cost_code_id(accounting_export: AccountingExport, expense: Expense): - return '123123' + """ + Get the standard cost code ID based on the provided AccountingExport and Expense. - class Meta: - abstract = True + Parameters: + - accounting_export (AccountingExport): The AccountingExport instance containing workspace information. + - expense (Expense): The Expense instance containing information for standard category ID retrieval. + + Returns: + - Optional[str]: The standard cost code ID as a string if found, otherwise None. + """ + standard_cost_code_id = None + + # Retrieve mapping settings for standard cost code + standard_cost_code_setting: MappingSetting = MappingSetting.objects.filter( + workspace_id=accounting_export.workspace_id, + destination_field='STANDARD_COST_CODE' + ).first() + + if standard_cost_code_setting: + # Retrieve the attribute corresponding to the source field + attribute = ExpenseAttribute.objects.filter(attribute_type=standard_cost_code_setting.source_field).first() + + # Determine the source value based on the configured source field + source_value = expense.custom_properties.get(attribute.display_name, None) + + # Check for a mapping based on the source value + mapping: Mapping = Mapping.objects.filter( + source_type=standard_cost_code_setting.source_field, + destination_type='STANDARD_COST_CODE', + source__value=source_value, + workspace_id=accounting_export.workspace_id + ).first() + + # If a mapping is found, retrieve the destination standard cost code ID + if mapping: + standard_cost_code_id = mapping.destination.destination_id + + return standard_cost_code_id diff --git a/apps/sage300/exports/helpers.py b/apps/sage300/exports/helpers.py index e69de29b..929f3c76 100644 --- a/apps/sage300/exports/helpers.py +++ b/apps/sage300/exports/helpers.py @@ -0,0 +1,17 @@ +from fyle_accounting_mappings.models import Mapping + + +def get_filtered_mapping( + source_field: str, destination_type: str, workspace_id: int, source_value: str, source_id: str) -> Mapping: + filters = { + 'source_type': source_field, + 'destination_type': destination_type, + 'workspace_id': workspace_id + } + + if source_id: + filters['source__source_id'] = source_id + else: + filters['source__value'] = source_value + + return Mapping.objects.filter(**filters).first() diff --git a/apps/sage300/exports/purchase_invoice/models.py b/apps/sage300/exports/purchase_invoice/models.py index 1b577c54..af56c0b8 100644 --- a/apps/sage300/exports/purchase_invoice/models.py +++ b/apps/sage300/exports/purchase_invoice/models.py @@ -34,7 +34,7 @@ class Meta: db_table = 'purchase_invoices' @classmethod - def create_purchase_invoice(self, accounting_export: AccountingExport): + def create_or_update_object(self, accounting_export: AccountingExport): """ Create Purchase Invoice :param accounting_export: expense group @@ -82,7 +82,7 @@ class Meta: db_table = 'purchase_invoice_lineitems' @classmethod - def create_purchase_invoice_lineitems(self, accounting_export: AccountingExport, advance_setting: AdvancedSetting): + def create_or_update_object(self, accounting_export: AccountingExport, advance_setting: AdvancedSetting): """ Create Purchase Invoice :param accounting_export: expense group @@ -105,20 +105,21 @@ def create_purchase_invoice_lineitems(self, accounting_export: AccountingExport, ).first() job_id = self.get_job_id(accounting_export, lineitem) - commitment_id = self.get_job_id(accounting_export, lineitem) - standard_category_id = self.get_job_id(accounting_export, lineitem) - standard_cost_code_id = self.get_job_id(accounting_export, lineitem) + commitment_id = self.get_commitment_id(accounting_export, lineitem) + standard_category_id = self.get_standard_category_id(accounting_export, lineitem) + standard_cost_code_id = self.get_standard_cost_code_id(accounting_export, lineitem) description = self.get_expense_purpose(accounting_export.workspace_id, lineitem, lineitem.category, advance_setting) if dependent_field_setting: - cost_category_id = self.get_cost_category_id_or_none(accounting_export, lineitem, dependent_field_setting, job_id) - cost_code_id = self.get_cost_type_id_or_none(accounting_export, lineitem, dependent_field_setting, job_id, cost_category_id) + cost_category_id = self.get_cost_category_id(accounting_export, lineitem, dependent_field_setting, job_id) + cost_code_id = self.get_cost_code_id(accounting_export, lineitem, dependent_field_setting, job_id, cost_category_id) - purchase_invoice_lineitem_objects, _ = PurchaseInvoiceLineitems.objects.update_or_create( + purchase_invoice_lineitem_object, _ = PurchaseInvoiceLineitems.objects.update_or_create( purchase_invoice_id=purchase_invoice.id, expense_id=lineitem.id, defaults={ - 'accounts_payable_account_id': account, + 'amount': lineitem.amount, + 'accounts_payable_account_id': account.destination_account.destination_id, 'job_id': job_id, 'commitment_id': commitment_id, 'standard_category_id': standard_category_id, @@ -128,5 +129,6 @@ def create_purchase_invoice_lineitems(self, accounting_export: AccountingExport, 'description': description } ) + purchase_invoice_lineitem_objects.append(purchase_invoice_lineitem_object) - return purchase_invoice_lineitem_objects + return purchase_invoice_lineitem_objects diff --git a/apps/sage300/exports/purchase_invoice/queues.py b/apps/sage300/exports/purchase_invoice/queues.py index ff25a0b9..c3a6ff7e 100644 --- a/apps/sage300/exports/purchase_invoice/queues.py +++ b/apps/sage300/exports/purchase_invoice/queues.py @@ -20,7 +20,7 @@ def check_accounting_export_and_start_import(workspace_id: int, accounting_expor fyle_credentials = FyleCredential.objects.filter(workspace_id=workspace_id).first() accounting_exports = AccountingExport.objects.filter( - status='ENQUEUED', + status__in=['IN_PROGRESS', 'ENQUEUED'], workspace_id=workspace_id, id__in=accounting_export_ids, purchaseinvoice__id__isnull=True, exported_at__isnull=True ).all() diff --git a/apps/sage300/exports/purchase_invoice/tasks.py b/apps/sage300/exports/purchase_invoice/tasks.py index 9622aad6..8efcf694 100644 --- a/apps/sage300/exports/purchase_invoice/tasks.py +++ b/apps/sage300/exports/purchase_invoice/tasks.py @@ -1,7 +1,11 @@ +from typing import Dict, List + from apps.sage300.exports.accounting_export import AccountingDataExporter from apps.accounting_exports.models import AccountingExport +from apps.workspaces.models import Sage300Credential from apps.sage300.utils import SageDesktopConnector from apps.sage300.exports.purchase_invoice.queues import check_accounting_export_and_start_import +from apps.sage300.exports.purchase_invoice.models import PurchaseInvoice, PurchaseInvoiceLineitems class ExportPurchaseInvoice(AccountingDataExporter): @@ -10,32 +14,74 @@ class ExportPurchaseInvoice(AccountingDataExporter): Extends the base AccountingDataExporter class. """ + def __init__(self): + super().__init__() # Call the constructor of the parent class + self.body_model = PurchaseInvoice + self.lineitem_model = PurchaseInvoiceLineitems + def trigger_export(self, workspace_id, accounting_export_ids): """ Trigger the import process for the Project module. """ check_accounting_export_and_start_import(workspace_id, accounting_export_ids) - def __construct_purchase_invoice(self, item, lineitem): + def __construct_purchase_invoice(self, body: PurchaseInvoice, lineitems: List[PurchaseInvoiceLineitems]) -> Dict: """ Construct the payload for the purchase invoice. + :param expense_report: ExpenseReport object extracted from database + :param expense_report_lineitems: ExpenseReportLineitem objects extracted from database + :return: constructed expense_report """ - # Implementation for constructing the purchase invoice payload goes here - pass - def post(self, item, lineitem): + purchase_invoice_lineitem_payload = [] + for lineitem in lineitems: + expense = { + "AccountsPayableAccountId": lineitem.accounts_payable_account_id, + "Amount": lineitem.amount, + "CategoryId": lineitem.category_id, + "CostCodeId": lineitem.cost_code_id, + "Description": 'sample description', + "ExpenseAccountId": lineitem.accounts_payable_account_id, + "JobId": lineitem.job_id, + "StandardCategoryId": lineitem.standard_category_id, + "StandardCostCodeId": lineitem.standard_cost_code_id + } + + purchase_invoice_lineitem_payload.append(expense) + + transaction_date = '2023-08-17' + purchase_invoice_payload = { + 'DocumentTypeId': '76744AB9-4697-430A-ADB5-701E633472A9', + 'Snapshot': { + 'Distributions': purchase_invoice_lineitem_payload, + 'Header': { + 'AccountingDate': transaction_date, + 'Amount': body.amount, + "Code": 'difgdofjig', + "Description": 'sample description', + "InvoiceDate": transaction_date, + "VendorId": body.vendor_id + } + } + } + + return purchase_invoice_payload + + def post(self, workspace_id, item, lineitem): """ Export the purchase invoice to Sage 300. """ try: purchase_invoice_payload = self.__construct_purchase_invoice(item, lineitem) - + sage300_credentials = Sage300Credential.objects.filter(workspace_id=workspace_id).first() # Establish a connection to Sage 300 - sage300_connection = SageDesktopConnector() + sage300_connection = SageDesktopConnector(sage300_credentials, workspace_id) # Post the purchase invoice to Sage 300 - created_purchase_invoice = sage300_connection.connection.documents.post_document(purchase_invoice_payload) - return created_purchase_invoice + created_purchase_invoice_id = sage300_connection.connection.documents.post_document(purchase_invoice_payload) + + exported_purchase_invoice_id = sage300_connection.connection.documents.export_document(created_purchase_invoice_id) + return exported_purchase_invoice_id except Exception as e: print(e) diff --git a/apps/sage300/helpers.py b/apps/sage300/helpers.py index 52551a8f..a6e2dd4c 100644 --- a/apps/sage300/helpers.py +++ b/apps/sage300/helpers.py @@ -52,7 +52,7 @@ def sync_dimensions(sage300_credential: Sage300Credential, workspace_id: int) -> sage300_connection = import_string('apps.sage300.utils.SageDesktopConnector')(sage300_credential, workspace_id) # List of dimensions to sync - dimensions = ['accounts', 'vendors', 'commitments', 'jobs', 'standard_categories', 'standard_cost_codes', 'cost_codes', 'cost_categories'] + dimensions = ['accounts', 'vendors', 'commitments', 'jobs', 'standard_categories', 'standard_cost_codes', 'cost_codes'] for dimension in dimensions: try: diff --git a/apps/sage300/serializers.py b/apps/sage300/serializers.py index fbebfdc9..0b9a54f3 100644 --- a/apps/sage300/serializers.py +++ b/apps/sage300/serializers.py @@ -87,7 +87,6 @@ def format_sage300_fields(self, workspace_id): "COST_CODE", "PAYMENT", ] - attributes = ( DestinationAttribute.objects.filter( ~Q(attribute_type__in=attribute_types), diff --git a/apps/workspaces/migrations/0003_rename_ccc_last_synced_at_workspace_credit_card_last_synced_at_and_more.py b/apps/workspaces/migrations/0003_rename_ccc_last_synced_at_workspace_credit_card_last_synced_at_and_more.py index 2e66e962..4e4472bd 100644 --- a/apps/workspaces/migrations/0003_rename_ccc_last_synced_at_workspace_credit_card_last_synced_at_and_more.py +++ b/apps/workspaces/migrations/0003_rename_ccc_last_synced_at_workspace_credit_card_last_synced_at_and_more.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): dependencies = [ - ('django_q', '0014_alter_ormq_id_alter_schedule_id'), ('workspaces', '0002_sage300credential_importsetting_fylecredential_and_more'), ] diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index de73b226..3ea8de3b 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -126,6 +126,7 @@ class Meta: ('JOURNAL_ENTRY', 'JOURNAL_ENTRY'), ) + CREDIT_CARD_EXPENSE_STATE_CHOICES = ( ('APPROVED', 'APPROVED'), ('PAYMENT_PROCESSING', 'PAYMENT_PROCESSING'), diff --git a/sage_desktop_sdk/apis/documents.py b/sage_desktop_sdk/apis/documents.py index 7a7b7bef..067e932c 100644 --- a/sage_desktop_sdk/apis/documents.py +++ b/sage_desktop_sdk/apis/documents.py @@ -24,7 +24,7 @@ def post_document(self, data: dict): Get Vendor Types :return: List of Dicts in Vendor Types Schema """ - return self._post_request(Documents.POST_DOCUMENT, data=json.dumps(data.__dict__)) + return self._post_request(Documents.POST_DOCUMENT, data=json.dumps(data)) def export_document(self, document_id: str): """