2
0
mirror of https://github.com/frappe/books.git synced 2025-01-09 09:50:27 +00:00

feat: erpnext sync

This commit is contained in:
akshayitzme 2024-11-26 12:39:58 +05:30
parent d304e9cd38
commit 603f2d972f
10 changed files with 738 additions and 1 deletions

View File

@ -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<DocValue | Doc[]> {
/* eslint-disable @typescript-eslint/no-floating-promises */
@ -247,6 +249,22 @@ export class Doc extends Observable<DocValue | Doc[]> {
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<DocValue | Doc[]> {
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;
}

10
main/api.ts Normal file
View File

@ -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;
}[];
}

View File

@ -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);
},

View File

@ -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
*/

View File

@ -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 {

View File

@ -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;

View File

@ -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) {

6
src/utils/api.ts Normal file
View File

@ -0,0 +1,6 @@
export async function sendAPIRequest(
endpoint: string,
options: RequestInit | undefined
) {
return await ipc.sendAPIRequest(endpoint, options);
}

631
src/utils/erpnextSync.ts Normal file
View File

@ -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<ERPNextSyncSettingsAPIResponse | undefined> {
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<ERPNSyncDocsResponse | undefined> {
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,
};
}

View File

@ -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',