diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index a5b23b6d..7d899073 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 */ @@ -247,6 +249,22 @@ export class Doc extends Observable { return true; } + get shouldDocSyncToERPNext() { + 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 +930,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 00000000..f9e326c5 --- /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 0e489284..5cfd3ef6 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 d3e60626..0e32af7d 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/helpers.ts b/models/helpers.ts index f6ee08e7..957fdaa3 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/types.ts b/models/types.ts index ff70c075..64aa9285 100644 --- a/models/types.ts +++ b/models/types.ts @@ -61,7 +61,9 @@ export enum ModelNameEnum { POSSettings = 'POSSettings', POSShift = 'POSShift', - ERPNextSyncSettings= 'ERPNextSyncSettings' + ERPNextSyncSettings= 'ERPNextSyncSettings', + ERPNextSyncQueue = 'ERPNextSyncQueue', + FetchFromERPNextQueue = 'FetchFromERPNextQueue', } export type ModelName = keyof typeof ModelNameEnum; diff --git a/src/App.vue b/src/App.vue index 00114924..1d9e0490 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/utils/api.ts b/src/utils/api.ts new file mode 100644 index 00000000..073686d2 --- /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 00000000..0d72eb7d --- /dev/null +++ b/src/utils/erpnextSync.ts @@ -0,0 +1,631 @@ +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'; + +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; + + 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; + const endpoint = fyo.singles.ERPNextSyncSettings?.endpoint; + + 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); + 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); + 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 ModelNameEnum.Party: + const isAddressExists = await fyo.db.exists( + ModelNameEnum.Address, + doc.addressName as string + ); + + if (!isAddressExists) { + await addToFetchFromERPNextQueue(fyo, { + referenceType: ModelNameEnum.Address, + documentName: doc.addressName, + }); + } + + return; + + case ModelNameEnum.SalesInvoice: + return await preSyncSalesInvoice(fyo, doc as SalesInvoice); + 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) {} +} + +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 ModelNameEnum.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 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 601a94ce..f8c1bd14 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',