diff --git a/apps/accounting_exports/models.py b/apps/accounting_exports/models.py index 6a376c09..064a7e1a 100644 --- a/apps/accounting_exports/models.py +++ b/apps/accounting_exports/models.py @@ -113,7 +113,6 @@ def create_accounting_export(expense_objects: List[Expense], fund_source: str, w # Group expenses based on specified fields and fund_source accounting_exports = _group_expenses(expense_objects, export_setting, fund_source) - fund_source_map = { 'PERSONAL': 'reimbursable', 'CCC': 'credit_card' @@ -138,6 +137,7 @@ def create_accounting_export(expense_objects: List[Expense], fund_source: str, w workspace_id=workspace_id, fund_source=accounting_export['fund_source'], description=accounting_export, + status='ENQUEUED' ) # Add related expenses to the AccountingExport object diff --git a/apps/fyle/helpers.py b/apps/fyle/helpers.py index 276f29a5..1310ac42 100644 --- a/apps/fyle/helpers.py +++ b/apps/fyle/helpers.py @@ -125,6 +125,12 @@ def get_fyle_orgs(refresh_token: str, cluster_domain: str): return get_request(api_url, {}, refresh_token) +def sync_dimensions(fyle_credentials: FyleCredential) -> None: + platform = PlatformConnector(fyle_credentials) + + platform.import_fyle_dimensions() + + def connect_to_platform(workspace_id: int) -> PlatformConnector: fyle_credentials: FyleCredential = FyleCredential.objects.get(workspace_id=workspace_id) diff --git a/apps/sage300/exports/accounting_export.py b/apps/sage300/exports/accounting_export.py new file mode 100644 index 00000000..9f30de3d --- /dev/null +++ b/apps/sage300/exports/accounting_export.py @@ -0,0 +1,68 @@ +from datetime import datetime +from django.db import transaction + +from apps.accounting_exports.models import AccountingExport +from apps.workspaces.models import AdvancedSetting + + +class AccountingDataExporter: + """ + Base class for exporting accounting data to an external accounting system. + Subclasses should implement the 'post' method for posting data. + """ + + def __init__(self): + self.body_model = None + self.lineitem_model = None + + def post(self, workspace_id, body, lineitems): + """ + Implement this method to post data to the external accounting system. + """ + raise NotImplementedError("Please implement this method") + + def create_sage300_object(self, accounting_export: AccountingExport): + """ + Create a purchase invoice in the external accounting system. + + Args: + accounting_export (AccountingExport): The accounting export object. + + Raises: + NotImplementedError: If the method is not implemented in the subclass. + """ + + # 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']: + accounting_export.status = 'IN_PROGRESS' + accounting_export.save() + else: + # If the status is already 'IN_PROGRESS' or 'COMPLETE', return without further processing + return + + try: + with transaction.atomic(): + # Create or update the main body of the accounting object + body_model_object = self.body_model.create_or_update_object(accounting_export) + + # Create or update line items for the accounting object + lineitems_model_objects = self.lineitem_model.create_or_update_object( + accounting_export, advance_settings + ) + + # Post the data to the external accounting system + created_object = self.post(accounting_export.workspace_id, body_model_object, lineitems_model_objects) + + # Update the accounting export details + accounting_export.detail = created_object + accounting_export.status = 'COMPLETE' + accounting_export.exported_at = datetime.now() + + accounting_export.save() + + except Exception as e: + print(e) + # Handle exceptions specific to the export process here diff --git a/apps/sage300/exports/base_model.py b/apps/sage300/exports/base_model.py new file mode 100644 index 00000000..effe3c77 --- /dev/null +++ b/apps/sage300/exports/base_model.py @@ -0,0 +1,303 @@ +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, DependentFieldSetting +from apps.workspaces.models import Workspace, FyleCredential, AdvancedSetting + +from apps.sage300.exports.helpers import get_filtered_mapping + + +class BaseExportModel(models.Model): + """ + Base Model for Sage300 Export + """ + 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) + org_id = workspace.org_id + + fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id) + cluster_domain = fyle_credentials.cluster_domain + workspace.cluster_domain = cluster_domain + workspace.save() + + expense_link = '{0}/app/main/#/enterprise/view_expense/{1}?org_id={2}'.format( + cluster_domain, lineitem.expense_id, org_id + ) + + memo_structure = advance_setting.expense_memo_structure + + details = { + 'employee_email': lineitem.employee_email, + 'merchant': '{0}'.format(lineitem.vendor) if lineitem.vendor else '', + 'category': '{0}'.format(category) if lineitem.category else '', + 'purpose': '{0}'.format(lineitem.purpose) if lineitem.purpose else '', + 'report_number': '{0}'.format(lineitem.claim_number), + 'spent_on': '{0}'.format(lineitem.spent_at.date()) if lineitem.spent_at else '', + 'expense_link': expense_link + } + + purpose = '' + + for id, field in enumerate(memo_structure): + if field in details: + purpose += details[field] + if id + 1 != len(memo_structure): + purpose = '{0} - '.format(purpose) + + return purpose + + def get_vendor_id(accounting_export: AccountingExport): + return '3a3485d9-5cc7-4668-9557-b06100a3e8c9' + + def get_total_amount(accounting_export: AccountingExport): + """ + 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. + """ + + # 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): + """ + 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): + """ + 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 + ) + + 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): + """ + Get the standard cost code 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 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 new file mode 100644 index 00000000..929f3c76 --- /dev/null +++ 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 new file mode 100644 index 00000000..af56c0b8 --- /dev/null +++ b/apps/sage300/exports/purchase_invoice/models.py @@ -0,0 +1,134 @@ +from django.db import models + +from fyle_accounting_mappings.models import CategoryMapping + +from apps.sage300.exports.base_model import BaseExportModel +from apps.accounting_exports.models import AccountingExport +from apps.workspaces.models import AdvancedSetting +from apps.fyle.models import Expense, DependentFieldSetting + + +from sage_desktop_api.models.fields import ( + CustomDateTimeField, + FloatNullField, + StringNullField, + TextNotNullField +) + + +class PurchaseInvoice(BaseExportModel): + """ + Purchase Invoice Model + """ + + accounting_export = models.OneToOneField(AccountingExport, on_delete=models.PROTECT, help_text='Expense group reference') + accounting_date = CustomDateTimeField(help_text='accounting date of purchase invoice') + amount = FloatNullField(help_text='Total Amount of the invoice') + code = StringNullField(max_length=10, help_text='unique code for invoice') + description = TextNotNullField(help_text='description for the invoice') + invoice_date = CustomDateTimeField(help_text='date of invoice') + tax_amount = FloatNullField(help_text='total tax amount of the invoice') + vendor_id = StringNullField(help_text='id of vendor') + + class Meta: + db_table = 'purchase_invoices' + + @classmethod + def create_or_update_object(self, accounting_export: AccountingExport): + """ + Create Purchase Invoice + :param accounting_export: expense group + :return: purchase invoices object + """ + description = accounting_export.description + + vendor_id = self.get_vendor_id(accounting_export=accounting_export) + amount = self.get_total_amount(accounting_export=accounting_export) + invoice_date = self.get_invoice_date(accounting_export=accounting_export) + + purchase_invoice, _ = PurchaseInvoice.objects.update_or_create( + accounting_export=accounting_export, + defaults={ + 'amount': amount, + 'vendor_id': vendor_id, + 'description': description, + 'invoice_date': invoice_date + } + ) + + return purchase_invoice + + +class PurchaseInvoiceLineitems(BaseExportModel): + """ + Purchase Invoice Lineitem Model + """ + + accounts_payable_account_id = StringNullField(help_text='destination id of accounts payable account') + purchase_invoice = models.ForeignKey(PurchaseInvoice, on_delete=models.PROTECT, help_text='Reference to PurchaseInvoice') + expense = models.OneToOneField(Expense, on_delete=models.PROTECT, help_text='Reference to Expense') + amount = FloatNullField(help_text='Amount of the invoice') + category_id = StringNullField(help_text='destination id of category') + commitment_id = StringNullField(help_text='destination id of commitment') + cost_code_id = StringNullField(help_text='destination id of cost code') + description = TextNotNullField(help_text='description for the invoice') + job_id = StringNullField(help_text='destination id of job') + tax_amount = FloatNullField(help_text='tax amount of the invoice') + tax_group_id = StringNullField(help_text='destination id of tax group') + standard_category_id = StringNullField(help_text='destination id of standard category') + standard_cost_code_id = StringNullField(help_text='destination id of standard cost code') + + class Meta: + db_table = 'purchase_invoice_lineitems' + + @classmethod + def create_or_update_object(self, accounting_export: AccountingExport, advance_setting: AdvancedSetting): + """ + Create Purchase Invoice + :param accounting_export: expense group + :return: purchase invoices object + """ + + expenses = accounting_export.expenses.all() + purchase_invoice = PurchaseInvoice.objects.get(accounting_export=accounting_export) + dependent_field_setting = DependentFieldSetting.objects.filter(workspace_id=accounting_export.workspace_id).first() + + cost_category_id = None + cost_code_id = None + + purchase_invoice_lineitem_objects = [] + + for lineitem in expenses: + account = CategoryMapping.objects.filter( + source_category__value=lineitem.category, + workspace_id=accounting_export.workspace_id + ).first() + + job_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(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_object, _ = PurchaseInvoiceLineitems.objects.update_or_create( + purchase_invoice_id=purchase_invoice.id, + expense_id=lineitem.id, + defaults={ + '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, + 'standard_cost_code_id': standard_cost_code_id, + 'category_id': cost_category_id, + 'cost_code_id': cost_code_id, + 'description': description + } + ) + purchase_invoice_lineitem_objects.append(purchase_invoice_lineitem_object) + + return purchase_invoice_lineitem_objects diff --git a/apps/sage300/exports/purchase_invoice/queues.py b/apps/sage300/exports/purchase_invoice/queues.py new file mode 100644 index 00000000..c3a6ff7e --- /dev/null +++ b/apps/sage300/exports/purchase_invoice/queues.py @@ -0,0 +1,52 @@ +from typing import List +from django_q.tasks import Chain +from fyle_integrations_platform_connector import PlatformConnector + +from apps.accounting_exports.models import AccountingExport +from apps.workspaces.models import FyleCredential + + +def import_fyle_dimensions(fyle_credentials: FyleCredential): + + platform = PlatformConnector(fyle_credentials) + platform.import_fyle_dimensions() + + +def check_accounting_export_and_start_import(workspace_id: int, accounting_export_ids: List[str]): + """ + Check accounting export group and start export + """ + + fyle_credentials = FyleCredential.objects.filter(workspace_id=workspace_id).first() + + accounting_exports = AccountingExport.objects.filter( + status__in=['IN_PROGRESS', 'ENQUEUED'], + workspace_id=workspace_id, id__in=accounting_export_ids, purchaseinvoice__id__isnull=True, + exported_at__isnull=True + ).all() + + chain = Chain() + chain.append('apps.fyle.helpers.sync_dimensions', fyle_credentials) + + for index, accounting_export_group in enumerate(accounting_exports): + accounting_export, _ = AccountingExport.objects.update_or_create( + workspace_id=accounting_export_group.workspace_id, + id=accounting_export_group.id, + defaults={ + 'status': 'ENQUEUED', + 'type': 'PURCHASE_INVOICE' + } + ) + + if accounting_export.status not in ['IN_PROGRESS', 'ENQUEUED']: + accounting_export.status = 'ENQUEUED' + accounting_export.save() + + """ + Todo: Add last export details + """ + + chain.append('apps.sage300.exports.purchase_invoice.tasks.create_purchase_invoice', accounting_export) + + if chain.length() > 1: + chain.run() diff --git a/apps/sage300/exports/purchase_invoice/tasks.py b/apps/sage300/exports/purchase_invoice/tasks.py new file mode 100644 index 00000000..8efcf694 --- /dev/null +++ b/apps/sage300/exports/purchase_invoice/tasks.py @@ -0,0 +1,99 @@ +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): + """ + Class for handling the export of purchase invoices to Sage 300. + 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, 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 + """ + + 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_credentials, workspace_id) + + # Post the purchase invoice to Sage 300 + 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) + + +def create_purchase_invoice(accounting_export: AccountingExport): + """ + Helper function to create and export a purchase invoice. + """ + export_purchase_invoice_instance = ExportPurchaseInvoice() + + # Create and export the purchase invoice using the base class method + exported_purchase_invoice = export_purchase_invoice_instance.create_sage300_object(accounting_export=accounting_export) + + return exported_purchase_invoice 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/migrations/0003_purchaseinvoice_purchaseinvoicelineitems.py b/apps/sage300/migrations/0003_purchaseinvoice_purchaseinvoicelineitems.py new file mode 100644 index 00000000..5469c59a --- /dev/null +++ b/apps/sage300/migrations/0003_purchaseinvoice_purchaseinvoicelineitems.py @@ -0,0 +1,60 @@ +# Generated by Django 4.1.2 on 2023-11-20 10:42 + +from django.db import migrations, models +import django.db.models.deletion +import sage_desktop_api.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('fyle', '0002_alter_dependentfieldsetting_workspace'), + ('accounting_exports', '0001_initial'), + ('sage300', '0002_costcategories'), + ] + + operations = [ + migrations.CreateModel( + name='PurchaseInvoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at')), + ('accounting_date', sage_desktop_api.models.fields.CustomDateTimeField(help_text='accounting date of purchase invoice', null=True)), + ('amount', sage_desktop_api.models.fields.FloatNullField(help_text='Total Amount of the invoice', null=True)), + ('code', sage_desktop_api.models.fields.StringNullField(help_text='unique code for invoice', max_length=10, null=True)), + ('description', sage_desktop_api.models.fields.TextNotNullField(help_text='description for the invoice')), + ('invoice_date', sage_desktop_api.models.fields.CustomDateTimeField(help_text='date of invoice', null=True)), + ('tax_amount', sage_desktop_api.models.fields.FloatNullField(help_text='total tax amount of the invoice', null=True)), + ('vendor_id', sage_desktop_api.models.fields.StringNullField(help_text='id of vendor', max_length=255, null=True)), + ('accounting_export', models.OneToOneField(help_text='Expense group reference', on_delete=django.db.models.deletion.PROTECT, to='accounting_exports.accountingexport')), + ], + options={ + 'db_table': 'purchase_invoices', + }, + ), + migrations.CreateModel( + name='PurchaseInvoiceLineitems', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at')), + ('accounts_payable_account_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of accounts payable account', max_length=255, null=True)), + ('amount', sage_desktop_api.models.fields.FloatNullField(help_text='Amount of the invoice', null=True)), + ('category_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of category', max_length=255, null=True)), + ('commitment_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of commitment', max_length=255, null=True)), + ('cost_code_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of cost code', max_length=255, null=True)), + ('description', sage_desktop_api.models.fields.TextNotNullField(help_text='description for the invoice')), + ('job_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of job', max_length=255, null=True)), + ('tax_amount', sage_desktop_api.models.fields.FloatNullField(help_text='tax amount of the invoice', null=True)), + ('tax_group_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of tax group', max_length=255, null=True)), + ('standard_category_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of standard category', max_length=255, null=True)), + ('standard_cost_code_id', sage_desktop_api.models.fields.StringNullField(help_text='destination id of standard cost code', max_length=255, null=True)), + ('expense', models.OneToOneField(help_text='Reference to Expense', on_delete=django.db.models.deletion.PROTECT, to='fyle.expense')), + ('purchase_invoice', models.ForeignKey(help_text='Reference to PurchaseInvoice', on_delete=django.db.models.deletion.PROTECT, to='sage300.purchaseinvoice')), + ], + options={ + 'db_table': 'purchase_invoice_lineitems', + }, + ), + ] diff --git a/apps/sage300/models.py b/apps/sage300/models.py index f463994d..4e024e5f 100644 --- a/apps/sage300/models.py +++ b/apps/sage300/models.py @@ -16,6 +16,8 @@ ) from apps.accounting_exports.models import AccountingExport +from apps.sage300.exports.purchase_invoice.models import PurchaseInvoice, PurchaseInvoiceLineitems # noqa + class Invoice(BaseModel): """ 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 c197e9d7..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 @@ -1,4 +1,4 @@ -# Generated by Django 4.1.2 on 2023-11-17 21:06 +# Generated by Django 4.1.2 on 2023-11-20 10:58 from django.db import migrations, models import django.db.models.deletion @@ -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/apps/workspaces/tasks.py b/apps/workspaces/tasks.py index 27cd7937..cf6bbd55 100644 --- a/apps/workspaces/tasks.py +++ b/apps/workspaces/tasks.py @@ -5,6 +5,8 @@ from apps.workspaces.models import ExportSetting, AdvancedSetting from apps.accounting_exports.models import AccountingExport, AccountingExportSummary +from apps.sage300.exports.purchase_invoice.tasks import ExportPurchaseInvoice +from apps.fyle.queue import queue_import_reimbursable_expenses, queue_import_credit_card_expenses logger = logging.getLogger(__name__) @@ -19,13 +21,16 @@ def run_import_export(workspace_id: int, export_mode = None): export_settings = ExportSetting.objects.get(workspace_id=workspace_id) advance_settings = AdvancedSetting.objects.get(workspace_id=workspace_id) - accounting_summary = AccountingExportSummary.objects.get(workspace_id=workspace_id) + accounting_summary, _ = AccountingExportSummary.objects.update_or_create( + workspace_id=workspace_id + ) last_exported_at = datetime.now() is_expenses_exported = False # For Reimbursable Expenses if export_settings.reimbursable_expenses_export_type: + queue_import_reimbursable_expenses(workspace_id=workspace_id, synchronous=True) accounting_export = AccountingExport.objects.get( workspace_id=workspace_id, type='FETCHING_REIMBURSABLE_EXPENSES' @@ -38,17 +43,16 @@ def run_import_export(workspace_id: int, export_mode = None): if len(accounting_export_ids): is_expenses_exported = True - """ - Export Logic goes here - """ + purchase_invoice = ExportPurchaseInvoice() + purchase_invoice.trigger_export(workspace_id=workspace_id, accounting_export_ids=accounting_export_ids) # For Credit Card Expenses if export_settings.credit_card_expense_export_type: + queue_import_credit_card_expenses(workspace_id=workspace_id, synchronous=True) accounting_export = AccountingExport.objects.get( workspace_id=workspace_id, - type='FETCHING_CREDIT_CARD_EXPENENSES' + type='FETCHING_CREDIT_CARD_EXPENSES' ) - if accounting_export.status == 'COMPLETE': accounting_export_ids = AccountingExport.objects.filter( fund_source='CCC', exported_at__isnull=True).values_list('id', flat=True) @@ -56,9 +60,8 @@ def run_import_export(workspace_id: int, export_mode = None): if len(accounting_export_ids): is_expenses_exported = True - """ - Export Logic goes here - """ + purchase_invoice = ExportPurchaseInvoice() + purchase_invoice.trigger_export(workspace_id=workspace_id, accounting_export_ids=accounting_export_ids) if is_expenses_exported: accounting_summary.last_exported_at = last_exported_at diff --git a/requirements.txt b/requirements.txt index 40c5ed75..ac8dd636 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ django-request-logging==0.7.5 django-filter==21.1 # DjangoQ for running async tasks -django-q==1.3.9 +django-q==1.3.4 # Read Database Credentials as URL dj-database-url==0.5.0 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): """