diff --git a/backend/helpers.ts b/backend/helpers.ts index f9c72c304..9559b5f73 100644 --- a/backend/helpers.ts +++ b/backend/helpers.ts @@ -17,6 +17,7 @@ export const sqliteTypeMap: Record = { Time: 'time', Text: 'text', Data: 'text', + Secret: 'text', Link: 'text', DynamicLink: 'text', Password: 'text', diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index a5b23b6d2..749b5a23f 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -44,6 +44,8 @@ import { ValidationMap, } from './types'; import { validateOptions, validateRequired } from './validationFunction'; +import { getShouldDocSyncToERPNext } from 'src/utils/erpnextSync'; +import { ModelNameEnum } from 'models/types'; export class Doc extends Observable { /* eslint-disable @typescript-eslint/no-floating-promises */ @@ -66,6 +68,8 @@ export class Doc extends Observable { _notInserted = true; _syncing = false; + _addDocToSyncQueue = true; + constructor( schema: Schema, data: DocValueMap, @@ -247,6 +251,22 @@ export class Doc extends Observable { return true; } + get shouldDocSyncToERPNext(): boolean { + const syncEnabled = !!this.fyo.singles.ERPNextSyncSettings?.isEnabled; + if (!syncEnabled) { + return false; + } + + if (!this.schemaName || !this.fyo.singles.ERPNextSyncSettings) { + return false; + } + + return getShouldDocSyncToERPNext( + this.fyo.singles.ERPNextSyncSettings, + this + ); + } + _setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) { for (const field of this.schema.fields) { const { fieldname, fieldtype } = field; @@ -912,6 +932,28 @@ export class Doc extends Observable { this._notInserted = false; await this.trigger('afterSync'); this.fyo.doc.observer.trigger(`sync:${this.schemaName}`, this.name); + + if (this._addDocToSyncQueue && !!this.shouldDocSyncToERPNext) { + const isDocExistsInQueue = await this.fyo.db.getAll( + ModelNameEnum.ERPNextSyncQueue, + { + filters: { + referenceType: this.schemaName, + documentName: this.name as string, + }, + } + ); + + if (!isDocExistsInQueue.length) { + this.fyo.doc + .getNewDoc(ModelNameEnum.ERPNextSyncQueue, { + referenceType: this.schemaName, + documentName: this.name, + }) + .sync(); + } + } + this._syncing = false; return doc; } diff --git a/main/api.ts b/main/api.ts new file mode 100644 index 000000000..f9e326c5e --- /dev/null +++ b/main/api.ts @@ -0,0 +1,10 @@ +import fetch, { RequestInit } from 'node-fetch'; + +export async function sendAPIRequest( + endpoint: string, + options: RequestInit | undefined +) { + return (await fetch(endpoint, options)).json() as unknown as { + [key: string]: string | number | boolean; + }[]; +} diff --git a/main/preload.ts b/main/preload.ts index 0e4892842..5cfd3ef68 100644 --- a/main/preload.ts +++ b/main/preload.ts @@ -180,6 +180,18 @@ const ipc = { await ipcRenderer.invoke(IPC_ACTIONS.SEND_ERROR, body); }, + async sendAPIRequest(endpoint: string, options: RequestInit | undefined) { + return (await ipcRenderer.invoke( + IPC_ACTIONS.SEND_API_REQUEST, + endpoint, + options + )) as Promise< + { + [key: string]: string | number | boolean | Date | object | object[]; + }[] + >; + }, + registerMainProcessErrorListener(listener: IPCRendererListener) { ipcRenderer.on(IPC_CHANNELS.LOG_MAIN_PROCESS_ERROR, listener); }, diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts index d3e60626f..0e32af7d7 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -26,6 +26,7 @@ import { setAndGetCleanedConfigFiles, } from './helpers'; import { saveHtmlAsPdf } from './saveHtmlAsPdf'; +import { sendAPIRequest } from './api'; export default function registerIpcMainActionListeners(main: Main) { ipcMain.handle(IPC_ACTIONS.CHECK_DB_ACCESS, async (_, filePath: string) => { @@ -209,6 +210,13 @@ export default function registerIpcMainActionListeners(main: Main) { return getTemplates(); }); + ipcMain.handle( + IPC_ACTIONS.SEND_API_REQUEST, + async (e, endpoint: string, options: RequestInit | undefined) => { + return sendAPIRequest(endpoint, options); + } + ); + /** * Database Related Actions */ diff --git a/models/baseModels/ERPNextSyncQueue/ERPNextSyncQueue.ts b/models/baseModels/ERPNextSyncQueue/ERPNextSyncQueue.ts new file mode 100644 index 000000000..ba3ebaed2 --- /dev/null +++ b/models/baseModels/ERPNextSyncQueue/ERPNextSyncQueue.ts @@ -0,0 +1,17 @@ +import { Doc } from 'fyo/model/doc'; +import { HiddenMap, ListViewSettings } from 'fyo/model/types'; + +export class ERPNextSyncQueue extends Doc { + referenceType?: string; + documentName?: string; + + hidden: HiddenMap = { + name: () => true, + }; + + static getListViewSettings(): ListViewSettings { + return { + columns: ['referenceType', 'documentName'], + }; + } +} diff --git a/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts b/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts new file mode 100644 index 000000000..63d21b25a --- /dev/null +++ b/models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings.ts @@ -0,0 +1,61 @@ +import { Doc } from 'fyo/model/doc'; +import { HiddenMap } from 'fyo/model/types'; + +export class ERPNextSyncSettings extends Doc { + endpoint?: string; + authToken?: string; + integrationAppVersion?: string; + isEnabled?: boolean; + dataSyncInterval?: number; + + syncItem?: boolean; + itemSyncType?: string; + + syncCustomer?: boolean; + customerSyncType?: string; + + syncSupplier?: boolean; + supplierSyncType?: string; + + syncSalesInvoice?: boolean; + salesInvoiceSyncType?: string; + + syncSalesInvoicePayment?: boolean; + sinvPaymentType?: string; + + syncStockMovement?: boolean; + stockMovementSyncType?: string; + + syncPriceList?: boolean; + priceListSyncType?: string; + + syncSerialNumber?: boolean; + serialNumberSyncType?: string; + + syncBatch?: boolean; + batchSyncType?: string; + + syncShipment?: boolean; + shipmentSyncType?: string; + + hidden: HiddenMap = { + syncPriceList: () => { + return !this.fyo.singles.AccountingSettings?.enablePriceList; + }, + priceListSyncType: () => { + return !this.fyo.singles.AccountingSettings?.enablePriceList; + }, + syncSerialNumber: () => { + return !this.fyo.singles.InventorySettings?.enableSerialNumber; + }, + serialNumberSyncType: () => { + return !this.fyo.singles.InventorySettings?.enableSerialNumber; + }, + syncBatch: () => { + return !this.fyo.singles.InventorySettings?.enableBatches; + }, + batchSyncType: () => { + return !this.fyo.singles.InventorySettings?.enableBatches; + }, + }; +} diff --git a/models/baseModels/FetchFromERPNextQueue/FetchFromERPNextQueue.ts b/models/baseModels/FetchFromERPNextQueue/FetchFromERPNextQueue.ts new file mode 100644 index 000000000..283d6afd2 --- /dev/null +++ b/models/baseModels/FetchFromERPNextQueue/FetchFromERPNextQueue.ts @@ -0,0 +1,17 @@ +import { Doc } from 'fyo/model/doc'; +import { HiddenMap, ListViewSettings } from 'fyo/model/types'; + +export class FetchFromERPNextQueue extends Doc { + referenceType?: string; + documentName?: string; + + hidden: HiddenMap = { + name: () => true, + }; + + static getListViewSettings(): ListViewSettings { + return { + columns: ['referenceType', 'documentName'], + }; + } +} diff --git a/models/helpers.ts b/models/helpers.ts index f6ee08e78..957fdaa32 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -1257,6 +1257,30 @@ export function removeFreeItems(sinvDoc: SalesInvoice) { } } +export async function updatePricingRule(sinvDoc: SalesInvoice) { + const applicablePricingRuleNames = await getPricingRule(sinvDoc); + + if (!applicablePricingRuleNames || !applicablePricingRuleNames.length) { + sinvDoc.pricingRuleDetail = undefined; + sinvDoc.isPricingRuleApplied = false; + removeFreeItems(sinvDoc); + return; + } + + const appliedPricingRuleCount = sinvDoc?.items?.filter( + (val) => val.isFreeItem + ).length; + + setTimeout(() => { + void (async () => { + if (appliedPricingRuleCount !== applicablePricingRuleNames?.length) { + await sinvDoc.appendPricingRuleDetail(applicablePricingRuleNames); + await sinvDoc.applyProductDiscount(); + } + })(); + }, 1); +} + export function getPricingRulesConflicts( pricingRules: PricingRule[] ): undefined | boolean { diff --git a/models/index.ts b/models/index.ts index ed4973b3e..260e22090 100644 --- a/models/index.ts +++ b/models/index.ts @@ -49,6 +49,9 @@ import { OpeningAmounts } from './inventory/Point of Sale/OpeningAmounts'; import { OpeningCash } from './inventory/Point of Sale/OpeningCash'; import { POSSettings } from './inventory/Point of Sale/POSSettings'; import { POSShift } from './inventory/Point of Sale/POSShift'; +import { ERPNextSyncSettings } from './baseModels/ERPNextSyncSettings/ERPNextSyncSettings'; +import { ERPNextSyncQueue } from './baseModels/ERPNextSyncQueue/ERPNextSyncQueue'; +import { FetchFromERPNextQueue } from './baseModels/FetchFromERPNextQueue/FetchFromERPNextQueue'; export const models = { Account, @@ -103,6 +106,10 @@ export const models = { OpeningCash, POSSettings, POSShift, + // ERPNext Sync + ERPNextSyncSettings, + ERPNextSyncQueue, + FetchFromERPNextQueue, } as ModelMap; export async function getRegionalModels( diff --git a/models/types.ts b/models/types.ts index 2420b8feb..11cf1fd21 100644 --- a/models/types.ts +++ b/models/types.ts @@ -10,7 +10,6 @@ export enum ModelNameEnum { GetStarted = 'GetStarted', Defaults = 'Defaults', Item = 'Item', - ItemPrice = 'ItemPrice', UOM = 'UOM', UOMConversionItem = 'UOMConversionItem', JournalEntry = 'JournalEntry', @@ -28,6 +27,7 @@ export enum ModelNameEnum { Payment = 'Payment', PaymentFor = 'PaymentFor', PriceList = 'PriceList', + PriceListItem = 'PriceListItem', PricingRule = 'PricingRule', PricingRuleItem = 'PricingRuleItem', PricingRuleDetail = 'PricingRuleDetail', @@ -59,7 +59,11 @@ export enum ModelNameEnum { CustomForm = 'CustomForm', CustomField = 'CustomField', POSSettings = 'POSSettings', - POSShift = 'POSShift' + POSShift = 'POSShift', + + ERPNextSyncSettings= 'ERPNextSyncSettings', + ERPNextSyncQueue = 'ERPNextSyncQueue', + FetchFromERPNextQueue = 'FetchFromERPNextQueue', } export type ModelName = keyof typeof ModelNameEnum; diff --git a/schemas/app/AccountingSettings.json b/schemas/app/AccountingSettings.json index 4493af67a..0a380e3d6 100644 --- a/schemas/app/AccountingSettings.json +++ b/schemas/app/AccountingSettings.json @@ -100,6 +100,13 @@ "default": false, "section": "Features" }, + { + "fieldname": "enableERPNextSync", + "label": "Enable ERPNext Sync", + "fieldtype": "Check", + "default": false, + "section": "Features" + }, { "fieldname": "enableLead", "label": "Enable Lead", diff --git a/schemas/app/ERPNextSyncQueue.json b/schemas/app/ERPNextSyncQueue.json new file mode 100644 index 000000000..c5f5b077b --- /dev/null +++ b/schemas/app/ERPNextSyncQueue.json @@ -0,0 +1,70 @@ +{ + "name": "ERPNextSyncQueue", + "label": "ERPNext Sync Queue", + "naming": "random", + "fields": [ + { + "fieldname": "referenceType", + "label": "Ref. Type", + "fieldtype": "Select", + "options": [ + { + "value": "Item", + "label": "Item" + }, + { + "value": "Party", + "label": "Party" + }, + { + "value": "SalesInvoice", + "label": "Sales Invoice" + }, + { + "value": "Payment", + "label": "Sales Payment" + }, + { + "value": "StockMovement", + "label": "Stock" + }, + { + "value": "PriceList", + "label": "Price List" + }, + { + "value": "PriceListItem", + "label": "Price List Item" + }, + { + "value": "SerialNumber", + "label": "Serial Number" + }, + { + "value": "Batch", + "label": "Batch" + }, + { + "value": "UOM", + "label": "UOM" + }, + { + "value": "Shipment", + "label": "Shipment" + }, + { + "value": "Address", + "label": "Address" + } + ], + "required": true + }, + { + "fieldname": "documentName", + "label": "Document Name", + "fieldtype": "DynamicLink", + "references": "referenceType", + "required": true + } + ] +} diff --git a/schemas/app/ERPNextSyncSettings.json b/schemas/app/ERPNextSyncSettings.json new file mode 100644 index 000000000..3f5a55a9c --- /dev/null +++ b/schemas/app/ERPNextSyncSettings.json @@ -0,0 +1,184 @@ +{ + "name": "ERPNextSyncSettings", + "label": "ERPNext Sync Settings", + "isSingle": true, + "isChild": false, + "isSubmittable": false, + "fields": [ + { + "label": "API Endpoint", + "fieldname": "endpoint", + "fieldtype": "Data", + "required": true, + "section": "Default" + }, + { + "label": "Auth Token", + "fieldname": "authToken", + "fieldtype": "Secret", + "required": true, + "section": "Default" + }, + { + "label": "FBooks Integration Version", + "fieldname": "integrationAppVersion", + "fieldtype": "Data", + "readOnly": true, + "section": "Default" + }, + { + "label": "Is Sync Enabled", + "fieldname": "isEnabled", + "fieldtype": "Check", + "readOnly": true, + "section": "Default" + }, + { + "label": "Data Sync Interval", + "fieldname": "dataSyncInterval", + "fieldtype": "Int", + "readOnly": true, + "section": "Default" + }, + { + "label": "Sync Item", + "fieldname": "syncItem", + "fieldtype": "Check", + "readOnly": true, + "section": "Item" + }, + { + "label": "Item Sync Type", + "fieldname": "itemSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Item" + }, + { + "label": "Sync Customer", + "fieldname": "syncCustomer", + "fieldtype": "Check", + "readOnly": true, + "section": "Customer" + }, + { + "label": "Customer Sync Type", + "fieldname": "customerSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Customer" + }, + { + "label": "Sync Supplier", + "fieldname": "syncSupplier", + "fieldtype": "Check", + "readOnly": true, + "section": "Supplier" + }, + { + "label": "Supplier Sync Type", + "fieldname": "supplierSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Supplier" + }, + { + "label": "Sync Sales Invoice", + "fieldname": "syncSalesInvoice", + "fieldtype": "Check", + "readOnly": true, + "section": "Sales Invoice" + }, + { + "label": "Sales Invoice Sync Type", + "fieldname": "salesInvoiceSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Sales Invoice" + }, + { + "label": "Sync SINV Payment", + "fieldname": "syncSalesInvoicePayment", + "fieldtype": "Check", + "readOnly": true, + "section": "Sales Payment" + }, + { + "label": "SINV Payment Sync Type", + "fieldname": "sinvPaymentSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Sales Payment" + }, + { + "label": "Sync Stock Movement", + "fieldname": "syncStockMovement", + "fieldtype": "Check", + "readOnly": true, + "section": "Stock Movement" + }, + { + "label": "Stock Entry Sync Type", + "fieldname": "stockMovementSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Stock Movement" + }, + { + "label": "Sync Price List", + "fieldname": "syncPriceList", + "fieldtype": "Check", + "readOnly": true, + "section": "Price List" + }, + { + "label": "Price List Sync Type", + "fieldname": "priceListSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Price List" + }, + { + "label": "Sync Serial Number", + "fieldname": "syncSerialNumber", + "fieldtype": "Check", + "readOnly": true, + "section": "Serial Number" + }, + { + "label": "Serial Number Sync Type", + "fieldname": "serialNumberSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Serial Number" + }, + { + "label": "Sync Batch", + "fieldname": "syncBatch", + "fieldtype": "Check", + "readOnly": true, + "section": "Batch" + }, + { + "label": "Batch Sync Type", + "fieldname": "batchSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Batch" + }, + { + "label": "Sync Delivery Note", + "fieldname": "syncShipment", + "fieldtype": "Check", + "readOnly": true, + "section": "Shipment" + }, + { + "label": "Sync Type", + "fieldname": "shipmentSyncType", + "fieldtype": "Data", + "readOnly": true, + "section": "Shipment" + } + ] +} diff --git a/schemas/app/FetchFromERPNextQueue.json b/schemas/app/FetchFromERPNextQueue.json new file mode 100644 index 000000000..d28f5c07f --- /dev/null +++ b/schemas/app/FetchFromERPNextQueue.json @@ -0,0 +1,62 @@ +{ + "name": "FetchFromERPNextQueue", + "label": "Fetch From ERPNext Queue", + "naming": "random", + "fields": [ + { + "fieldname": "referenceType", + "label": "Ref. Type", + "fieldtype": "Select", + "options": [ + { + "value": "Item", + "label": "Item" + }, + { + "value": "Party", + "label": "Party" + }, + { + "value": "SalesInvoice", + "label": "Sales Invoice" + }, + { + "value": "Payment", + "label": "Sales Payment" + }, + { + "value": "StockMovement", + "label": "Stock" + }, + { + "value": "PriceList", + "label": "Price List" + }, + { + "value": "SerialNumber", + "label": "Serial Number" + }, + { + "value": "Batch", + "label": "Batch" + }, + { + "value": "UOM", + "label": "UOM" + }, + { + "value": "Address", + "label": "Address" + } + ], + "required": true + }, + { + "fieldname": "documentName", + "label": "Document Name", + "fieldtype": "Data", + "references": "referenceType", + "required": true + } + ] +} diff --git a/schemas/app/PriceList.json b/schemas/app/PriceList.json index 9d2c3a30e..bd024e0d7 100644 --- a/schemas/app/PriceList.json +++ b/schemas/app/PriceList.json @@ -32,7 +32,6 @@ "label": "Item Prices", "fieldtype": "Table", "target": "PriceListItem", - "required": true, "section": "Item Prices" } ] diff --git a/schemas/schemas.ts b/schemas/schemas.ts index f3d56c23c..273988c7c 100644 --- a/schemas/schemas.ts +++ b/schemas/schemas.ts @@ -72,6 +72,9 @@ import OpeningCash from './app/inventory/Point of Sale/OpeningCash.json'; import POSSettings from './app/inventory/Point of Sale/POSSettings.json'; import POSShift from './app/inventory/Point of Sale/POSShift.json'; import POSShiftAmounts from './app/inventory/Point of Sale/POSShiftAmounts.json'; +import ERPNextSyncSettings from './app/ERPNextSyncSettings.json'; +import ERPNextSyncQueue from './app/ERPNextSyncQueue.json'; +import FetchFromERPNextQueue from './app/FetchFromERPNextQueue.json'; import { Schema, SchemaStub } from './types'; export const coreSchemas: Schema[] = [ @@ -172,4 +175,8 @@ export const appSchemas: Schema[] | SchemaStub[] = [ POSSettings as Schema, POSShift as Schema, POSShiftAmounts as Schema, + + ERPNextSyncSettings as Schema, + ERPNextSyncQueue as Schema, + FetchFromERPNextQueue as Schema, ]; diff --git a/src/App.vue b/src/App.vue index 001149243..1d9e04900 100644 --- a/src/App.vue +++ b/src/App.vue @@ -70,6 +70,7 @@ import { Shortcuts } from './utils/shortcuts'; import { routeTo } from './utils/ui'; import { useKeys } from './utils/vueUtils'; import { setDarkMode } from 'src/utils/theme'; +import { initERPNSync, updateERPNSyncSettings } from './utils/erpnextSync'; enum Screen { Desk = 'Desk', @@ -224,6 +225,8 @@ export default defineComponent({ await initializeInstance(filePath, false, countryCode, fyo); await updatePrintTemplates(fyo); + await updateERPNSyncSettings(fyo); + initERPNSync(fyo); await this.setDesk(filePath); }, async handleConnectionFailed(error: Error, actionSymbol: symbol) { diff --git a/src/components/Controls/FormControl.vue b/src/components/Controls/FormControl.vue index cab1653c9..53d3ac981 100644 --- a/src/components/Controls/FormControl.vue +++ b/src/components/Controls/FormControl.vue @@ -15,6 +15,7 @@ import Int from './Int.vue'; import Link from './Link.vue'; import Select from './Select.vue'; import Text from './Text.vue'; +import Secret from './Secret.vue'; const components = { AttachImage, @@ -32,6 +33,7 @@ const components = { Attachment, Currency, Text, + Secret, }; export default { diff --git a/src/components/Controls/Secret.vue b/src/components/Controls/Secret.vue new file mode 100644 index 000000000..7141ff964 --- /dev/null +++ b/src/components/Controls/Secret.vue @@ -0,0 +1,14 @@ + diff --git a/src/pages/Settings/Settings.vue b/src/pages/Settings/Settings.vue index e8932c821..9b6de3f20 100644 --- a/src/pages/Settings/Settings.vue +++ b/src/pages/Settings/Settings.vue @@ -129,6 +129,7 @@ export default defineComponent({ ModelNameEnum.InventorySettings, ModelNameEnum.Defaults, ModelNameEnum.POSSettings, + ModelNameEnum.ERPNextSyncSettings, ModelNameEnum.PrintSettings, ModelNameEnum.SystemSettings, ].some((s) => this.fyo.singles[s]?.canSave); @@ -148,6 +149,7 @@ export default defineComponent({ [ModelNameEnum.InventorySettings]: this.t`Inventory`, [ModelNameEnum.Defaults]: this.t`Defaults`, [ModelNameEnum.POSSettings]: this.t`POS Settings`, + [ModelNameEnum.ERPNextSyncSettings]: this.t`ERPNext Sync`, [ModelNameEnum.SystemSettings]: this.t`System`, }; }, @@ -156,12 +158,15 @@ export default defineComponent({ !!this.fyo.singles.AccountingSettings?.enableInventory; const enablePOS = !!this.fyo.singles.InventorySettings?.enablePointOfSale; + const enableERPNextSync = + !!this.fyo.singles.AccountingSettings?.enableERPNextSync; return [ ModelNameEnum.AccountingSettings, ModelNameEnum.InventorySettings, ModelNameEnum.Defaults, ModelNameEnum.POSSettings, + ModelNameEnum.ERPNextSyncSettings, ModelNameEnum.PrintSettings, ModelNameEnum.SystemSettings, ] @@ -173,6 +178,11 @@ export default defineComponent({ if (s === ModelNameEnum.POSSettings && !enablePOS) { return false; } + + if (s === ModelNameEnum.ERPNextSyncSettings && !enableERPNextSync) { + return false; + } + return true; }) .map((s) => this.fyo.schemaMap[s]!); diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 000000000..073686d2d --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,6 @@ +export async function sendAPIRequest( + endpoint: string, + options: RequestInit | undefined +) { + return await ipc.sendAPIRequest(endpoint, options); +} diff --git a/src/utils/erpnextSync.ts b/src/utils/erpnextSync.ts new file mode 100644 index 000000000..af021dd13 --- /dev/null +++ b/src/utils/erpnextSync.ts @@ -0,0 +1,659 @@ +import { Fyo } from 'fyo'; +import { sendAPIRequest } from './api'; +import { ModelNameEnum } from 'models/types'; +import { ERPNextSyncSettings } from 'models/baseModels/ERPNextSyncSettings/ERPNextSyncSettings'; +import { DocValueMap } from 'fyo/core/types'; +import { Doc } from 'fyo/model/doc'; +import { ERPNextSyncQueue } from 'models/baseModels/ERPNextSyncQueue/ERPNextSyncQueue'; +import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; +import { StockMovementItem } from 'models/inventory/StockMovementItem'; + +export async function updateERPNSyncSettings(fyo: Fyo) { + const syncSettingsDoc = (await fyo.doc.getDoc( + ModelNameEnum.ERPNextSyncSettings + )) as ERPNextSyncSettings; + + const endpoint = syncSettingsDoc.endpoint; + const authToken = syncSettingsDoc.authToken; + + if (!endpoint || !authToken) { + return; + } + + const res = await getERPNSyncSettings(endpoint, authToken); + if (!res || !res.message || !res.message.success) { + return; + } + + await syncSettingsDoc.setMultiple(parseSyncSettingsData(res)); + await syncSettingsDoc.sync(); +} + +async function getERPNSyncSettings( + endpoint: string, + token: string +): Promise { + try { + return (await sendAPIRequest( + `${endpoint}/api/method/books_integration.api.sync_settings`, + { + headers: { + Authorization: `token ${token}`, + }, + } + )) as unknown as ERPNextSyncSettingsAPIResponse; + } catch (error) { + return; + } +} + +export function initERPNSync(fyo: Fyo) { + const isSyncEnabled = fyo.singles.ERPNextSyncSettings?.isEnabled; + if (!isSyncEnabled) { + return; + } + + const syncInterval = fyo.singles.ERPNextSyncSettings + ?.dataSyncInterval as number; + + if (!syncInterval) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + setInterval(async () => { + await syncFetchFromERPNextQueue(fyo); + await syncDocumentsFromERPNext(fyo); + await syncDocumentsToERPNext(fyo); + }, syncInterval); +} + +export async function syncDocumentsFromERPNext(fyo: Fyo) { + const isEnabled = fyo.singles.ERPNextSyncSettings?.isEnabled; + if (!isEnabled) { + return; + } + + const token = fyo.singles.ERPNextSyncSettings?.authToken as string; + const endpoint = fyo.singles.ERPNextSyncSettings?.endpoint as string; + + if (!token || !endpoint) { + return; + } + + const docsToSync = await getDocsFromERPNext(endpoint, token); + + if (!docsToSync || !docsToSync.message.success || !docsToSync.message.data) { + return; + } + + for (const doc of docsToSync.message.data) { + if (!(getDocTypeName(doc) in ModelNameEnum)) { + continue; + } + + try { + if ((doc.fbooksDocName as string) || (doc.name as string)) { + const isDocExists = await fyo.db.exists( + getDocTypeName(doc), + (doc.fbooksDocName as string) || (doc.name as string) + ); + + if (isDocExists) { + const existingDoc = await fyo.doc.getDoc( + getDocTypeName(doc), + (doc.fbooksDocName as string) || (doc.name as string) + ); + + await existingDoc.setMultiple(doc); + await performPreSync(fyo, doc); + existingDoc._addDocToSyncQueue = false; + + await existingDoc.sync(); + + if (doc.submitted) { + await existingDoc.submit(); + } + + if (doc.cancelled) { + await existingDoc.cancel(); + } + + continue; + } + } + } catch (error) {} + + try { + const newDoc = fyo.doc.getNewDoc(getDocTypeName(doc), doc); + + await performPreSync(fyo, doc); + newDoc._addDocToSyncQueue = false; + + await newDoc.sync(); + + if (doc.submitted) { + await newDoc.submit(); + } + + if (doc.cancelled) { + await newDoc.cancel(); + } + + await afterDocSync( + endpoint, + token, + doc, + doc.name as string, + newDoc.name as string + ); + } catch (error) { + return error; + } + } +} + +async function performPreSync(fyo: Fyo, doc: DocValueMap) { + switch (doc.doctype) { + case ModelNameEnum.Item: + const isUnitExists = await fyo.db.exists( + ModelNameEnum.UOM, + doc.unit as string + ); + + const isUnitExistsInQueue = ( + await fyo.db.getAll(ModelNameEnum.FetchFromERPNextQueue, { + filters: { + referenceType: ModelNameEnum.UOM, + documentName: doc.unit as string, + }, + }) + ).length; + + if (!isUnitExists && !isUnitExistsInQueue) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.UOM, + documentName: doc.unit, + }); + } + + if (doc.uomConversions) { + for (const row of doc.uomConversions as DocValueMap[]) { + const isUnitExists = await fyo.db.exists( + ModelNameEnum.UOM, + row.uom as string + ); + + if (!isUnitExists && !isUnitExistsInQueue) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.UOM, + documentName: row.uom, + }); + } + } + } + return; + + case 'Customer': + case 'Supplier': + const isAddressExists = await fyo.db.exists( + ModelNameEnum.Address, + doc.address as string + ); + + if (!isAddressExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.Address, + documentName: doc.address, + }); + } + + return; + + case ModelNameEnum.SalesInvoice: + return await preSyncSalesInvoice(fyo, doc as SalesInvoice); + + case ModelNameEnum.StockMovement: + if (!doc || !doc.items) { + return; + } + + for (const item of doc.items as StockMovementItem[]) { + const isItemExists = await fyo.db.exists(ModelNameEnum.Item, item.item); + + if (!isItemExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.Item, + documentName: item.item, + }); + } + } + return; + default: + return; + } +} + +async function preSyncSalesInvoice(fyo: Fyo, doc: SalesInvoice) { + const isPartyExists = await fyo.db.exists( + ModelNameEnum.Party, + doc.party as string + ); + + if (!isPartyExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.Party, + documentName: doc.party, + }); + } + + if (doc.items) { + for (const item of doc.items) { + const isUnitExists = await fyo.db.exists(ModelNameEnum.UOM, item.unit); + if (!isUnitExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.UOM, + documentName: item.unit, + }); + } + + const isItemExists = await fyo.db.exists(ModelNameEnum.Item, item.item); + if (!isItemExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.Item, + documentName: item.item, + }); + } + + if (item.batch) { + const isBatchExists = await fyo.db.exists( + ModelNameEnum.Batch, + item.batch + ); + + if (!isBatchExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.Batch, + documentName: item.batch, + }); + } + } + } + } + + if (doc.priceList) { + const isPriceListExists = await fyo.db.exists( + ModelNameEnum.PriceList, + doc.priceList + ); + + if (!isPriceListExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.PriceList, + documentName: doc.priceList, + }); + } + } +} + +async function addToFetchFromERPNextQueue(fyo: Fyo, data: DocValueMap) { + await fyo.doc.getNewDoc(ModelNameEnum.FetchFromERPNextQueue, data).sync(); +} + +export async function syncDocumentsToERPNext(fyo: Fyo) { + const isEnabled = fyo.singles.ERPNextSyncSettings?.isEnabled; + if (!isEnabled) { + return; + } + + const token = fyo.singles.ERPNextSyncSettings?.authToken as string; + const endpoint = fyo.singles.ERPNextSyncSettings?.endpoint as string; + + if (!token || !endpoint) { + return; + } + + const docsToSync = []; + const syncQueueItems = (await fyo.db.getAll(ModelNameEnum.ERPNextSyncQueue, { + fields: ['referenceType', 'documentName'], + order: 'desc', + })) as ERPNextSyncQueue[]; + + if (!syncQueueItems.length) { + return; + } + + for (const doc of syncQueueItems) { + const referenceDoc = await fyo.doc.getDoc( + doc.referenceType as ModelNameEnum, + doc.documentName + ); + + if (!referenceDoc) { + continue; + } + + docsToSync.push({ + doctype: getDocTypeName(referenceDoc), + ...referenceDoc.getValidDict(), + }); + } + + if (!docsToSync.length) { + return; + } + + try { + const res = (await sendAPIRequest( + `${endpoint}/api/method/books_integration.api.insert_docs`, + { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + body: JSON.stringify({ payload: docsToSync }), + } + )) as unknown as InsertDocsAPIResponse; + + if (res.message.success) { + if (!res.message.success_log.length) { + return; + } + + for (const doc of res.message.success_log) { + const filteredLogDoc = await fyo.db.getAll( + ModelNameEnum.ERPNextSyncQueue, + { + filters: { + referenceType: getDocTypeName(doc), + documentName: doc.name, + }, + } + ); + + if (!filteredLogDoc.length) { + return; + } + + const logDoc = await fyo.doc.getDoc( + ModelNameEnum.ERPNextSyncQueue, + filteredLogDoc[0].name as string + ); + + await logDoc.delete(); + } + } + } catch (error) { + return error; + } +} + +async function syncFetchFromERPNextQueue(fyo: Fyo) { + const docsInQueue = await fyo.db.getAll(ModelNameEnum.FetchFromERPNextQueue, { + fields: ['referenceType', 'documentName'], + }); + + if (!docsInQueue.length) { + return; + } + + const token = fyo.singles.ERPNextSyncSettings?.authToken as string; + const endpoint = fyo.singles.ERPNextSyncSettings?.endpoint as string; + + if (!token || !endpoint) { + return; + } + + try { + const res = (await sendAPIRequest( + `${endpoint}/api/method/books_integration.api.sync_queue`, + { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + body: JSON.stringify({ records: docsInQueue }), + } + )) as unknown as ERPNSyncDocsResponse; + + if (!res.message.success) { + return; + } + + if (!res.message.success_log) { + return; + } + + for (const row of res.message.success_log) { + const isDocExisitsInQueue = await fyo.db.getAll( + ModelNameEnum.FetchFromERPNextQueue, + { + filters: { + referenceType: row.doctype_name as string, + documentName: row.document_name as string, + }, + } + ); + + if (!isDocExisitsInQueue.length) { + continue; + } + + const existingDoc = await fyo.doc.getDoc( + ModelNameEnum.FetchFromERPNextQueue, + isDocExisitsInQueue[0].name as string + ); + await existingDoc.delete(); + } + } catch (error) { + return undefined; + } +} + +async function getDocsFromERPNext( + endpoint: string, + token: string +): Promise { + try { + return (await sendAPIRequest( + `${endpoint}/api/method/books_integration.api.sync_queue`, + { + headers: { + Authorization: `token ${token}`, + }, + } + )) as unknown as ERPNSyncDocsResponse; + } catch (error) { + return undefined; + } +} + +async function afterDocSync( + endpoint: string, + token: string, + doc: Doc | DocValueMap, + erpnDocName: string, + fbooksDocName: string +) { + const res = await ipc.sendAPIRequest( + `${endpoint}/api/method/books_integration.api.perform_aftersync`, + { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + body: JSON.stringify({ + doctype: getDocTypeName(doc), + nameInERPNext: erpnDocName, + nameInFBooks: fbooksDocName, + doc, + }), + } + ); + return res; +} + +export function getShouldDocSyncToERPNext( + syncSettings: ERPNextSyncSettings, + doc: Doc +): boolean { + switch (doc.schemaName) { + case ModelNameEnum.Payment: + const isSalesPayment = doc.referenceType === ModelNameEnum.SalesInvoice; + return ( + isSalesPayment && syncSettings.sinvPaymentType !== 'ERPNext to FBooks' + ); + + case ModelNameEnum.Party: + const isCustomer = doc.role !== 'Supplier'; + + if (isCustomer) { + return ( + !!syncSettings.syncCustomer && + syncSettings.customerSyncType !== 'ERPNext to FBooks' + ); + } + + return ( + !!syncSettings.syncSupplier && + syncSettings.supplierSyncType !== 'ERPNext to FBooks' + ); + + case 'PriceListItem': + const isPriceListSyncEnabled = !!syncSettings.syncPriceList; + + return ( + isPriceListSyncEnabled && + syncSettings.supplierSyncType !== 'ERPNext to FBooks' + ); + + default: + const schemaName = + doc.schemaName[0].toLowerCase() + doc.schemaName.substring(1); + + if (!syncSettings[`${schemaName}SyncType`]) { + return false; + } + + return syncSettings[`${schemaName}SyncType`] !== 'ERPNext to FBooks'; + } +} + +function getDocTypeName(doc: DocValueMap | Doc): string { + const doctype = + doc.schemaName ?? doc.referenceType ?? (doc.doctype as string); + + if (['Supplier', 'Customer'].includes(doctype as string)) { + return ModelNameEnum.Party; + } + + if (doctype === 'Party') { + if (doc.role && doc.role !== 'Both') { + return doc.role as string; + } + } + + return doctype as string; +} + +export interface InsertDocsAPIResponse { + message: { + success: boolean; + success_log: { name: string; doctype: string }[]; + failed_log: { name: string; doctype: string }[]; + }; +} + +export interface ERPNSyncDocsResponse { + message: { + success: boolean; + data: DocValueMap[]; + success_log?: DocValueMap[]; + failed_log?: DocValueMap[]; + }; +} +export interface FetchFromBooksResponse { + message: { + success: boolean; + data?: DocValueMap[]; + }; +} + +export interface ERPNextSyncSettingsAPIResponse { + message: { + success: boolean; + app_version: string; + data: { + name: string; + owner: string; + modified: string; + modified_by: string; + docstatus: boolean; + idx: string; + enable_sync: boolean; + sync_dependant_masters: boolean; + sync_interval: number; + sync_item: boolean; + item_sync_type: string; + sync_customer: boolean; + customer_sync_type: string; + sync_supplier: boolean; + supplier_sync_type: string; + sync_sales_invoice: boolean; + sales_invoice_sync_type: string; + sync_sales_payment: boolean; + sales_payment_sync_type: string; + sync_stock: boolean; + stock_sync_type: string; + sync_price_list: boolean; + price_list_sync_type: string; + sync_serial_number: boolean; + serial_number_sync_type: string; + sync_batches: boolean; + batch_sync_type: string; + sync_delivery_note: boolean; + delivery_note_sync_type: string; + doctype: string; + }; + }; +} + +function parseSyncSettingsData( + res: ERPNextSyncSettingsAPIResponse +): DocValueMap { + return { + integrationAppVersion: res.message.app_version, + isEnabled: !!res.message.data.enable_sync, + dataSyncInterval: res.message.data.sync_interval, + + syncItem: res.message.data.sync_item, + itemSyncType: res.message.data.item_sync_type, + + syncCustomer: res.message.data.sync_customer, + customerSyncType: res.message.data.customer_sync_type, + + syncSupplier: res.message.data.sync_supplier, + supplierSyncType: res.message.data.supplier_sync_type, + + syncSalesInvoice: res.message.data.sync_sales_invoice, + salesInvoiceSyncType: res.message.data.sales_invoice_sync_type, + + syncSalesInvoicePayment: res.message.data.sync_sales_payment, + sinvPaymentSyncType: res.message.data.sales_payment_sync_type, + + syncStockMovement: res.message.data.sync_stock, + stockMovementSyncType: res.message.data.stock_sync_type, + + syncPriceList: res.message.data.sync_price_list, + priceListSyncType: res.message.data.price_list_sync_type, + + syncSerialNumber: res.message.data.sync_serial_number, + serialNumberSyncType: res.message.data.serial_number_sync_type, + + syncBatch: res.message.data.sync_batches, + batchSyncType: res.message.data.batch_sync_type, + + syncShipment: res.message.data.sync_delivery_note, + shipmentSyncType: res.message.data.delivery_note_sync_type, + }; +} diff --git a/utils/messages.ts b/utils/messages.ts index 601a94ce6..f8c1bd149 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -33,6 +33,7 @@ export enum IPC_ACTIONS { GET_TEMPLATES = 'get-templates', DELETE_FILE = 'delete-file', GET_DB_DEFAULT_PATH = 'get-db-default-path', + SEND_API_REQUEST = 'send-api-request', // Database messages DB_CREATE = 'db-create', DB_CONNECT = 'db-connect',