diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..21fb8066 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Rename 'frappe' to 'fyo' outside src +32d282dc9c6f129807a1cf53eae47fc3602aa976 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..d89325b6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +**/types.ts \ No newline at end of file diff --git a/META.md b/META.md new file mode 100644 index 00000000..df58d2c7 --- /dev/null +++ b/META.md @@ -0,0 +1,91 @@ +This `md` lays out how this project is structured. + +## Execution + +Since it's an electron project, there are two points from where the execution +begins. + +1. **Main Process**: Think of this as the _server_, the file where this beings + is `books/main.ts` +2. **Renderer Process**: Think of this as the _client_, the file where this + begins is `books/src/main.js` + +_Note: For more insight into how electron execution is structured check out electron's +[Process Model](https://www.electronjs.org/docs/latest/tutorial/process-model)._ + +This process is architected in a _client-server_ manner. If the _client_ side +requires resources from the _server_ side, it does so by making use of +`ipcRenderer.send` or `ipcRenderer.invoke` i.e. if the front end is being run on +electron. + +The `ipcRenderer` calls are done only in `fyo/demux/*.ts` files. I.e. these +are the only files on the _client_ side that are aware of the platform the +_client_ is being run on i.e. `electron` or Browser. So all platform specific +calls should go through these _demux_ files. + +## Code Structure + +Code is structured in a way so as to maintain clear separation between what each +set of files structured under some subdirectory does. It is also to maintain a +clear separation between the _client_ and the _server_. + +The _client_ code should not be calling _server_ code directly (i.e. by +importing it) and vice-versa. This is to maintain the _client_ code in a +platform agnostic manner. + +Some of the code is side agnostic, i.e. can be called from the _client_ or the +_server_. Only code that doesn't have platform specific calls example using +`node` `fs` or the browsers `window`. Ideally this code won't have an imports. + +### Special Folders + +Here's a list of subdirectories and their purposes, for more details on +individual ones, check the `README.md` in those subdirectories: + +| Folder | Side | Description | +| -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | +| `main` | _server_ | Electron main process specific code called from `books/main.ts` | +| `schemas` | _server_ | Collection of database schemas in a `json` format and the code to combine them | +| `backend` | _server_ | Database management and CRUD calls | +| `scripts` | _server_ | Code that is not called when the project is running, but separately to run some task for instance to generate translations | +| `build` | _server_ | Build specific files not used unless building the project | +| `translations` | _server_ | Collection of csv files containing translations | +| `src` | _client_ | Code that mainly deals with the view layer (all `.vue` are stored here) | +| `reports` | _client\*_ | Collection of logic code and view layer config files for displaying reports. | +| `models` | _client\*_ | Collection of `Model.ts` files that manage the data and some business logic on the client side. | +| `fyo` | _client\*_ | Code for the underlying library that manages the client side | +| `utils` | _agnostic_ | Collection of code used by either sides. | +| `dummy` | _agnostic_ | Code used to generate dummy data for testing or demo purposes | + +#### _client\*_ + +The code in these folders is called during runtime from the _client_ +side but since they contain business logic, they are tested using `mocha` on the +_server_ side. This is a bit stupid and so will be fixed later. + +Due to this, the code in these files should not be calling _client_ side code +directly. If client side code is to be called, it should be done so only by +using dynamic imports, i.e. `await import('...')` along pathways that won't run +in a test. + +### Special Files + +Other than this there are two special types of files: + +#### `**/types.ts` + +These contains all the type information, these files are side agnostic and +should only import code from other type files. + +The type information contained depends on the folder it is under i.e. where the +code associated with the types is written. + +If trying to understand the code in this project I'd suggest not ignoring these. + +#### `**/test/*.spec.ts` + +These contain tests, as of now all tests run on the _server_ side using `mocha`. + +The tests files are located in `**/test` folders which are nested under the +directories of what they are testing. No code from these files is called during +runtime. diff --git a/README.md b/README.md index cdf413cc..45f87651 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,15 @@ If you want to contribute code then you can fork this repo, make changes and rai ## Translation Contributors -| Language | Contributors | -|----|---| -| French | [DeepL](https://www.deepl.com/) | -| German | [DeepL](https://www.deepl.com/), [barredterra](https://github.com/barredterra) | -| Portuguese | [DeepL](https://www.deepl.com/) | -| Arabic | [taha2002](https://github.com/taha2002) | -| Catalan | Dídac E. Jiménez | +| Language | Contributors | +| ---------- | ------------------------------------------------------------------------------ | +| French | [DeepL](https://www.deepl.com/) | +| German | [DeepL](https://www.deepl.com/), [barredterra](https://github.com/barredterra) | +| Portuguese | [DeepL](https://www.deepl.com/) | +| Arabic | [taha2002](https://github.com/taha2002) | +| Catalan | Dídac E. Jiménez | +| Dutch | [FastAct](https://github.com/FastAct) | +| Spanish | [talmax1124](https://github.com/talmax1124) | ## License diff --git a/accounting/exchangeRate.js b/accounting/exchangeRate.js deleted file mode 100644 index 8555fd0b..00000000 --- a/accounting/exchangeRate.js +++ /dev/null @@ -1,31 +0,0 @@ -import frappe from 'frappe'; -import { DateTime } from 'luxon'; - -export async function getExchangeRate({ fromCurrency, toCurrency, date }) { - if (!date) { - date = DateTime.local().toISODate(); - } - if (!fromCurrency || !toCurrency) { - throw new frappe.errors.NotFoundError( - 'Please provide `fromCurrency` and `toCurrency` to get exchange rate.' - ); - } - let cacheKey = `currencyExchangeRate:${date}:${fromCurrency}:${toCurrency}`; - let exchangeRate = parseFloat(localStorage.getItem(cacheKey)); - if (!exchangeRate) { - try { - let res = await fetch( - ` https://api.vatcomply.com/rates?date=${date}&base=${fromCurrency}&symbols=${toCurrency}` - ); - let data = await res.json(); - exchangeRate = data.rates[toCurrency]; - localStorage.setItem(cacheKey, exchangeRate); - } catch (error) { - console.error(error); - throw new Error( - `Could not fetch exchange rate for ${fromCurrency} -> ${toCurrency}` - ); - } - } - return exchangeRate; -} diff --git a/accounting/gst.js b/accounting/gst.js deleted file mode 100644 index 718d71c6..00000000 --- a/accounting/gst.js +++ /dev/null @@ -1,368 +0,0 @@ -import { showMessageDialog } from '@/utils'; -import frappe, { t } from 'frappe'; -import { DateTime } from 'luxon'; -import { exportCsv, saveExportData } from '../reports/commonExporter'; -import { getSavePath } from '../src/utils'; - -// prettier-ignore -export const stateCodeMap = { - 'JAMMU AND KASHMIR': '1', - 'HIMACHAL PRADESH': '2', - 'PUNJAB': '3', - 'CHANDIGARH': '4', - 'UTTARAKHAND': '5', - 'HARYANA': '6', - 'DELHI': '7', - 'RAJASTHAN': '8', - 'UTTAR PRADESH': '9', - 'BIHAR': '10', - 'SIKKIM': '11', - 'ARUNACHAL PRADESH': '12', - 'NAGALAND': '13', - 'MANIPUR': '14', - 'MIZORAM': '15', - 'TRIPURA': '16', - 'MEGHALAYA': '17', - 'ASSAM': '18', - 'WEST BENGAL': '19', - 'JHARKHAND': '20', - 'ODISHA': '21', - 'CHATTISGARH': '22', - 'MADHYA PRADESH': '23', - 'GUJARAT': '24', - 'DADRA AND NAGAR HAVELI AND DAMAN AND DIU': '26', - 'MAHARASHTRA': '27', - 'KARNATAKA': '29', - 'GOA': '30', - 'LAKSHADWEEP': '31', - 'KERALA': '32', - 'TAMIL NADU': '33', - 'PUDUCHERRY': '34', - 'ANDAMAN AND NICOBAR ISLANDS': '35', - 'TELANGANA': '36', - 'ANDHRA PRADESH': '37', - 'LADAKH': '38', -}; - -const GST = { - 'GST-0': 0, - 'GST-0.25': 0.25, - 'GST-3': 3, - 'GST-5': 5, - 'GST-6': 6, - 'GST-12': 12, - 'GST-18': 18, - 'GST-28': 28, - 'IGST-0': 0, - 'IGST-0.25': 0.25, - 'IGST-3': 3, - 'IGST-5': 5, - 'IGST-6': 6, - 'IGST-12': 12, - 'IGST-18': 18, - 'IGST-28': 28, -}; - -const CSGST = { - 'GST-0': 0, - 'GST-0.25': 0.125, - 'GST-3': 1.5, - 'GST-5': 2.5, - 'GST-6': 3, - 'GST-12': 6, - 'GST-18': 9, - 'GST-28': 14, -}; - -const IGST = { - 'IGST-0.25': 0.25, - 'IGST-3': 3, - 'IGST-5': 5, - 'IGST-6': 6, - 'IGST-12': 12, - 'IGST-18': 18, - 'IGST-28': 28, -}; - -export async function generateGstr1Json(getReportData) { - const { gstin } = frappe.AccountingSettings; - if (!gstin) { - showMessageDialog({ - message: t`Export Failed`, - description: t`Please set GSTIN in General Settings.`, - }); - return; - } - - const { - rows, - filters: { transferType, toDate }, - } = getReportData(); - - const { filePath, canceled } = await getSavePath('gstr-1', 'json'); - if (canceled || !filePath) return; - - const gstData = { - version: 'GST3.0.4', - hash: 'hash', - gstin: gstin, - // fp is the the MMYYYY for the last month of the report - // for example if you are extracting report for 1st July 2020 to 31st September 2020 then - // fb = 092020 - fp: DateTime.fromISO(toDate).toFormat('MMyyyy'), - }; - - if (transferType === 'B2B') { - gstData.b2b = await generateB2bData(rows); - } else if (transferType === 'B2CL') { - gstData.b2cl = await generateB2clData(rows); - } else if (transferType === 'B2CS') { - gstData.b2cs = await generateB2csData(rows); - } - - const jsonData = JSON.stringify(gstData); - await saveExportData(jsonData, filePath); -} - -async function generateB2bData(rows) { - const b2b = []; - - for (let row of rows) { - const customer = { - ctin: row.gstin, - inv: [], - }; - - const invRecord = { - inum: row.invNo, - idt: DateTime.fromFormat(row.invDate, 'yyyy-MM-dd').toFormat( - 'dd-MM-yyyy' - ), - val: row.invAmt, - pos: row.gstin && row.gstin.substring(0, 2), - rchrg: row.reverseCharge, - inv_typ: 'R', - itms: [], - }; - - let items = await frappe.db - .knex('SalesInvoiceItem') - .where('parent', invRecord.inum); - - items.forEach((item) => { - const itemRecord = { - num: item.hsnCode, - itm_det: { - txval: frappe.pesa(item.baseAmount).float, - rt: GST[item.tax], - csamt: 0, - camt: frappe - .pesa(CSGST[item.tax] || 0) - .mul(item.baseAmount) - .div(100).float, - samt: frappe - .pesa(CSGST[item.tax] || 0) - .mul(item.baseAmount) - .div(100).float, - iamt: frappe - .pesa(IGST[item.tax] || 0) - .mul(item.baseAmount) - .div(100).float, - }, - }; - - invRecord.itms.push(itemRecord); - }); - - const customerRecord = b2b.find((b) => b.ctin === row.gstin); - - if (customerRecord) { - customerRecord.inv.push(invRecord); - } else { - customer.inv.push(invRecord); - b2b.push(customer); - } - } - - return b2b; -} - -async function generateB2clData(invoices) { - const b2cl = []; - - for (let invoice of invoices) { - const stateInvoiceRecord = { - pos: stateCodeMap[invoice.place.toUpperCase()], - inv: [], - }; - - const invRecord = { - inum: invoice.invNo, - idt: DateTime.fromFormat(invoice.invDate, 'yyyy-MM-dd').toFormat( - 'dd-MM-yyyy' - ), - val: invoice.invAmt, - itms: [], - }; - - let items = await frappe.db - .knex('SalesInvoiceItem') - .where('parent', invRecord.inum); - - items.forEach((item) => { - const itemRecord = { - num: item.hsnCode, - itm_det: { - txval: frappe.pesa(item.baseAmount).float, - rt: GST[item.tax], - csamt: 0, - iamt: frappe - .pesa(invoice.rate || 0) - .mul(item.baseAmount) - .div(100).float, - }, - }; - - invRecord.itms.push(itemRecord); - }); - - const stateRecord = b2cl.find((b) => b.pos === stateCodeMap[invoice.place]); - - if (stateRecord) { - stateRecord.inv.push(invRecord); - } else { - stateInvoiceRecord.inv.push(invRecord); - b2cl.push(stateInvoiceRecord); - } - } - - return b2cl; -} - -async function generateB2csData(invoices) { - const b2cs = []; - - for (let invoice of invoices) { - const pos = invoice.place.toUpperCase(); - - const invRecord = { - sply_ty: invoice.inState ? 'INTRA' : 'INTER', - pos: stateCodeMap[pos], - // "OE" - Abbreviation for errors and omissions excepted. - typ: 'OE', - txval: invoice.taxVal, - rt: invoice.rate, - iamt: !invoice.inState ? (invoice.taxVal * invoice.rate) / 100 : 0, - camt: invoice.inState ? invoice.cgstAmt : 0, - samt: invoice.inState ? invoice.sgstAmt : 0, - csamt: 0, - }; - - b2cs.push(invRecord); - } - - return b2cs; -} - -export async function generateGstr2Csv(getReportData) { - const { gstin } = frappe.AccountingSettings; - if (!gstin) { - showMessageDialog({ - message: t`Export Failed`, - description: t`Please set GSTIN in General Settings.`, - }); - return; - } - - const { - rows, - columns, - filters: { transferType, toDate }, - } = getReportData(); - - const { filePath, canceled } = await getSavePath('gstr-2', 'csv'); - if (canceled || !filePath) return; - - let gstData; - if (transferType === 'B2B') { - gstData = await generateB2bCsvGstr2(rows, columns); - } - - await exportCsv(gstData.rows, gstData.columns, filePath); -} - -async function generateB2bCsvGstr2(rows, columns) { - const csvColumns = [ - { - label: t`GSTIN of Supplier`, - fieldname: 'gstin', - }, - { - label: t`Invoice Number`, - fieldname: 'invNo', - }, - { - label: t`Invoice Date`, - fieldname: 'invDate', - }, - { - label: t`Invoice Value`, - fieldname: 'invAmt', - }, - { - label: t`Place of supply`, - fieldname: 'place', - }, - { - label: t`Reverse Charge`, - fieldname: 'reverseCharge', - }, - { - label: t`Rate`, - fieldname: 'rate', - }, - { - label: t`Taxable Value`, - fieldname: 'taxVal', - }, - { - label: t`Intergrated Tax Paid`, - fieldname: 'igstAmt', - }, - { - label: t`Central Tax Paid`, - fieldname: 'cgstAmt', - }, - { - label: t`State/UT Tax Paid`, - fieldname: 'sgstAmt', - }, - ]; - - return { - columns: csvColumns || [], - rows: rows || [], - }; -} - -export async function generateGstr1Csv(getReportData) { - const { gstin } = frappe.AccountingSettings; - if (!gstin) { - showMessageDialog({ - message: t`Export Failed`, - description: t`Please set GSTIN in General Settings.`, - }); - return; - } - - const { - rows, - columns, - filters: { transferType, toDate }, - } = getReportData(); - - const { filePath, canceled } = await getSavePath('gstr-1', 'csv'); - if (canceled || !filePath) return; - - await exportCsv(rows, columns, filePath); -} diff --git a/accounting/importCOA.js b/accounting/importCOA.js deleted file mode 100644 index d1629840..00000000 --- a/accounting/importCOA.js +++ /dev/null @@ -1,79 +0,0 @@ -import frappe from 'frappe'; -import standardCOA from '../fixtures/verified/standardCOA.json'; -import { getCOAList } from '../src/utils'; -const accountFields = ['accountType', 'accountNumber', 'rootType', 'isGroup']; - -function getAccountName(accountName, accountNumber) { - if (accountNumber) { - return `${accountName} - ${accountNumber}`; - } - return accountName; -} - -async function importAccounts(children, parentAccount, rootType, rootAccount) { - for (let rootName in children) { - if (accountFields.includes(rootName)) { - continue; - } - - const child = children[rootName]; - - if (rootAccount) { - rootType = child.rootType; - } - - const { accountType, accountNumber } = child; - const accountName = getAccountName(rootName, accountNumber); - const isGroup = identifyIsGroup(child); - const doc = frappe.newDoc({ - doctype: 'Account', - name: accountName, - parentAccount, - isGroup, - rootType, - balance: 0, - accountType, - }); - - await doc.insert(); - await importAccounts(child, accountName, rootType); - } -} - -function identifyIsGroup(child) { - if (child.isGroup) { - return child.isGroup; - } - - const keys = Object.keys(child); - const children = keys.filter((key) => !accountFields.includes(key)); - - if (children.length) { - return 1; - } - - return 0; -} - -export async function getCountryCOA(chartOfAccounts) { - const coaList = getCOAList(); - const coa = coaList.find(({ name }) => name === chartOfAccounts); - const conCode = coa.countryCode; - if (!conCode) { - return standardCOA; - } - - try { - const countryCoa = ( - await import('../fixtures/verified/' + conCode + '.json') - ).default; - return countryCoa.tree; - } catch (e) { - return standardCOA; - } -} - -export default async function importCharts(chartOfAccounts) { - const chart = await getCountryCOA(chartOfAccounts); - await importAccounts(chart, '', '', true); -} diff --git a/accounting/ledgerPosting.js b/accounting/ledgerPosting.js deleted file mode 100644 index f2f504bf..00000000 --- a/accounting/ledgerPosting.js +++ /dev/null @@ -1,164 +0,0 @@ -import frappe from 'frappe'; - -export default class LedgerPosting { - constructor({ reference, party, date, description }) { - this.reference = reference; - this.party = party; - this.date = date; - this.description = description; - this.entries = []; - this.entryMap = {}; - this.reverted = 0; - // To change balance while entering ledger entries - this.accountEntries = []; - } - - async debit(account, amount, referenceType, referenceName) { - const entry = this.getEntry(account, referenceType, referenceName); - entry.debit = entry.debit.add(amount); - await this.setAccountBalanceChange(account, 'debit', amount); - } - - async credit(account, amount, referenceType, referenceName) { - const entry = this.getEntry(account, referenceType, referenceName); - entry.credit = entry.credit.add(amount); - await this.setAccountBalanceChange(account, 'credit', amount); - } - - async setAccountBalanceChange(accountName, type, amount) { - const debitAccounts = ['Asset', 'Expense']; - const { rootType } = await frappe.getDoc('Account', accountName); - if (debitAccounts.indexOf(rootType) === -1) { - const change = type == 'credit' ? amount : amount.neg(); - this.accountEntries.push({ - name: accountName, - balanceChange: change, - }); - } else { - const change = type == 'debit' ? amount : amount.neg(); - this.accountEntries.push({ - name: accountName, - balanceChange: change, - }); - } - } - - getEntry(account, referenceType, referenceName) { - if (!this.entryMap[account]) { - const entry = { - account: account, - party: this.party || '', - date: this.date || this.reference.date, - referenceType: referenceType || this.reference.doctype, - referenceName: referenceName || this.reference.name, - description: this.description, - reverted: this.reverted, - debit: frappe.pesa(0), - credit: frappe.pesa(0), - }; - - this.entries.push(entry); - this.entryMap[account] = entry; - } - - return this.entryMap[account]; - } - - async post() { - this.validateEntries(); - await this.insertEntries(); - } - - async postReverse() { - this.validateEntries(); - - let data = await frappe.db.getAll({ - doctype: 'AccountingLedgerEntry', - fields: ['name'], - filters: { - referenceName: this.reference.name, - reverted: 0, - }, - }); - - for (let entry of data) { - let entryDoc = await frappe.getDoc('AccountingLedgerEntry', entry.name); - entryDoc.reverted = 1; - await entryDoc.update(); - } - - let temp; - for (let entry of this.entries) { - temp = entry.debit; - entry.debit = entry.credit; - entry.credit = temp; - entry.reverted = 1; - } - for (let entry of this.accountEntries) { - entry.balanceChange = entry.balanceChange.neg(); - } - await this.insertEntries(); - } - - makeRoundOffEntry() { - let { debit, credit } = this.getTotalDebitAndCredit(); - let difference = debit.sub(credit); - let absoluteValue = difference.abs(); - let allowance = 0.5; - if (absoluteValue.eq(0)) { - return; - } - - let roundOffAccount = this.getRoundOffAccount(); - if (absoluteValue.lte(allowance)) { - if (difference.gt(0)) { - this.credit(roundOffAccount, absoluteValue); - } else { - this.debit(roundOffAccount, absoluteValue); - } - } - } - - validateEntries() { - let { debit, credit } = this.getTotalDebitAndCredit(); - if (debit.neq(credit)) { - throw new frappe.errors.ValidationError( - `Total Debit: ${frappe.format( - debit, - 'Currency' - )} must be equal to Total Credit: ${frappe.format(credit, 'Currency')}` - ); - } - } - - getTotalDebitAndCredit() { - let debit = frappe.pesa(0); - let credit = frappe.pesa(0); - - for (let entry of this.entries) { - debit = debit.add(entry.debit); - credit = credit.add(entry.credit); - } - - return { debit, credit }; - } - - async insertEntries() { - for (let entry of this.entries) { - let entryDoc = frappe.newDoc({ - doctype: 'AccountingLedgerEntry', - }); - Object.assign(entryDoc, entry); - await entryDoc.insert(); - } - for (let entry of this.accountEntries) { - let entryDoc = await frappe.getDoc('Account', entry.name); - entryDoc.balance = entryDoc.balance.add(entry.balanceChange); - await entryDoc.update(); - } - } - - getRoundOffAccount() { - return frappe.AccountingSettings.roundOffAccount; - } -} diff --git a/accounting/utils.js b/accounting/utils.js deleted file mode 100644 index 5ad2fbb9..00000000 --- a/accounting/utils.js +++ /dev/null @@ -1,20 +0,0 @@ -import { t } from 'frappe'; - -export const ledgerLink = { - label: t`Ledger Entries`, - condition: (doc) => doc.submitted, - action: (doc, router) => { - router.push({ - name: 'Report', - params: { - reportName: 'general-ledger', - defaultFilters: { - referenceType: doc.doctype, - referenceName: doc.name, - }, - }, - }); - }, -}; - -export default { ledgerLink }; diff --git a/backend/database/bespoke.ts b/backend/database/bespoke.ts new file mode 100644 index 00000000..a2655841 --- /dev/null +++ b/backend/database/bespoke.ts @@ -0,0 +1,122 @@ +import DatabaseCore from './core'; +import { BespokeFunction } from './types'; + +export class BespokeQueries { + [key: string]: BespokeFunction; + + static async getLastInserted( + db: DatabaseCore, + schemaName: string + ): Promise { + const lastInserted = (await db.knex!.raw( + 'select cast(name as int) as num from ?? order by num desc limit 1', + [schemaName] + )) as { num: number }[]; + + const num = lastInserted?.[0]?.num; + if (num === undefined) { + return 0; + } + return num; + } + + static async getTopExpenses( + db: DatabaseCore, + fromDate: string, + toDate: string + ) { + const expenseAccounts = db + .knex!.select('name') + .from('Account') + .where('rootType', 'Expense'); + + const topExpenses = await db + .knex!.select({ + total: db.knex!.raw('sum(cast(debit as real) - cast(credit as real))'), + }) + .select('account') + .from('AccountingLedgerEntry') + .where('reverted', false) + .where('account', 'in', expenseAccounts) + .whereBetween('date', [fromDate, toDate]) + .groupBy('account') + .orderBy('total', 'desc') + .limit(5); + return topExpenses; + } + + static async getTotalOutstanding( + db: DatabaseCore, + schemaName: string, + fromDate: string, + toDate: string + ) { + return await db.knex!(schemaName) + .sum({ total: 'baseGrandTotal' }) + .sum({ outstanding: 'outstandingAmount' }) + .where('submitted', true) + .where('cancelled', false) + .whereBetween('date', [fromDate, toDate]) + .first(); + } + + static async getCashflow(db: DatabaseCore, fromDate: string, toDate: string) { + const cashAndBankAccounts = db.knex!('Account') + .select('name') + .where('accountType', 'in', ['Cash', 'Bank']) + .andWhere('isGroup', false); + const dateAsMonthYear = db.knex!.raw(`strftime('%Y-%m', ??)`, 'date'); + return await db.knex!('AccountingLedgerEntry') + .where('reverted', false) + .sum({ + inflow: 'debit', + outflow: 'credit', + }) + .select({ + yearmonth: dateAsMonthYear, + }) + .where('account', 'in', cashAndBankAccounts) + .whereBetween('date', [fromDate, toDate]) + .groupBy(dateAsMonthYear); + } + + static async getIncomeAndExpenses( + db: DatabaseCore, + fromDate: string, + toDate: string + ) { + const income = await db.knex!.raw( + ` + select sum(cast(credit as real) - cast(debit as real)) as balance, strftime('%Y-%m', date) as yearmonth + from AccountingLedgerEntry + where + reverted = false and + date between date(?) and date(?) and + account in ( + select name + from Account + where rootType = 'Income' + ) + group by yearmonth`, + [fromDate, toDate] + ); + + const expense = await db.knex!.raw( + ` + select sum(cast(debit as real) - cast(credit as real)) as balance, strftime('%Y-%m', date) as yearmonth + from AccountingLedgerEntry + where + reverted = false and + date between date(?) and date(?) and + account in ( + select name + from Account + where rootType = 'Expense' + ) + group by yearmonth`, + [fromDate, toDate] + ); + + return { income, expense }; + } +} diff --git a/backend/database/core.ts b/backend/database/core.ts new file mode 100644 index 00000000..41e0a590 --- /dev/null +++ b/backend/database/core.ts @@ -0,0 +1,943 @@ +import { + CannotCommitError, + DatabaseError, + DuplicateEntryError, + LinkValidationError, + NotFoundError, + ValueError, +} from 'fyo/utils/errors'; +import { knex, Knex } from 'knex'; +import { + Field, + FieldTypeEnum, + RawValue, + Schema, + SchemaMap, + TargetField, +} from '../../schemas/types'; +import { + getIsNullOrUndef, + getRandomString, + getValueMapFromList, +} from '../../utils'; +import { DatabaseBase, GetAllOptions, QueryFilter } from '../../utils/db/types'; +import { getDefaultMetaFieldValueMap, sqliteTypeMap, SYSTEM } from '../helpers'; +import { + ColumnDiff, + FieldValueMap, + GetQueryBuilderOptions, + SingleValue, +} from './types'; + +/** + * # DatabaseCore + * This is the ORM, the DatabaseCore interface (function signatures) should be + * replicated by the frontend demuxes and all the backend muxes. + * + * ## Db Core Call Sequence + * + * 1. Init core: `const db = new DatabaseCore(dbPath)`. + * 2. Connect db: `db.connect()`. This will allow for raw queries to be executed. + * 3. Set schemas: `db.setSchemaMap(schemaMap)`. This will allow for ORM functions to be executed. + * 4. Migrate: `await db.migrate()`. This will create absent tables and update the tables' shape. + * 5. ORM function execution: `db.get(...)`, `db.insert(...)`, etc. + * 6. Close connection: `await db.close()`. + * + * Note: Meta values: created, modified, createdBy, modifiedBy are set by DatabaseCore + * only for schemas that are SingleValue. Else they have to be passed by the caller in + * the `fieldValueMap`. + */ + +export default class DatabaseCore extends DatabaseBase { + knex?: Knex; + typeMap = sqliteTypeMap; + dbPath: string; + schemaMap: SchemaMap = {}; + connectionParams: Knex.Config; + + constructor(dbPath?: string) { + super(); + this.dbPath = dbPath ?? ':memory:'; + this.connectionParams = { + client: 'better-sqlite3', + connection: { + filename: this.dbPath, + }, + useNullAsDefault: true, + asyncStackTraces: process.env.NODE_ENV === 'development', + }; + } + + static async getCountryCode(dbPath: string): Promise { + let countryCode = 'in'; + const db = new DatabaseCore(dbPath); + await db.connect(); + + let query: { value: string }[] = []; + try { + query = await db.knex!('SingleValue').where({ + fieldname: 'countryCode', + parent: 'SystemSettings', + }); + } catch { + // Database not inialized and no countryCode passed + } + + if (query.length > 0) { + countryCode = query[0].value as string; + } + + await db.close(); + return countryCode; + } + + setSchemaMap(schemaMap: SchemaMap) { + this.schemaMap = schemaMap; + } + + async connect() { + this.knex = knex(this.connectionParams); + this.knex.on('query-error', (error) => { + error.type = this.#getError(error); + }); + await this.knex.raw('PRAGMA foreign_keys=ON'); + } + + async close() { + await this.knex!.destroy(); + } + + async commit() { + /** + * this auto commits, commit is not required + * will later wrap the outermost functions in + * transactions. + */ + try { + // await this.knex!.raw('commit'); + } catch (err) { + const type = this.#getError(err as Error); + if (type !== CannotCommitError) { + throw err; + } + } + } + + async migrate() { + for (const schemaName in this.schemaMap) { + const schema = this.schemaMap[schemaName] as Schema; + if (schema.isSingle) { + continue; + } + + if (await this.#tableExists(schemaName)) { + await this.#alterTable(schemaName); + } else { + await this.#createTable(schemaName); + } + } + + await this.commit(); + await this.#initializeSingles(); + } + + async exists(schemaName: string, name?: string): Promise { + const schema = this.schemaMap[schemaName] as Schema; + if (schema.isSingle) { + return this.#singleExists(schemaName); + } + + let row = []; + try { + const qb = this.knex!(schemaName); + if (name !== undefined) { + qb.where({ name }); + } + row = await qb.limit(1); + } catch (err) { + if (this.#getError(err as Error) !== NotFoundError) { + throw err; + } + } + return row.length > 0; + } + + async insert( + schemaName: string, + fieldValueMap: FieldValueMap + ): Promise { + // insert parent + if (this.schemaMap[schemaName]!.isSingle) { + await this.#updateSingleValues(schemaName, fieldValueMap); + } else { + await this.#insertOne(schemaName, fieldValueMap); + } + + // insert children + await this.#insertOrUpdateChildren(schemaName, fieldValueMap, false); + return fieldValueMap; + } + + async get( + schemaName: string, + name: string = '', + fields?: string | string[] + ): Promise { + const schema = this.schemaMap[schemaName] as Schema; + if (!schema.isSingle && !name) { + throw new ValueError('name is mandatory'); + } + + /** + * If schema is single return all the values + * of the single type schema, in this case field + * is ignored. + */ + let fieldValueMap: FieldValueMap = {}; + if (schema.isSingle) { + return await this.#getSingle(schemaName); + } + + if (typeof fields === 'string') { + fields = [fields]; + } + + if (fields === undefined) { + fields = schema.fields.map((f) => f.fieldname); + } + + /** + * Separate table fields and non table fields + */ + const allTableFields: TargetField[] = this.#getTableFields(schemaName); + const allTableFieldNames: string[] = allTableFields.map((f) => f.fieldname); + const tableFields: TargetField[] = allTableFields.filter((f) => + fields!.includes(f.fieldname) + ); + const nonTableFieldNames: string[] = fields.filter( + (f) => !allTableFieldNames.includes(f) + ); + + /** + * If schema is not single then return specific fields + * if child fields are selected, all child fields are returned. + */ + if (nonTableFieldNames.length) { + fieldValueMap = + (await this.#getOne(schemaName, name, nonTableFieldNames)) ?? {}; + } + + if (tableFields.length) { + await this.#loadChildren(name, fieldValueMap, tableFields); + } + return fieldValueMap; + } + + async getAll( + schemaName: string, + options: GetAllOptions = {} + ): Promise { + const schema = this.schemaMap[schemaName] as Schema; + if (schema === undefined) { + throw new NotFoundError(`schema ${schemaName} not found`); + } + + const hasCreated = !!schema.fields.find((f) => f.fieldname === 'created'); + + const { + fields = ['name'], + filters, + offset, + limit, + groupBy, + orderBy = hasCreated ? 'created' : undefined, + order = 'desc', + } = options; + + return (await this.#getQueryBuilder( + schemaName, + typeof fields === 'string' ? [fields] : fields, + filters ?? {}, + { + offset, + limit, + groupBy, + orderBy, + order, + } + )) as FieldValueMap[]; + } + + async getSingleValues( + ...fieldnames: ({ fieldname: string; parent?: string } | string)[] + ): Promise> { + const fieldnameList = fieldnames.map((fieldname) => { + if (typeof fieldname === 'string') { + return { fieldname }; + } + return fieldname; + }); + + let builder = this.knex!('SingleValue'); + builder = builder.where(fieldnameList[0]); + + fieldnameList.slice(1).forEach(({ fieldname, parent }) => { + if (typeof parent === 'undefined') { + builder = builder.orWhere({ fieldname }); + } else { + builder = builder.orWhere({ fieldname, parent }); + } + }); + + let values: { fieldname: string; parent: string; value: RawValue }[] = []; + try { + values = await builder.select('fieldname', 'value', 'parent'); + } catch (err) { + if (this.#getError(err as Error) === NotFoundError) { + return []; + } + + throw err; + } + + return values; + } + + async rename(schemaName: string, oldName: string, newName: string) { + /** + * Rename is expensive mostly won't allow it. + * TODO: rename all links + * TODO: rename in childtables + */ + await this.knex!(schemaName) + .update({ name: newName }) + .where('name', oldName); + await this.commit(); + } + + async update(schemaName: string, fieldValueMap: FieldValueMap) { + // update parent + if (this.schemaMap[schemaName]!.isSingle) { + await this.#updateSingleValues(schemaName, fieldValueMap); + } else { + await this.#updateOne(schemaName, fieldValueMap); + } + + // insert or update children + await this.#insertOrUpdateChildren(schemaName, fieldValueMap, true); + } + + async delete(schemaName: string, name: string) { + const schema = this.schemaMap[schemaName] as Schema; + if (schema.isSingle) { + await this.#deleteSingle(schemaName, name); + return; + } + + await this.#deleteOne(schemaName, name); + + // delete children + const tableFields = this.#getTableFields(schemaName); + + for (const field of tableFields) { + await this.#deleteChildren(field.target, name); + } + } + + async #tableExists(schemaName: string) { + return await this.knex!.schema.hasTable(schemaName); + } + + async #singleExists(singleSchemaName: string) { + const res = await this.knex!('SingleValue') + .count('parent as count') + .where('parent', singleSchemaName) + .first(); + return (res?.count ?? 0) > 0; + } + + async #removeColumns(schemaName: string, targetColumns: string[]) { + const fields = this.schemaMap[schemaName]?.fields + .filter((f) => f.fieldtype !== FieldTypeEnum.Table) + .map((f) => f.fieldname); + const tableRows = await this.getAll(schemaName, { fields }); + this.prestigeTheTable(schemaName, tableRows); + } + + #getError(err: Error) { + let errorType = DatabaseError; + if (err.message.includes('SQLITE_ERROR: no such table')) { + errorType = NotFoundError; + } + if (err.message.includes('FOREIGN KEY')) { + errorType = LinkValidationError; + } + if (err.message.includes('SQLITE_ERROR: cannot commit')) { + errorType = CannotCommitError; + } + if (err.message.includes('SQLITE_CONSTRAINT: UNIQUE constraint failed:')) { + errorType = DuplicateEntryError; + } + return errorType; + } + + async prestigeTheTable(schemaName: string, tableRows: FieldValueMap[]) { + const max = 200; + + // Alter table hacx for sqlite in case of schema change. + const tempName = `__${schemaName}`; + await this.knex!.schema.dropTableIfExists(tempName); + + await this.knex!.raw('PRAGMA foreign_keys=OFF'); + await this.#createTable(schemaName, tempName); + + if (tableRows.length > 200) { + const fi = Math.floor(tableRows.length / max); + for (let i = 0; i <= fi; i++) { + const rowSlice = tableRows.slice(i * max, i + 1 * max); + if (rowSlice.length === 0) { + break; + } + await this.knex!.batchInsert(tempName, rowSlice); + } + } else { + await this.knex!.batchInsert(tempName, tableRows); + } + + await this.knex!.schema.dropTable(schemaName); + await this.knex!.schema.renameTable(tempName, schemaName); + await this.knex!.raw('PRAGMA foreign_keys=ON'); + } + + async #getTableColumns(schemaName: string): Promise { + const info: FieldValueMap[] = await this.knex!.raw( + `PRAGMA table_info(${schemaName})` + ); + return info.map((d) => d.name as string); + } + + async truncate(tableNames?: string[]) { + if (tableNames === undefined) { + const q = (await this.knex!.raw(` + select name from sqlite_schema + where type='table' + and name not like 'sqlite_%'`)) as { name: string }[]; + tableNames = q.map((i) => i.name); + } + + for (const name of tableNames) { + await this.knex!(name).del(); + } + } + + async #getForeignKeys(schemaName: string): Promise { + const foreignKeyList: FieldValueMap[] = await this.knex!.raw( + `PRAGMA foreign_key_list(${schemaName})` + ); + return foreignKeyList.map((d) => d.from as string); + } + + #getQueryBuilder( + schemaName: string, + fields: string[], + filters: QueryFilter, + options: GetQueryBuilderOptions + ): Knex.QueryBuilder { + const builder = this.knex!.select(fields).from(schemaName); + + this.#applyFiltersToBuilder(builder, filters); + + if (options.orderBy) { + builder.orderBy(options.orderBy, options.order); + } + + if (options.groupBy) { + builder.groupBy(options.groupBy); + } + + if (options.offset) { + builder.offset(options.offset); + } + + if (options.limit) { + builder.limit(options.limit); + } + + return builder; + } + + #applyFiltersToBuilder(builder: Knex.QueryBuilder, filters: QueryFilter) { + // {"status": "Open"} => `status = "Open"` + + // {"status": "Open", "name": ["like", "apple%"]} + // => `status="Open" and name like "apple%" + + // {"date": [">=", "2017-09-09", "<=", "2017-11-01"]} + // => `date >= 2017-09-09 and date <= 2017-11-01` + + const filtersArray = []; + + for (const field in filters) { + const value = filters[field]; + let operator: string | number = '='; + let comparisonValue = value as string | number | (string | number)[]; + + if (Array.isArray(value)) { + operator = (value[0] as string).toLowerCase(); + comparisonValue = value[1] as string | number | (string | number)[]; + + if (operator === 'includes') { + operator = 'like'; + } + + if ( + operator === 'like' && + !(comparisonValue as (string | number)[]).includes('%') + ) { + comparisonValue = `%${comparisonValue}%`; + } + } + + filtersArray.push([field, operator, comparisonValue]); + + if (Array.isArray(value) && value.length > 2) { + // multiple conditions + const operator = value[2]; + const comparisonValue = value[3]; + filtersArray.push([field, operator, comparisonValue]); + } + } + + filtersArray.map((filter) => { + const field = filter[0] as string; + const operator = filter[1]; + const comparisonValue = filter[2]; + + if (operator === '=') { + builder.where(field, comparisonValue); + } else { + builder.where(field, operator as string, comparisonValue as string); + } + }); + } + + async #getColumnDiff(schemaName: string): Promise { + const tableColumns = await this.#getTableColumns(schemaName); + const validFields = this.schemaMap[schemaName]!.fields; + const diff: ColumnDiff = { added: [], removed: [] }; + + for (const field of validFields) { + const hasDbType = this.typeMap.hasOwnProperty(field.fieldtype); + if (!tableColumns.includes(field.fieldname) && hasDbType) { + diff.added.push(field); + } + } + + const validFieldNames = validFields.map((field) => field.fieldname); + for (const column of tableColumns) { + if (!validFieldNames.includes(column)) { + diff.removed.push(column); + } + } + + return diff; + } + + async #getNewForeignKeys(schemaName: string): Promise { + const foreignKeys = await this.#getForeignKeys(schemaName); + const newForeignKeys: Field[] = []; + const schema = this.schemaMap[schemaName] as Schema; + for (const field of schema.fields) { + if ( + field.fieldtype === 'Link' && + !foreignKeys.includes(field.fieldname) + ) { + newForeignKeys.push(field); + } + } + return newForeignKeys; + } + + #buildColumnForTable(table: Knex.AlterTableBuilder, field: Field) { + if (field.fieldtype === FieldTypeEnum.Table) { + // In case columnType is "Table" + // childTable links are handled using the childTable's "parent" field + return; + } + + const columnType = this.typeMap[field.fieldtype]; + if (!columnType) { + return; + } + + const column = table[columnType]( + field.fieldname + ) as Knex.SqlLiteColumnBuilder; + + // primary key + if (field.fieldname === 'name') { + column.primary(); + } + + // iefault value + if (field.default !== undefined) { + column.defaultTo(field.default); + } + + // required + if (field.required) { + column.notNullable(); + } + + // link + if ( + field.fieldtype === FieldTypeEnum.Link && + (field as TargetField).target + ) { + const targetSchemaName = (field as TargetField).target as string; + const schema = this.schemaMap[targetSchemaName] as Schema; + table + .foreign(field.fieldname) + .references('name') + .inTable(schema.name) + .onUpdate('CASCADE') + .onDelete('RESTRICT'); + } + } + + async #alterTable(schemaName: string) { + // get columns + const diff: ColumnDiff = await this.#getColumnDiff(schemaName); + const newForeignKeys: Field[] = await this.#getNewForeignKeys(schemaName); + + return this.knex!.schema.table(schemaName, (table) => { + if (diff.added.length) { + for (const field of diff.added) { + this.#buildColumnForTable(table, field); + } + } + + if (diff.removed.length) { + this.#removeColumns(schemaName, diff.removed); + } + }).then(() => { + if (newForeignKeys.length) { + return this.#addForeignKeys(schemaName, newForeignKeys); + } + }); + } + + async #createTable(schemaName: string, tableName?: string) { + tableName ??= schemaName; + const fields = this.schemaMap[schemaName]!.fields; + return await this.#runCreateTableQuery(tableName, fields); + } + + #runCreateTableQuery(schemaName: string, fields: Field[]) { + return this.knex!.schema.createTable(schemaName, (table) => { + for (const field of fields) { + this.#buildColumnForTable(table, field); + } + }); + } + + async #getNonExtantSingleValues(singleSchemaName: string) { + const existingFields = ( + await this.knex!('SingleValue') + .where({ parent: singleSchemaName }) + .select('fieldname') + ).map(({ fieldname }) => fieldname); + + return this.schemaMap[singleSchemaName]!.fields.map( + ({ fieldname, default: value }) => ({ + fieldname, + value: value as RawValue | undefined, + }) + ).filter( + ({ fieldname, value }) => + !existingFields.includes(fieldname) && value !== undefined + ); + } + + async #deleteOne(schemaName: string, name: string) { + return await this.knex!(schemaName).where('name', name).delete(); + } + + async #deleteSingle(schemaName: string, fieldname: string) { + return await this.knex!('SingleValue') + .where({ parent: schemaName, fieldname }) + .delete(); + } + + #deleteChildren(schemaName: string, parentName: string) { + return this.knex!(schemaName).where('parent', parentName).delete(); + } + + #runDeleteOtherChildren( + field: TargetField, + parentName: string, + added: string[] + ) { + // delete other children + return this.knex!(field.target) + .where('parent', parentName) + .andWhere('name', 'not in', added) + .delete(); + } + + #prepareChild( + parentSchemaName: string, + parentName: string, + child: FieldValueMap, + field: Field, + idx: number + ) { + if (!child.name) { + child.name ??= getRandomString(); + } + child.parent = parentName; + child.parentSchemaName = parentSchemaName; + child.parentFieldname = field.fieldname; + child.idx ??= idx; + } + + async #addForeignKeys(schemaName: string, newForeignKeys: Field[]) { + await this.knex!.raw('PRAGMA foreign_keys=OFF'); + await this.knex!.raw('BEGIN TRANSACTION'); + + const tempName = 'TEMP' + schemaName; + + // create temp table + await this.#createTable(schemaName, tempName); + + try { + // copy from old to new table + await this.knex!(tempName).insert(this.knex!.select().from(schemaName)); + } catch (err) { + await this.knex!.raw('ROLLBACK'); + await this.knex!.raw('PRAGMA foreign_keys=ON'); + + const rows = await this.knex!.select().from(schemaName); + await this.prestigeTheTable(schemaName, rows); + return; + } + + // drop old table + await this.knex!.schema.dropTable(schemaName); + + // rename new table + await this.knex!.schema.renameTable(tempName, schemaName); + + await this.knex!.raw('COMMIT'); + await this.knex!.raw('PRAGMA foreign_keys=ON'); + } + + async #loadChildren( + parentName: string, + fieldValueMap: FieldValueMap, + tableFields: TargetField[] + ) { + for (const field of tableFields) { + fieldValueMap[field.fieldname] = await this.getAll(field.target, { + fields: ['*'], + filters: { parent: parentName }, + orderBy: 'idx', + order: 'asc', + }); + } + } + + async #getOne(schemaName: string, name: string, fields: string[]) { + const fieldValueMap: FieldValueMap = await this.knex!.select(fields) + .from(schemaName) + .where('name', name) + .first(); + return fieldValueMap; + } + + async #getSingle(schemaName: string): Promise { + const values = await this.getAll('SingleValue', { + fields: ['fieldname', 'value'], + filters: { parent: schemaName }, + orderBy: 'fieldname', + order: 'asc', + }); + + return getValueMapFromList(values, 'fieldname', 'value') as FieldValueMap; + } + + #insertOne(schemaName: string, fieldValueMap: FieldValueMap) { + if (!fieldValueMap.name) { + fieldValueMap.name = getRandomString(); + } + + // Non Table Fields + const fields = this.schemaMap[schemaName]!.fields.filter( + (f) => f.fieldtype !== FieldTypeEnum.Table + ); + + const validMap: FieldValueMap = {}; + for (const { fieldname } of fields) { + validMap[fieldname] = fieldValueMap[fieldname]; + } + + return this.knex!(schemaName).insert(validMap); + } + + async #updateSingleValues( + singleSchemaName: string, + fieldValueMap: FieldValueMap + ) { + const fields = this.schemaMap[singleSchemaName]!.fields; + + for (const field of fields) { + const value = fieldValueMap[field.fieldname] as RawValue | undefined; + if (value === undefined) { + continue; + } + + await this.#updateSingleValue(singleSchemaName, field.fieldname, value); + } + } + + async #updateSingleValue( + singleSchemaName: string, + fieldname: string, + value: RawValue + ) { + const names: { name: string }[] = await this.knex!('SingleValue') + .select('name') + .where({ + parent: singleSchemaName, + fieldname, + }); + const name = names?.[0]?.name as string | undefined; + + if (name === undefined) { + this.#insertSingleValue(singleSchemaName, fieldname, value); + } else { + return await this.knex!('SingleValue').where({ name }).update({ + value, + modifiedBy: SYSTEM, + modified: new Date().toISOString(), + }); + } + } + + async #insertSingleValue( + singleSchemaName: string, + fieldname: string, + value: RawValue + ) { + const updateMap = getDefaultMetaFieldValueMap(); + const fieldValueMap: FieldValueMap = Object.assign({}, updateMap, { + parent: singleSchemaName, + fieldname, + value, + name: getRandomString(), + }); + return await this.knex!('SingleValue').insert(fieldValueMap); + } + + async #initializeSingles() { + const singleSchemaNames = Object.keys(this.schemaMap).filter( + (n) => this.schemaMap[n]!.isSingle + ); + + for (const schemaName of singleSchemaNames) { + if (await this.#singleExists(schemaName)) { + await this.#updateNonExtantSingleValues(schemaName); + continue; + } + + const fields = this.schemaMap[schemaName]!.fields; + if (fields.every((f) => f.default === undefined)) { + continue; + } + + const defaultValues: FieldValueMap = fields.reduce((acc, f) => { + if (f.default !== undefined) { + acc[f.fieldname] = f.default; + } + + return acc; + }, {} as FieldValueMap); + + await this.#updateSingleValues(schemaName, defaultValues); + } + } + + async #updateNonExtantSingleValues(schemaName: string) { + const singleValues = await this.#getNonExtantSingleValues(schemaName); + for (const sv of singleValues) { + await this.#updateSingleValue(schemaName, sv.fieldname, sv.value!); + } + } + + async #updateOne(schemaName: string, fieldValueMap: FieldValueMap) { + const updateMap = { ...fieldValueMap }; + delete updateMap.name; + const schema = this.schemaMap[schemaName] as Schema; + for (const { fieldname, fieldtype } of schema.fields) { + if (fieldtype !== FieldTypeEnum.Table) { + continue; + } + + delete updateMap[fieldname]; + } + + if (Object.keys(updateMap).length === 0) { + return; + } + + return await this.knex!(schemaName) + .where('name', fieldValueMap.name as string) + .update(updateMap); + } + + async #insertOrUpdateChildren( + schemaName: string, + fieldValueMap: FieldValueMap, + isUpdate: boolean + ) { + const parentName = fieldValueMap.name as string; + const tableFields = this.#getTableFields(schemaName); + + for (const field of tableFields) { + const added: string[] = []; + + const tableFieldValue = fieldValueMap[field.fieldname] as + | FieldValueMap[] + | undefined + | null; + if (getIsNullOrUndef(tableFieldValue)) { + continue; + } + + for (const child of tableFieldValue!) { + this.#prepareChild(schemaName, parentName, child, field, added.length); + + if ( + isUpdate && + (await this.exists(field.target, child.name as string)) + ) { + await this.#updateOne(field.target, child); + } else { + await this.#insertOne(field.target, child); + } + + added.push(child.name as string); + } + + if (isUpdate) { + await this.#runDeleteOtherChildren(field, parentName, added); + } + } + } + + #getTableFields(schemaName: string): TargetField[] { + return this.schemaMap[schemaName]!.fields.filter( + (f) => f.fieldtype === FieldTypeEnum.Table + ) as TargetField[]; + } +} diff --git a/backend/database/manager.ts b/backend/database/manager.ts new file mode 100644 index 00000000..b36a47fe --- /dev/null +++ b/backend/database/manager.ts @@ -0,0 +1,164 @@ +import { constants } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; +import { DatabaseDemuxBase, DatabaseMethod } from 'utils/db/types'; +import { getSchemas } from '../../schemas'; +import { databaseMethodSet } from '../helpers'; +import patches from '../patches'; +import { BespokeQueries } from './bespoke'; +import DatabaseCore from './core'; +import { runPatches } from './runPatch'; +import { BespokeFunction, Patch } from './types'; + +export class DatabaseManager extends DatabaseDemuxBase { + db?: DatabaseCore; + + get #isInitialized(): boolean { + return this.db !== undefined && this.db.knex !== undefined; + } + + getSchemaMap() { + return this.db?.schemaMap ?? {}; + } + + async createNewDatabase(dbPath: string, countryCode: string) { + await this.#unlinkIfExists(dbPath); + return await this.connectToDatabase(dbPath, countryCode); + } + + async connectToDatabase(dbPath: string, countryCode?: string) { + countryCode = await this._connect(dbPath, countryCode); + await this.#migrate(); + return countryCode; + } + + async _connect(dbPath: string, countryCode?: string) { + countryCode ??= await DatabaseCore.getCountryCode(dbPath); + this.db = new DatabaseCore(dbPath); + await this.db.connect(); + const schemaMap = getSchemas(countryCode); + this.db.setSchemaMap(schemaMap); + return countryCode; + } + + async #migrate(): Promise { + if (!this.#isInitialized) { + return; + } + + const isFirstRun = await this.#getIsFirstRun(); + if (isFirstRun) { + await this.db!.migrate(); + } + + /** + * This needs to be supplimented with transactions + * TODO: Add transactions in core.ts + */ + const dbPath = this.db!.dbPath; + const copyPath = await this.#makeTempCopy(); + + try { + await this.#runPatchesAndMigrate(); + } catch (err) { + console.error(err); + await this.db!.close(); + await fs.copyFile(copyPath, dbPath); + throw err; + } finally { + await fs.unlink(copyPath); + } + } + + async #runPatchesAndMigrate() { + const patchesToExecute = await this.#getPatchesToExecute(); + + patchesToExecute.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + const preMigrationPatches = patchesToExecute.filter( + (p) => p.patch.beforeMigrate + ); + const postMigrationPatches = patchesToExecute.filter( + (p) => !p.patch.beforeMigrate + ); + + await runPatches(preMigrationPatches, this); + await this.db!.migrate(); + await runPatches(postMigrationPatches, this); + } + + async #getPatchesToExecute(): Promise { + if (this.db === undefined) { + return []; + } + + const query: { name: string }[] = await this.db.knex!('PatchRun').select( + 'name' + ); + const executedPatches = query.map((q) => q.name); + return patches.filter((p) => !executedPatches.includes(p.name)); + } + + async call(method: DatabaseMethod, ...args: unknown[]) { + if (!this.#isInitialized) { + return; + } + + if (!databaseMethodSet.has(method)) { + return; + } + + // @ts-ignore + const response = await this.db[method](...args); + if (method === 'close') { + delete this.db; + } + + return response; + } + + async callBespoke(method: string, ...args: unknown[]): Promise { + if (!this.#isInitialized) { + return; + } + + if (!BespokeQueries.hasOwnProperty(method)) { + return; + } + + // @ts-ignore + const queryFunction: BespokeFunction = BespokeQueries[method]; + return await queryFunction(this.db!, ...args); + } + + async #unlinkIfExists(dbPath: string) { + const exists = await fs + .access(dbPath, constants.W_OK) + .then(() => true) + .catch(() => false); + + if (exists) { + fs.unlink(dbPath); + } + } + + async #getIsFirstRun(): Promise { + if (!this.#isInitialized) { + return true; + } + + const tableList: unknown[] = await this.db!.knex!.raw( + "SELECT name FROM sqlite_master WHERE type='table'" + ); + return tableList.length === 0; + } + + async #makeTempCopy() { + const src = this.db!.dbPath; + const dir = path.parse(src).dir; + const dest = path.join(dir, '__premigratory_temp.db'); + await fs.copyFile(src, dest); + return dest; + } +} + +export default new DatabaseManager(); diff --git a/backend/database/runPatch.ts b/backend/database/runPatch.ts new file mode 100644 index 00000000..f93704fd --- /dev/null +++ b/backend/database/runPatch.ts @@ -0,0 +1,31 @@ +import { getDefaultMetaFieldValueMap } from '../helpers'; +import { DatabaseManager } from './manager'; +import { FieldValueMap, Patch } from './types'; + +export async function runPatches(patches: Patch[], dm: DatabaseManager) { + const list: { name: string; success: boolean }[] = []; + for (const patch of patches) { + const success = await runPatch(patch, dm); + list.push({ name: patch.name, success }); + } + return list; +} + +async function runPatch(patch: Patch, dm: DatabaseManager): Promise { + try { + await patch.patch.execute(dm); + } catch (err) { + console.error('PATCH FAILED: ', patch.name); + console.error(err); + return false; + } + + await makeEntry(patch.name, dm); + return true; +} + +async function makeEntry(patchName: string, dm: DatabaseManager) { + const defaultFieldValueMap = getDefaultMetaFieldValueMap() as FieldValueMap; + defaultFieldValueMap.name = patchName; + await dm.db!.insert('PatchRun', defaultFieldValueMap); +} diff --git a/backend/database/tests/helpers.ts b/backend/database/tests/helpers.ts new file mode 100644 index 00000000..9a58e3f1 --- /dev/null +++ b/backend/database/tests/helpers.ts @@ -0,0 +1,212 @@ +import assert from 'assert'; +import { cloneDeep } from 'lodash'; +import { SchemaMap, SchemaStub, SchemaStubMap } from 'schemas/types'; +import { + addMetaFields, + cleanSchemas, + getAbstractCombinedSchemas, +} from '../../../schemas'; +import SingleValue from '../../../schemas/core/SingleValue.json'; + +const Customer = { + name: 'Customer', + label: 'Customer', + fields: [ + { + fieldname: 'name', + label: 'Name', + fieldtype: 'Data', + required: true, + }, + { + fieldname: 'email', + label: 'Email', + fieldtype: 'Data', + placeholder: 'john@thoe.com', + }, + { + fieldname: 'phone', + label: 'Phone', + fieldtype: 'Data', + placeholder: '9999999999', + }, + ], + quickEditFields: ['email'], + keywordFields: ['name'], +}; + +const SalesInvoiceItem = { + name: 'SalesInvoiceItem', + label: 'Sales Invoice Item', + isChild: true, + fields: [ + { + fieldname: 'item', + label: 'Item', + fieldtype: 'Data', + required: true, + }, + { + fieldname: 'quantity', + label: 'Quantity', + fieldtype: 'Float', + required: true, + default: 1, + }, + { + fieldname: 'rate', + label: 'Rate', + fieldtype: 'Float', + required: true, + }, + { + fieldname: 'amount', + label: 'Amount', + fieldtype: 'Float', + readOnly: true, + }, + ], + tableFields: ['item', 'quantity', 'rate', 'amount'], +}; + +const SalesInvoice = { + name: 'SalesInvoice', + label: 'Sales Invoice', + isSingle: false, + isChild: false, + isSubmittable: true, + keywordFields: ['name', 'customer'], + fields: [ + { + label: 'Invoice No', + fieldname: 'name', + fieldtype: 'Data', + required: true, + readOnly: true, + }, + { + fieldname: 'date', + label: 'Date', + fieldtype: 'Date', + }, + { + fieldname: 'customer', + label: 'Customer', + fieldtype: 'Link', + target: 'Customer', + required: true, + }, + { + fieldname: 'account', + label: 'Account', + fieldtype: 'Data', + required: true, + }, + { + fieldname: 'items', + label: 'Items', + fieldtype: 'Table', + target: 'SalesInvoiceItem', + required: true, + }, + { + fieldname: 'grandTotal', + label: 'Grand Total', + fieldtype: 'Currency', + readOnly: true, + }, + ], +}; + +const SystemSettings = { + name: 'SystemSettings', + label: 'System Settings', + isSingle: true, + isChild: false, + fields: [ + { + fieldname: 'dateFormat', + label: 'Date Format', + fieldtype: 'Select', + options: [ + { + label: '23/03/2022', + value: 'dd/MM/yyyy', + }, + { + label: '03/23/2022', + value: 'MM/dd/yyyy', + }, + ], + default: 'dd/MM/yyyy', + required: true, + }, + { + fieldname: 'locale', + label: 'Locale', + fieldtype: 'Data', + default: 'en-IN', + }, + ], + quickEditFields: ['locale', 'dateFormat'], + keywordFields: [], +}; + +export function getBuiltTestSchemaMap(): SchemaMap { + const testSchemaMap: SchemaStubMap = { + SingleValue: SingleValue as SchemaStub, + Customer: Customer as SchemaStub, + SalesInvoice: SalesInvoice as SchemaStub, + SalesInvoiceItem: SalesInvoiceItem as SchemaStub, + SystemSettings: SystemSettings as SchemaStub, + }; + + const schemaMapClone = cloneDeep(testSchemaMap); + const abstractCombined = getAbstractCombinedSchemas(schemaMapClone); + const cleanedSchemas = cleanSchemas(abstractCombined); + return addMetaFields(cleanedSchemas); +} + +export function getBaseMeta() { + return { + createdBy: 'Administrator', + modifiedBy: 'Administrator', + created: new Date().toISOString(), + modified: new Date().toISOString(), + }; +} + +export async function assertThrows( + func: () => Promise, + message?: string +) { + let threw = true; + try { + await func(); + threw = false; + } catch { + } finally { + if (!threw) { + throw new assert.AssertionError({ + message: `Missing expected exception: ${message}`, + }); + } + } +} + +export async function assertDoesNotThrow( + func: () => Promise, + message?: string +) { + try { + await func(); + } catch (err) { + throw new assert.AssertionError({ + message: `Got unwanted exception: ${message}\nError: ${ + (err as Error).message + }\n${(err as Error).stack}`, + }); + } +} + +export type BaseMetaKey = 'created' | 'modified' | 'createdBy' | 'modifiedBy'; diff --git a/backend/database/tests/testCore.spec.ts b/backend/database/tests/testCore.spec.ts new file mode 100644 index 00000000..248a1a46 --- /dev/null +++ b/backend/database/tests/testCore.spec.ts @@ -0,0 +1,624 @@ +import * as assert from 'assert'; +import 'mocha'; +import { FieldTypeEnum, RawValue, Schema } from 'schemas/types'; +import { getMapFromList, getValueMapFromList, sleep } from 'utils'; +import { getDefaultMetaFieldValueMap, sqliteTypeMap } from '../../helpers'; +import DatabaseCore from '../core'; +import { FieldValueMap, SqliteTableInfo } from '../types'; +import { + assertDoesNotThrow, + assertThrows, + BaseMetaKey, + getBuiltTestSchemaMap, +} from './helpers'; + +/** + * Note: these tests have a strange structure where multiple tests are + * inside a `specify`, this is cause `describe` doesn't support `async` or waiting + * on promises. + * + * Due to this `async` db operations need to be handled in `specify`. And `specify` + * can't be nested in the `describe` can, hence the strange structure. + * + * This also implies that assert calls should have discriptive + */ + +describe('DatabaseCore: Connect Migrate Close', function () { + const db = new DatabaseCore(); + specify('dbPath', function () { + assert.strictEqual(db.dbPath, ':memory:'); + }); + + const schemaMap = getBuiltTestSchemaMap(); + db.setSchemaMap(schemaMap); + specify('schemaMap', function () { + assert.strictEqual(schemaMap, db.schemaMap); + }); + + specify('connect', async function () { + await assertDoesNotThrow(async () => await db.connect()); + assert.notStrictEqual(db.knex, undefined); + }); + + specify('migrate and close', async function () { + // Does not throw + await db.migrate(); + // Does not throw + await db.close(); + }); +}); + +describe('DatabaseCore: Migrate and Check Db', function () { + let db: DatabaseCore; + const schemaMap = getBuiltTestSchemaMap(); + + this.beforeEach(async function () { + db = new DatabaseCore(); + await db.connect(); + db.setSchemaMap(schemaMap); + }); + + this.afterEach(async function () { + await db.close(); + }); + + specify(`Pre Migrate TableInfo`, async function () { + for (const schemaName in schemaMap) { + const columns = await db.knex?.raw('pragma table_info(??)', schemaName); + assert.strictEqual(columns.length, 0, `column count ${schemaName}`); + } + }); + + specify('Post Migrate TableInfo', async function () { + await db.migrate(); + for (const schemaName in schemaMap) { + const schema = schemaMap[schemaName] as Schema; + const fieldMap = getMapFromList(schema.fields, 'fieldname'); + const columns: SqliteTableInfo[] = await db.knex!.raw( + 'pragma table_info(??)', + schemaName + ); + + let columnCount = schema.fields.filter( + (f) => f.fieldtype !== FieldTypeEnum.Table + ).length; + + if (schema.isSingle) { + columnCount = 0; + } + + assert.strictEqual( + columns.length, + columnCount, + `${schemaName}:: column count: ${columns.length}, ${columnCount}` + ); + + for (const column of columns) { + const field = fieldMap[column.name]; + const dbColType = sqliteTypeMap[field.fieldtype]; + + assert.strictEqual( + column.name, + field.fieldname, + `${schemaName}.${column.name}:: name check: ${column.name}, ${field.fieldname}` + ); + + assert.strictEqual( + column.type.toLowerCase(), + dbColType, + `${schemaName}.${column.name}:: type check: ${column.type}, ${dbColType}` + ); + + if (field.required !== undefined) { + assert.strictEqual( + !!column.notnull, + field.required, + `${schemaName}.${column.name}:: iotnull iheck: ${column.notnull}, ${field.required}` + ); + } else { + assert.strictEqual( + column.notnull, + 0, + `${schemaName}.${column.name}:: notnull check: ${column.notnull}, ${field.required}` + ); + } + + if (column.dflt_value === null) { + assert.strictEqual( + field.default, + undefined, + `${schemaName}.${column.name}:: dflt_value check: ${column.dflt_value}, ${field.default}` + ); + } else { + assert.strictEqual( + column.dflt_value.slice(1, -1), + String(field.default), + `${schemaName}.${column.name}:: dflt_value check: ${column.type}, ${dbColType}` + ); + } + } + } + }); +}); + +describe('DatabaseCore: CRUD', function () { + let db: DatabaseCore; + const schemaMap = getBuiltTestSchemaMap(); + + this.beforeEach(async function () { + db = new DatabaseCore(); + await db.connect(); + db.setSchemaMap(schemaMap); + await db.migrate(); + }); + + this.afterEach(async function () { + await db.close(); + }); + + specify('exists() before insertion', async function () { + for (const schemaName in schemaMap) { + const doesExist = await db.exists(schemaName); + if (['SingleValue', 'SystemSettings'].includes(schemaName)) { + assert.strictEqual(doesExist, true, `${schemaName} exists`); + } else { + assert.strictEqual(doesExist, false, `${schemaName} exists`); + } + } + }); + + specify('CRUD single values', async function () { + /** + * Checking default values which are created when db.migrate + * takes place. + */ + let rows: Record[] = await db.knex!.raw( + 'select * from SingleValue' + ); + const defaultMap = getValueMapFromList( + (schemaMap.SystemSettings as Schema).fields, + 'fieldname', + 'default' + ); + for (const row of rows) { + assert.strictEqual( + row.value, + defaultMap[row.fieldname as string], + `${row.fieldname} default values equality` + ); + } + + /** + * Insertion and updation for single values call the same function. + * + * Insert + */ + + let localeRow = rows.find((r) => r.fieldname === 'locale'); + const localeEntryName = localeRow?.name as string; + const localeEntryCreated = localeRow?.created as string; + + let locale = 'hi-IN'; + await db.insert('SystemSettings', { locale }); + rows = await db.knex!.raw('select * from SingleValue'); + localeRow = rows.find((r) => r.fieldname === 'locale'); + + assert.notStrictEqual(localeEntryName, undefined, 'localeEntryName'); + assert.strictEqual(rows.length, 2, 'rows length insert'); + assert.strictEqual( + localeRow?.name as string, + localeEntryName, + `localeEntryName ${localeRow?.name}, ${localeEntryName}` + ); + assert.strictEqual( + localeRow?.value, + locale, + `locale ${localeRow?.value}, ${locale}` + ); + assert.strictEqual( + localeRow?.created, + localeEntryCreated, + `locale ${localeRow?.value}, ${locale}` + ); + + /** + * Update + */ + locale = 'ca-ES'; + await db.update('SystemSettings', { locale }); + rows = await db.knex!.raw('select * from SingleValue'); + localeRow = rows.find((r) => r.fieldname === 'locale'); + + assert.notStrictEqual(localeEntryName, undefined, 'localeEntryName'); + assert.strictEqual(rows.length, 2, 'rows length update'); + assert.strictEqual( + localeRow?.name as string, + localeEntryName, + `localeEntryName ${localeRow?.name}, ${localeEntryName}` + ); + assert.strictEqual( + localeRow?.value, + locale, + `locale ${localeRow?.value}, ${locale}` + ); + assert.strictEqual( + localeRow?.created, + localeEntryCreated, + `locale ${localeRow?.value}, ${locale}` + ); + + /** + * Delete + */ + await db.delete('SystemSettings', 'locale'); + rows = await db.knex!.raw('select * from SingleValue'); + assert.strictEqual(rows.length, 1, 'delete one'); + await db.delete('SystemSettings', 'dateFormat'); + rows = await db.knex!.raw('select * from SingleValue'); + assert.strictEqual(rows.length, 0, 'delete two'); + + const dateFormat = 'dd/mm/yy'; + await db.insert('SystemSettings', { locale, dateFormat }); + rows = await db.knex!.raw('select * from SingleValue'); + assert.strictEqual(rows.length, 2, 'delete two'); + + /** + * Read + * + * getSingleValues + */ + const svl = await db.getSingleValues('locale', 'dateFormat'); + assert.strictEqual(svl.length, 2, 'getSingleValues length'); + for (const sv of svl) { + assert.strictEqual( + sv.parent, + 'SystemSettings', + `singleValue parent ${sv.parent}` + ); + assert.strictEqual( + sv.value, + { locale, dateFormat }[sv.fieldname], + `singleValue value ${sv.value}` + ); + + /** + * get + */ + const svlMap = await db.get('SystemSettings'); + assert.strictEqual(Object.keys(svlMap).length, 2, 'get key length'); + assert.strictEqual(svlMap.locale, locale, 'get locale'); + assert.strictEqual(svlMap.dateFormat, dateFormat, 'get locale'); + } + }); + + specify('CRUD nondependent schema', async function () { + const schemaName = 'Customer'; + let rows = await db.knex!(schemaName); + assert.strictEqual(rows.length, 0, 'rows length before insertion'); + + /** + * Insert + */ + const metaValues = getDefaultMetaFieldValueMap(); + const name = 'John Thoe'; + + await assertThrows( + async () => await db.insert(schemaName, { name }), + 'insert() did not throw without meta values' + ); + + const updateMap = Object.assign({}, metaValues, { name }); + await db.insert(schemaName, updateMap); + rows = await db.knex!(schemaName); + let firstRow = rows?.[0]; + assert.strictEqual(rows.length, 1, `rows length insert ${rows.length}`); + assert.strictEqual( + firstRow.name, + name, + `name check ${firstRow.name}, ${name}` + ); + assert.strictEqual(firstRow.email, null, `email check ${firstRow.email}`); + + for (const key in metaValues) { + assert.strictEqual( + firstRow[key], + metaValues[key as BaseMetaKey], + `${key} check` + ); + } + + /** + * Update + */ + const email = 'john@thoe.com'; + await sleep(1); // required for modified to change + await db.update(schemaName, { + name, + email, + modified: new Date().toISOString(), + }); + rows = await db.knex!(schemaName); + firstRow = rows?.[0]; + assert.strictEqual(rows.length, 1, `rows length update ${rows.length}`); + assert.strictEqual( + firstRow.name, + name, + `name check update ${firstRow.name}, ${name}` + ); + assert.strictEqual( + firstRow.email, + email, + `email check update ${firstRow.email}` + ); + + const phone = '8149133530'; + await sleep(1); + await db.update(schemaName, { + name, + phone, + modified: new Date().toISOString(), + }); + rows = await db.knex!(schemaName); + firstRow = rows?.[0]; + assert.strictEqual( + firstRow.email, + email, + `email check update ${firstRow.email}` + ); + assert.strictEqual( + firstRow.phone, + phone, + `email check update ${firstRow.phone}` + ); + + for (const key in metaValues) { + const val = firstRow[key]; + const expected = metaValues[key as BaseMetaKey]; + if (key !== 'modified') { + assert.strictEqual(val, expected, `${key} check ${val}, ${expected}`); + } else { + assert.notStrictEqual( + val, + expected, + `${key} check ${val}, ${expected}` + ); + } + } + + /** + * Delete + */ + await db.delete(schemaName, name); + rows = await db.knex!(schemaName); + assert.strictEqual(rows.length, 0, `rows length delete ${rows.length}`); + + /** + * Get + */ + let fvMap = await db.get(schemaName, name); + assert.strictEqual( + Object.keys(fvMap).length, + 0, + `key count get ${JSON.stringify(fvMap)}` + ); + + /** + * > 1 entries + */ + + const cOne = { name: 'John Whoe', ...getDefaultMetaFieldValueMap() }; + const cTwo = { name: 'Jane Whoe', ...getDefaultMetaFieldValueMap() }; + + // Insert + await db.insert(schemaName, cOne); + assert.strictEqual( + (await db.knex!(schemaName)).length, + 1, + `rows length minsert` + ); + await db.insert(schemaName, cTwo); + rows = await db.knex!(schemaName); + assert.strictEqual(rows.length, 2, `rows length minsert`); + + const cs = [cOne, cTwo]; + for (const i in cs) { + for (const k in cs[i]) { + const val = cs[i][k as BaseMetaKey]; + assert.strictEqual( + rows?.[i]?.[k], + val, + `equality check ${i} ${k} ${val} ${rows?.[i]?.[k]}` + ); + } + } + + // Update + await db.update(schemaName, { name: cOne.name, email }); + const cOneEmail = await db.get(schemaName, cOne.name, 'email'); + assert.strictEqual( + cOneEmail.email, + email, + `mi update check one ${cOneEmail}` + ); + const cTwoEmail = await db.get(schemaName, cTwo.name, 'email'); + assert.strictEqual( + cOneEmail.email, + email, + `mi update check two ${cTwoEmail}` + ); + + // Rename + const newName = 'Johnny Whoe'; + await db.rename(schemaName, cOne.name, newName); + + fvMap = await db.get(schemaName, cOne.name); + assert.strictEqual( + Object.keys(fvMap).length, + 0, + `mi rename check old ${JSON.stringify(fvMap)}` + ); + + fvMap = await db.get(schemaName, newName); + assert.strictEqual( + fvMap.email, + email, + `mi rename check new ${JSON.stringify(fvMap)}` + ); + + // Delete + await db.delete(schemaName, newName); + rows = await db.knex!(schemaName); + assert.strictEqual(rows.length, 1, `mi delete length ${rows.length}`); + assert.strictEqual( + rows[0].name, + cTwo.name, + `mi delete name ${rows[0].name}` + ); + }); + + specify('CRUD dependent schema', async function () { + const Customer = 'Customer'; + const SalesInvoice = 'SalesInvoice'; + const SalesInvoiceItem = 'SalesInvoiceItem'; + + const customer: FieldValueMap = { + name: 'John Whoe', + email: 'john@whoe.com', + ...getDefaultMetaFieldValueMap(), + }; + + const invoice: FieldValueMap = { + name: 'SINV-1001', + date: '2022-01-21', + customer: customer.name, + account: 'Debtors', + submitted: false, + cancelled: false, + ...getDefaultMetaFieldValueMap(), + }; + + await assertThrows( + async () => await db.insert(SalesInvoice, invoice), + 'foreign key constraint fail failed' + ); + + await assertDoesNotThrow(async () => { + await db.insert(Customer, customer); + await db.insert(SalesInvoice, invoice); + }, 'insertion failed'); + + await assertThrows( + async () => await db.delete(Customer, customer.name as string), + 'foreign key constraint fail failed' + ); + + await assertDoesNotThrow(async () => { + await db.delete(SalesInvoice, invoice.name as string); + await db.delete(Customer, customer.name as string); + }, 'deletion failed'); + + await db.insert(Customer, customer); + await db.insert(SalesInvoice, invoice); + + let fvMap = await db.get(SalesInvoice, invoice.name as string); + for (const key in invoice) { + let expected = invoice[key]; + if (typeof expected === 'boolean') { + expected = +expected; + } + + assert.strictEqual( + fvMap[key], + expected, + `equality check ${key}: ${fvMap[key]}, ${invoice[key]}` + ); + } + + assert.strictEqual( + (fvMap.items as unknown[])?.length, + 0, + 'empty items check' + ); + + const items: FieldValueMap[] = [ + { + item: 'Bottle Caps', + quantity: 2, + rate: 100, + amount: 200, + }, + ]; + + await assertThrows( + async () => await db.insert(SalesInvoice, { name: invoice.name, items }), + 'invoice insertion with ct did not fail' + ); + await assertDoesNotThrow( + async () => await db.update(SalesInvoice, { name: invoice.name, items }), + 'ct insertion failed' + ); + + fvMap = await db.get(SalesInvoice, invoice.name as string); + const ct = fvMap.items as FieldValueMap[]; + assert.strictEqual(ct.length, 1, `ct length ${ct.length}`); + assert.strictEqual(ct[0].parent, invoice.name, `ct parent ${ct[0].parent}`); + assert.strictEqual( + ct[0].parentFieldname, + 'items', + `ct parentFieldname ${ct[0].parentFieldname}` + ); + assert.strictEqual( + ct[0].parentSchemaName, + SalesInvoice, + `ct parentSchemaName ${ct[0].parentSchemaName}` + ); + for (const key in items[0]) { + assert.strictEqual( + ct[0][key], + items[0][key], + `ct values ${key}: ${ct[0][key]}, ${items[0][key]}` + ); + } + + items.push({ + item: 'Mentats', + quantity: 4, + rate: 200, + amount: 800, + }); + await assertDoesNotThrow( + async () => await db.update(SalesInvoice, { name: invoice.name, items }), + 'ct updation failed' + ); + + let rows = await db.getAll(SalesInvoiceItem, { + fields: ['item', 'quantity', 'rate', 'amount'], + }); + assert.strictEqual(rows.length, 2, `ct length update ${rows.length}`); + + for (const i in rows) { + for (const key in rows[i]) { + assert.strictEqual( + rows[i][key], + items[i][key], + `ct values ${i},${key}: ${rows[i][key]}` + ); + } + } + + invoice.date = '2022-04-01'; + invoice.modified = new Date().toISOString(); + await db.update('SalesInvoice', { + name: invoice.name, + date: invoice.date, + modified: invoice.modified, + }); + + rows = await db.knex!(SalesInvoiceItem); + assert.strictEqual(rows.length, 2, `postupdate ct empty ${rows.length}`); + + await db.delete(SalesInvoice, invoice.name as string); + rows = await db.getAll(SalesInvoiceItem); + assert.strictEqual(rows.length, 0, `ct length delete ${rows.length}`); + }); +}); diff --git a/backend/database/types.ts b/backend/database/types.ts new file mode 100644 index 00000000..7b0c3e15 --- /dev/null +++ b/backend/database/types.ts @@ -0,0 +1,57 @@ +import { Field, RawValue } from '../../schemas/types'; +import DatabaseCore from './core'; +import { DatabaseManager } from './manager'; + +export interface GetQueryBuilderOptions { + offset?: number; + limit?: number; + groupBy?: string; + orderBy?: string; + order?: 'desc' | 'asc'; +} + +export type ColumnDiff = { added: Field[]; removed: string[] }; +export type FieldValueMap = Record< + string, + RawValue | undefined | FieldValueMap[] +>; + +export interface Patch { + name: string; + version: string; + patch: { + execute: (dm: DatabaseManager) => Promise; + beforeMigrate?: boolean; + }; + priority?: number; +} + +export type KnexColumnType = + | 'text' + | 'integer' + | 'float' + | 'boolean' + | 'date' + | 'datetime' + | 'time' + | 'binary'; + +// Returned by pragma table_info +export interface SqliteTableInfo { + pk: number; + cid: number; + name: string; + type: string; + notnull: number; // 0 | 1 + dflt_value: string | null; +} + +export type BespokeFunction = ( + db: DatabaseCore, + ...args: unknown[] +) => Promise; +export type SingleValue = { + fieldname: string; + parent: string; + value: T; +}[]; diff --git a/backend/helpers.ts b/backend/helpers.ts new file mode 100644 index 00000000..4e480160 --- /dev/null +++ b/backend/helpers.ts @@ -0,0 +1,49 @@ +import { DatabaseMethod } from 'utils/db/types'; +import { KnexColumnType } from './database/types'; + +export const sqliteTypeMap: Record = { + AutoComplete: 'text', + Currency: 'text', + Int: 'integer', + Float: 'float', + Percent: 'float', + Check: 'boolean', + Code: 'text', + Date: 'date', + Datetime: 'datetime', + Time: 'time', + Text: 'text', + Data: 'text', + Link: 'text', + DynamicLink: 'text', + Password: 'text', + Select: 'text', + File: 'binary', + Attach: 'text', + AttachImage: 'text', + Color: 'text', +}; + +export const SYSTEM = '__SYSTEM__'; +export const validTypes = Object.keys(sqliteTypeMap); +export function getDefaultMetaFieldValueMap() { + const now = new Date().toISOString(); + return { + createdBy: SYSTEM, + modifiedBy: SYSTEM, + created: now, + modified: now, + }; +} + +export const databaseMethodSet: Set = new Set([ + 'insert', + 'get', + 'getAll', + 'getSingleValues', + 'rename', + 'update', + 'delete', + 'close', + 'exists', +]); diff --git a/backend/patches/index.ts b/backend/patches/index.ts new file mode 100644 index 00000000..11b8768d --- /dev/null +++ b/backend/patches/index.ts @@ -0,0 +1,13 @@ +import { Patch } from '../database/types'; +import testPatch from './testPatch'; +import updateSchemas from './updateSchemas'; + +export default [ + { name: 'testPatch', version: '0.5.0-beta.0', patch: testPatch }, + { + name: 'updateSchemas', + version: '0.5.0-beta.0', + patch: updateSchemas, + priority: 100, + }, +] as Patch[]; diff --git a/backend/patches/testPatch.ts b/backend/patches/testPatch.ts new file mode 100644 index 00000000..a18f9635 --- /dev/null +++ b/backend/patches/testPatch.ts @@ -0,0 +1,10 @@ +import { DatabaseManager } from '../database/manager'; + +async function execute(dm: DatabaseManager) { + /** + * Execute function will receive the DatabaseManager which is to be used + * to apply database patches. + */ +} + +export default { execute, beforeMigrate: true }; diff --git a/backend/patches/updateSchemas.ts b/backend/patches/updateSchemas.ts new file mode 100644 index 00000000..e6e23b20 --- /dev/null +++ b/backend/patches/updateSchemas.ts @@ -0,0 +1,400 @@ +import fs from 'fs/promises'; +import { RawValueMap } from 'fyo/core/types'; +import { Knex } from 'knex'; +import path from 'path'; +import { changeKeys, deleteKeys, getIsNullOrUndef, invertMap } from 'utils'; +import { getCountryCodeFromCountry } from 'utils/misc'; +import { Version } from 'utils/version'; +import { ModelNameEnum } from '../../models/types'; +import { FieldTypeEnum, Schema, SchemaMap } from '../../schemas/types'; +import { DatabaseManager } from '../database/manager'; + +const ignoreColumns = ['keywords']; +const columnMap = { creation: 'created', owner: 'createdBy' }; +const childTableColumnMap = { + parenttype: 'parentSchemaName', + parentfield: 'parentFieldname', +}; + +const defaultNumberSeriesMap = { + [ModelNameEnum.Payment]: 'PAY-', + [ModelNameEnum.JournalEntry]: 'JE-', + [ModelNameEnum.SalesInvoice]: 'SINV-', + [ModelNameEnum.PurchaseInvoice]: 'PINV-', +} as Record; + +async function execute(dm: DatabaseManager) { + const sourceKnex = dm.db!.knex!; + const version = ( + await sourceKnex('SingleValue') + .select('value') + .where({ fieldname: 'version' }) + )?.[0]?.value; + + /** + * Versions after this should have the new schemas + */ + + if (version && Version.gt(version, '0.4.3-beta.0')) { + return; + } + + /** + * Initialize a different db to copy all the updated + * data into. + */ + const countryCode = await getCountryCode(sourceKnex); + const destDm = await getDestinationDM(dm.db!.dbPath, countryCode); + + /** + * Copy data from all the relevant tables + * the other tables will be empty cause unused. + */ + try { + await copyData(sourceKnex, destDm); + } catch (err) { + const destPath = destDm.db!.dbPath; + destDm.db!.close(); + await fs.unlink(destPath); + throw err; + } + + /** + * Version will update when migration completes, this + * is set to prevent this patch from running again. + */ + await destDm.db!.update(ModelNameEnum.SystemSettings, { + version: '0.5.0-beta.0', + }); + + /** + * Replace the database with the new one. + */ + await replaceDatabaseCore(dm, destDm); +} + +async function replaceDatabaseCore( + dm: DatabaseManager, + destDm: DatabaseManager +) { + const newDbPath = destDm.db!.dbPath; // new db with new schema + const oldDbPath = dm.db!.dbPath; // old db to be replaced + + await dm.db!.close(); + await destDm.db!.close(); + await fs.unlink(oldDbPath); + await fs.rename(newDbPath, oldDbPath); + await dm._connect(oldDbPath); +} + +async function copyData(sourceKnex: Knex, destDm: DatabaseManager) { + const destKnex = destDm.db!.knex!; + const schemaMap = destDm.getSchemaMap(); + await destKnex!.raw('PRAGMA foreign_keys=OFF'); + await copySingleValues(sourceKnex, destKnex, schemaMap); + await copyParty(sourceKnex, destKnex, schemaMap[ModelNameEnum.Party]!); + await copyItem(sourceKnex, destKnex, schemaMap[ModelNameEnum.Item]!); + await copyChildTables(sourceKnex, destKnex, schemaMap); + await copyOtherTables(sourceKnex, destKnex, schemaMap); + await copyTransactionalTables(sourceKnex, destKnex, schemaMap); + await copyLedgerEntries( + sourceKnex, + destKnex, + schemaMap[ModelNameEnum.AccountingLedgerEntry]! + ); + await copyNumberSeries( + sourceKnex, + destKnex, + schemaMap[ModelNameEnum.NumberSeries]! + ); + await destKnex!.raw('PRAGMA foreign_keys=ON'); +} + +async function copyNumberSeries( + sourceKnex: Knex, + destKnex: Knex, + schema: Schema +) { + const values = (await sourceKnex( + ModelNameEnum.NumberSeries + )) as RawValueMap[]; + + const refMap = invertMap(defaultNumberSeriesMap); + + for (const value of values) { + if (value.referenceType) { + continue; + } + + const name = value.name as string; + const referenceType = refMap[name]; + if (!referenceType) { + delete value.name; + continue; + } + + const indices = await sourceKnex.raw( + ` + select cast(substr(name, ??) as int) as idx + from ?? + order by idx desc + limit 1`, + [name.length + 1, referenceType] + ); + + value.start = 1001; + value.current = indices[0]?.idx ?? value.current ?? value.start; + value.referenceType = referenceType; + } + + await copyValues( + destKnex, + ModelNameEnum.NumberSeries, + values.filter((v) => v.name), + [], + {}, + schema + ); +} + +async function copyLedgerEntries( + sourceKnex: Knex, + destKnex: Knex, + schema: Schema +) { + const values = (await sourceKnex( + ModelNameEnum.AccountingLedgerEntry + )) as RawValueMap[]; + await copyValues( + destKnex, + ModelNameEnum.AccountingLedgerEntry, + values, + ['description', 'againstAccount', 'balance'], + {}, + schema + ); +} + +async function copyOtherTables( + sourceKnex: Knex, + destKnex: Knex, + schemaMap: SchemaMap +) { + const schemaNames = [ + ModelNameEnum.Account, + ModelNameEnum.Currency, + ModelNameEnum.Address, + ModelNameEnum.Color, + ModelNameEnum.Tax, + ModelNameEnum.PatchRun, + ]; + + for (const sn of schemaNames) { + const values = (await sourceKnex(sn)) as RawValueMap[]; + await copyValues(destKnex, sn, values, [], {}, schemaMap[sn]); + } +} + +async function copyTransactionalTables( + sourceKnex: Knex, + destKnex: Knex, + schemaMap: SchemaMap +) { + const schemaNames = [ + ModelNameEnum.JournalEntry, + ModelNameEnum.Payment, + ModelNameEnum.SalesInvoice, + ModelNameEnum.PurchaseInvoice, + ]; + + for (const sn of schemaNames) { + const values = (await sourceKnex(sn)) as RawValueMap[]; + values.forEach((v) => { + if (!v.submitted) { + v.submitted = 0; + } + + if (!v.cancelled) { + v.cancelled = 0; + } + + if (!v.numberSeries) { + v.numberSeries = defaultNumberSeriesMap[sn]; + } + + if (v.customer) { + v.party = v.customer; + } + + if (v.supplier) { + v.party = v.supplier; + } + }); + await copyValues( + destKnex, + sn, + values, + [], + childTableColumnMap, + schemaMap[sn] + ); + } +} + +async function copyChildTables( + sourceKnex: Knex, + destKnex: Knex, + schemaMap: SchemaMap +) { + const childSchemaNames = Object.keys(schemaMap).filter( + (sn) => schemaMap[sn]?.isChild + ); + + for (const sn of childSchemaNames) { + const values = (await sourceKnex(sn)) as RawValueMap[]; + await copyValues( + destKnex, + sn, + values, + [], + childTableColumnMap, + schemaMap[sn] + ); + } +} + +async function copyItem(sourceKnex: Knex, destKnex: Knex, schema: Schema) { + const values = (await sourceKnex(ModelNameEnum.Item)) as RawValueMap[]; + values.forEach((value) => { + value.for = 'Both'; + }); + + await copyValues(destKnex, ModelNameEnum.Item, values, [], {}, schema); +} + +async function copyParty(sourceKnex: Knex, destKnex: Knex, schema: Schema) { + const values = (await sourceKnex(ModelNameEnum.Party)) as RawValueMap[]; + values.forEach((value) => { + // customer will be mapped onto role + if (Number(value.supplier) === 1) { + value.customer = 'Supplier'; + } else { + value.customer = 'Customer'; + } + }); + + await copyValues( + destKnex, + ModelNameEnum.Party, + values, + ['supplier', 'addressDisplay'], + { customer: 'role' }, + schema + ); +} + +async function copySingleValues( + sourceKnex: Knex, + destKnex: Knex, + schemaMap: SchemaMap +) { + const singleSchemaNames = Object.keys(schemaMap).filter( + (k) => schemaMap[k]?.isSingle + ); + const singleValues = (await sourceKnex(ModelNameEnum.SingleValue).whereIn( + 'parent', + singleSchemaNames + )) as RawValueMap[]; + await copyValues(destKnex, ModelNameEnum.SingleValue, singleValues); +} + +async function copyValues( + destKnex: Knex, + destTableName: string, + values: RawValueMap[], + keysToDelete: string[] = [], + keyMap: Record = {}, + schema?: Schema +) { + keysToDelete = [...keysToDelete, ...ignoreColumns]; + keyMap = { ...keyMap, ...columnMap }; + + values = values.map((sv) => deleteKeys(sv, keysToDelete)); + values = values.map((sv) => changeKeys(sv, keyMap)); + + if (schema) { + values.forEach((v) => notNullify(v, schema)); + } + + if (schema) { + const newKeys = schema?.fields.map((f) => f.fieldname); + values.forEach((v) => deleteOldKeys(v, newKeys)); + } + + await destKnex.batchInsert(destTableName, values, 100); +} + +async function getDestinationDM(sourceDbPath: string, countryCode: string) { + /** + * This is where all the stuff from the old db will be copied. + * That won't be altered cause schema update will cause data loss. + */ + + const dir = path.parse(sourceDbPath).dir; + const dbPath = path.join(dir, '__update_schemas_temp.db'); + const dm = new DatabaseManager(); + await dm._connect(dbPath, countryCode); + await dm.db!.migrate(); + await dm.db!.truncate(); + return dm; +} + +async function getCountryCode(knex: Knex) { + /** + * Need to account for schema changes, in 0.4.3-beta.0 + */ + const country = ( + await knex('SingleValue').select('value').where({ fieldname: 'country' }) + )?.[0]?.value; + + if (!country) { + return ''; + } + + return getCountryCodeFromCountry(country); +} + +function notNullify(map: RawValueMap, schema: Schema) { + for (const field of schema.fields) { + if (!field.required || !getIsNullOrUndef(map[field.fieldname])) { + continue; + } + + switch (field.fieldtype) { + case FieldTypeEnum.Float: + case FieldTypeEnum.Int: + case FieldTypeEnum.Check: + map[field.fieldname] = 0; + break; + case FieldTypeEnum.Currency: + map[field.fieldname] = '0.00000000000'; + break; + case FieldTypeEnum.Table: + continue; + default: + map[field.fieldname] = ''; + } + } +} + +function deleteOldKeys(map: RawValueMap, newKeys: string[]) { + for (const key of Object.keys(map)) { + if (newKeys.includes(key)) { + continue; + } + + delete map[key]; + } +} + +export default { execute, beforeMigrate: true }; diff --git a/dummy/README.md b/dummy/README.md new file mode 100644 index 00000000..fca58117 --- /dev/null +++ b/dummy/README.md @@ -0,0 +1,11 @@ +# Dummy + +This will be used to generate dummy data for the purposes of tests and to create +a demo instance. + +There are a few `.json` files here (eg: `items.json`) which have been generated, +these are not to be edited. + +The generated data has some randomness, there is a `baseCount` arg to the +exported `setupDummyInstance` function, the number of transactions generated are +always more than this. \ No newline at end of file diff --git a/dummy/helpers.ts b/dummy/helpers.ts new file mode 100644 index 00000000..b9f7a084 --- /dev/null +++ b/dummy/helpers.ts @@ -0,0 +1,64 @@ +import { DateTime } from 'luxon'; + +// prettier-ignore +export const partyPurchaseItemMap: Record = { + 'Janky Office Spaces': ['Office Rent', 'Office Cleaning'], + "Josféña's 611s": ['611 Jeans - PCH', '611 Jeans - SHR'], + 'Lankness Feet Fomenters': ['Bominga Shoes', 'Jade Slippers'], + 'The Overclothes Company': ['Jacket - RAW', 'Cryo Gloves', 'Cool Cloth'], + 'Adani Electricity Mumbai Limited': ['Electricity'], + 'Only Fulls': ['Full Sleeve - BLK', 'Full Sleeve - COL'], + 'Just Epaulettes': ['Epaulettes - 4POR'], + 'Le Socials': ['Social Ads'], + 'Maxwell': ['Marketing - Video'], +}; + +export const purchaseItemPartyMap: Record = Object.keys( + partyPurchaseItemMap +).reduce((acc, party) => { + for (const item of partyPurchaseItemMap[party]) { + acc[item] = party; + } + return acc; +}, {} as Record); + +export const flow = [ + 0.35, // Jan + 0.25, // Feb + 0.15, // Mar + 0.15, // Apr + 0.25, // May + 0.05, // Jun + 0.05, // Jul + 0.15, // Aug + 0.25, // Sep + 0.35, // Oct + 0.45, // Nov + 0.55, // Dec +]; +export function getFlowConstant(months: number) { + // Jan to December + const d = DateTime.now().minus({ months }); + return flow[d.month - 1]; +} + +export function getRandomDates(count: number, months: number): Date[] { + /** + * Returns `count` number of dates for a month, `months` back from the + * current date. + */ + let endDate = DateTime.now(); + if (months !== 0) { + const back = endDate.minus({ months }); + endDate = DateTime.local(back.year, back.month, back.daysInMonth); + } + + const dates: Date[] = []; + for (let i = 0; i < count; i++) { + const day = Math.ceil(endDate.day * Math.random()); + const date = DateTime.local(endDate.year, endDate.month, day); + dates.push(date.toJSDate()); + } + + return dates; +} diff --git a/dummy/index.ts b/dummy/index.ts new file mode 100644 index 00000000..90d19084 --- /dev/null +++ b/dummy/index.ts @@ -0,0 +1,537 @@ +import { Fyo, t } from 'fyo'; +import { Doc } from 'fyo/model/doc'; +import { range, sample } from 'lodash'; +import { DateTime } from 'luxon'; +import { Invoice } from 'models/baseModels/Invoice/Invoice'; +import { Payment } from 'models/baseModels/Payment/Payment'; +import { PurchaseInvoice } from 'models/baseModels/PurchaseInvoice/PurchaseInvoice'; +import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice'; +import { ModelNameEnum } from 'models/types'; +import setupInstance from 'src/setup/setupInstance'; +import { getMapFromList } from 'utils'; +import { getFiscalYear } from 'utils/misc'; +import { + flow, + getFlowConstant, + getRandomDates, + purchaseItemPartyMap, +} from './helpers'; +import items from './items.json'; +import logo from './logo'; +import parties from './parties.json'; + +type Notifier = (stage: string, percent: number) => void; + +export async function setupDummyInstance( + dbPath: string, + fyo: Fyo, + years: number = 1, + baseCount: number = 1000, + notifier?: Notifier +) { + fyo.purgeCache(); + notifier?.(fyo.t`Setting Up Instance`, -1); + const options = { + logo: null, + companyName: "Flo's Clothes", + country: 'India', + fullname: 'Lin Florentine', + email: 'lin@flosclothes.com', + bankName: 'Supreme Bank', + currency: 'INR', + fiscalYearStart: getFiscalYear('04-01', true), + fiscalYearEnd: getFiscalYear('04-01', false), + chartOfAccounts: 'India - Chart of Accounts', + }; + await setupInstance(dbPath, options, fyo); + + years = Math.floor(years); + notifier?.(fyo.t`Creating Items and Parties`, -1); + await generateStaticEntries(fyo); + await generateDynamicEntries(fyo, years, baseCount, notifier); + await setOtherSettings(fyo); + + const instanceId = (await fyo.getValue( + ModelNameEnum.SystemSettings, + 'instanceId' + )) as string; + return { companyName: options.companyName, instanceId }; +} + +async function setOtherSettings(fyo: Fyo) { + const doc = await fyo.doc.getDoc(ModelNameEnum.PrintSettings); + const address = fyo.doc.getNewDoc(ModelNameEnum.Address); + await address.setAndSync({ + addressLine1: '1st Column, Fitzgerald Bridge', + city: 'Pune', + state: 'Maharashtra', + pos: 'Maharashtra', + postalCode: '411001', + country: 'India', + }); + + await doc.setAndSync({ + color: '#F687B3', + template: 'Business', + displayLogo: true, + phone: '+91 8983-000418', + logo, + address: address.name, + }); + + const acc = await fyo.doc.getDoc(ModelNameEnum.AccountingSettings); + await acc.setAndSync({ + gstin: '27LIN180000A1Z5', + }); + console.log(acc.gstin, await fyo.db.getSingleValues('gstin')); +} + +/** + * warning: long functions ahead! + */ + +async function generateDynamicEntries( + fyo: Fyo, + years: number, + baseCount: number, + notifier?: Notifier +) { + const salesInvoices = await getSalesInvoices(fyo, years, baseCount, notifier); + + notifier?.(fyo.t`Creating Purchase Invoices`, -1); + const purchaseInvoices = await getPurchaseInvoices(fyo, years, salesInvoices); + + notifier?.(fyo.t`Creating Journal Entries`, -1); + const journalEntries = await getJournalEntries(fyo, salesInvoices); + await syncAndSubmit(journalEntries, notifier); + + const invoices = ([salesInvoices, purchaseInvoices].flat() as Invoice[]).sort( + (a, b) => +(a.date as Date) - +(b.date as Date) + ); + await syncAndSubmit(invoices, notifier); + + const payments = await getPayments(fyo, invoices); + await syncAndSubmit(payments, notifier); +} + +async function getJournalEntries(fyo: Fyo, salesInvoices: SalesInvoice[]) { + const entries = []; + const amount = salesInvoices + .map((i) => i.items!) + .flat() + .reduce((a, b) => a.add(b.amount!), fyo.pesa(0)) + .percent(75) + .clip(0); + const lastInv = salesInvoices.sort((a, b) => +a.date! - +b.date!).at(-1)! + .date!; + const date = DateTime.fromJSDate(lastInv).minus({ months: 6 }).toJSDate(); + + // Bank Entry + let doc = fyo.doc.getNewDoc( + ModelNameEnum.JournalEntry, + { + date, + entryType: 'Bank Entry', + }, + false + ); + await doc.append('accounts', { + account: 'Supreme Bank', + debit: amount, + credit: fyo.pesa(0), + }); + + await doc.append('accounts', { + account: 'Secured Loans', + credit: amount, + debit: fyo.pesa(0), + }); + entries.push(doc); + + // Cash Entry + doc = fyo.doc.getNewDoc( + ModelNameEnum.JournalEntry, + { + date, + entryType: 'Cash Entry', + }, + false + ); + await doc.append('accounts', { + account: 'Cash', + debit: amount.percent(30), + credit: fyo.pesa(0), + }); + + await doc.append('accounts', { + account: 'Supreme Bank', + credit: amount.percent(30), + debit: fyo.pesa(0), + }); + entries.push(doc); + + return entries; +} + +async function getPayments(fyo: Fyo, invoices: Invoice[]) { + const payments = []; + for (const invoice of invoices) { + // Defaulters + if (invoice.isSales && Math.random() < 0.007) { + continue; + } + + const doc = fyo.doc.getNewDoc(ModelNameEnum.Payment, {}, false) as Payment; + doc.party = invoice.party as string; + doc.paymentType = invoice.isSales ? 'Receive' : 'Pay'; + doc.paymentMethod = 'Cash'; + doc.date = DateTime.fromJSDate(invoice.date as Date) + .plus({ hours: 1 }) + .toJSDate(); + if (doc.paymentType === 'Receive') { + doc.account = 'Debtors'; + doc.paymentAccount = 'Cash'; + } else { + doc.account = 'Cash'; + doc.paymentAccount = 'Creditors'; + } + doc.amount = invoice.outstandingAmount; + + // Discount + if (invoice.isSales && Math.random() < 0.05) { + await doc.set('writeOff', invoice.outstandingAmount?.percent(15)); + } + + doc.push('for', { + referenceType: invoice.schemaName, + referenceName: invoice.name, + amount: invoice.outstandingAmount, + }); + + if (doc.amount!.isZero()) { + continue; + } + + payments.push(doc); + } + + return payments; +} + +function getSalesInvoiceDates(years: number, baseCount: number): Date[] { + const dates: Date[] = []; + for (const months of range(0, years * 12)) { + const flow = getFlowConstant(months); + const count = Math.ceil(flow * baseCount * (Math.random() * 0.25 + 0.75)); + dates.push(...getRandomDates(count, months)); + } + + return dates; +} + +async function getSalesInvoices( + fyo: Fyo, + years: number, + baseCount: number, + notifier?: Notifier +) { + const invoices: SalesInvoice[] = []; + const salesItems = items.filter((i) => i.for !== 'Purchases'); + const customers = parties.filter((i) => i.role !== 'Supplier'); + + /** + * Get certain number of entries for each month of the count + * of years. + */ + const dates = getSalesInvoiceDates(years, baseCount); + + /** + * For each date create a Sales Invoice. + */ + + for (const d in dates) { + const date = dates[d]; + + notifier?.( + `Creating Sales Invoices, ${d} out of ${dates.length}`, + parseInt(d) / dates.length + ); + const customer = sample(customers); + + const doc = fyo.doc.getNewDoc( + ModelNameEnum.SalesInvoice, + { + date, + }, + false + ) as SalesInvoice; + + await doc.set('party', customer!.name); + if (!doc.account) { + doc.account = 'Debtors'; + } + /** + * Add `numItems` number of items to the invoice. + */ + const numItems = Math.ceil(Math.random() * 5); + for (let i = 0; i < numItems; i++) { + const item = sample(salesItems); + if ((doc.items ?? []).find((i) => i.item === item)) { + continue; + } + + let quantity = 1; + + /** + * Increase quantity depending on the rate. + */ + if (item!.rate < 100 && Math.random() < 0.4) { + quantity = Math.ceil(Math.random() * 10); + } else if (item!.rate < 1000 && Math.random() < 0.2) { + quantity = Math.ceil(Math.random() * 4); + } else if (Math.random() < 0.01) { + quantity = Math.ceil(Math.random() * 3); + } + + let fc = flow[date.getMonth()]; + if (baseCount < 500) { + fc += 1; + } + const rate = fyo.pesa(item!.rate * (fc + 1)).clip(0); + await doc.append('items', {}); + await doc.items!.at(-1)!.set({ + item: item!.name, + rate, + quantity, + account: item!.incomeAccount, + amount: rate.mul(quantity), + tax: item!.tax, + description: item!.description, + hsnCode: item!.hsnCode, + }); + } + + invoices.push(doc); + } + + return invoices; +} + +async function getPurchaseInvoices( + fyo: Fyo, + years: number, + salesInvoices: SalesInvoice[] +): Promise { + return [ + await getSalesPurchaseInvoices(fyo, salesInvoices), + await getNonSalesPurchaseInvoices(fyo, years), + ].flat(); +} + +async function getSalesPurchaseInvoices( + fyo: Fyo, + salesInvoices: SalesInvoice[] +): Promise { + const invoices = [] as PurchaseInvoice[]; + /** + * Group all sales invoices by their YYYY-MM. + */ + const dateGrouped = salesInvoices + .map((si) => { + const date = DateTime.fromJSDate(si.date as Date); + const key = `${date.year}-${String(date.month).padStart(2, '0')}`; + return { key, si }; + }) + .reduce((acc, item) => { + acc[item.key] ??= []; + acc[item.key].push(item.si); + return acc; + }, {} as Record); + + /** + * Sort the YYYY-MM keys in ascending order. + */ + const dates = Object.keys(dateGrouped) + .map((k) => ({ key: k, date: new Date(k) })) + .sort((a, b) => +a.date - +b.date); + const purchaseQty: Record = {}; + + /** + * For each date create a set of Purchase Invoices. + */ + for (const { key, date } of dates) { + /** + * Group items by name to get the total quantity used in a month. + */ + const itemGrouped = dateGrouped[key].reduce((acc, si) => { + for (const item of si.items!) { + if (item.item === 'Dry-Cleaning') { + continue; + } + + acc[item.item as string] ??= 0; + acc[item.item as string] += item.quantity as number; + } + + return acc; + }, {} as Record); + + /** + * Set order quantity for the first of the month. + */ + Object.keys(itemGrouped).forEach((name) => { + const quantity = itemGrouped[name]; + purchaseQty[name] ??= 0; + let prevQty = purchaseQty[name]; + + if (prevQty <= quantity) { + prevQty = quantity - prevQty; + } + + purchaseQty[name] = Math.ceil(prevQty / 10) * 10; + }); + + const supplierGrouped = Object.keys(itemGrouped).reduce((acc, item) => { + const supplier = purchaseItemPartyMap[item]; + acc[supplier] ??= []; + acc[supplier].push(item); + + return acc; + }, {} as Record); + + /** + * For each supplier create a Purchase Invoice + */ + for (const supplier in supplierGrouped) { + const doc = fyo.doc.getNewDoc( + ModelNameEnum.PurchaseInvoice, + { + date, + }, + false + ) as PurchaseInvoice; + + await doc.set('party', supplier); + if (!doc.account) { + doc.account = 'Creditors'; + } + + /** + * For each item create a row + */ + for (const item of supplierGrouped[supplier]) { + await doc.append('items', {}); + const quantity = purchaseQty[item]; + doc.items!.at(-1)!.set({ item, quantity }); + } + + invoices.push(doc); + } + } + + return invoices; +} + +async function getNonSalesPurchaseInvoices( + fyo: Fyo, + years: number +): Promise { + const purchaseItems = items.filter((i) => i.for !== 'Sales'); + const itemMap = getMapFromList(purchaseItems, 'name'); + const periodic: Record = { + 'Marketing - Video': 2, + 'Social Ads': 1, + Electricity: 1, + 'Office Cleaning': 1, + 'Office Rent': 1, + }; + const invoices: SalesInvoice[] = []; + + for (const months of range(0, years * 12)) { + /** + * All purchases on the first of the month. + */ + const temp = DateTime.now().minus({ months }); + const date = DateTime.local(temp.year, temp.month, 1).toJSDate(); + + for (const name in periodic) { + if (months % periodic[name] !== 0) { + continue; + } + + const doc = fyo.doc.getNewDoc( + ModelNameEnum.PurchaseInvoice, + { + date, + }, + false + ) as PurchaseInvoice; + + const party = purchaseItemPartyMap[name]; + await doc.set('party', party); + if (!doc.account) { + doc.account = 'Creditors'; + } + await doc.append('items', {}); + const row = doc.items!.at(-1)!; + const item = itemMap[name]; + + let quantity = 1; + let rate = item.rate; + if (name === 'Social Ads') { + quantity = Math.ceil(Math.random() * 200); + } else if (name !== 'Office Rent') { + rate = rate * (Math.random() * 0.4 + 0.8); + } + + await row.set({ + item: item.name, + quantity, + rate: fyo.pesa(rate).clip(0), + }); + + invoices.push(doc); + } + } + + return invoices; +} + +async function generateStaticEntries(fyo: Fyo) { + await generateItems(fyo); + await generateParties(fyo); +} + +async function generateItems(fyo: Fyo) { + for (const item of items) { + const doc = fyo.doc.getNewDoc('Item', item, false); + await doc.sync(); + } +} + +async function generateParties(fyo: Fyo) { + for (const party of parties) { + const doc = fyo.doc.getNewDoc('Party', party, false); + await doc.sync(); + } +} + +async function syncAndSubmit(docs: Doc[], notifier?: Notifier) { + const nameMap: Record = { + [ModelNameEnum.PurchaseInvoice]: t`Invoices`, + [ModelNameEnum.SalesInvoice]: t`Invoices`, + [ModelNameEnum.Payment]: t`Payments`, + [ModelNameEnum.JournalEntry]: t`Journal Entries`, + }; + + const total = docs.length; + for (const i in docs) { + const doc = docs[i]; + notifier?.( + `Syncing ${nameMap[doc.schemaName]}, ${i} out of ${total}`, + parseInt(i) / total + ); + await doc.sync(); + await doc.submit(); + } +} diff --git a/dummy/items.json b/dummy/items.json new file mode 100644 index 00000000..09acafbb --- /dev/null +++ b/dummy/items.json @@ -0,0 +1 @@ +[{"name": "Dry-Cleaning", "description": null, "unit": "Unit", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 69, "hsnCode": 999712, "for": "Sales"}, {"name": "Electricity", "description": "Bzz Bzz", "unit": "Day", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Utility Expenses", "tax": "GST-0", "rate": 6000, "hsnCode": 271600, "for": "Purchases"}, {"name": "Marketing - Video", "description": "One single video", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Marketing Expenses", "tax": "GST-18", "rate": 15000, "hsnCode": 998371, "for": "Purchases"}, {"name": "Office Rent", "description": "Rent per day", "unit": "Day", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Office Rent", "tax": "GST-18", "rate": 50000, "hsnCode": 997212, "for": "Purchases"}, {"name": "Office Cleaning", "description": "Cleaning cost per day", "unit": "Day", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Office Maintenance Expenses", "tax": "GST-18", "rate": 7500, "hsnCode": 998533, "for": "Purchases"}, {"name": "Social Ads", "description": "Cost per click", "unit": "Unit", "itemType": "Service", "incomeAccount": "Service", "expenseAccount": "Marketing Expenses", "tax": "GST-18", "rate": 50, "hsnCode": 99836, "for": "Purchases"}, {"name": "Cool Cloth", "description": "Some real \ud83c\udd92 cloth", "unit": "Meter", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 4000, "hsnCode": 59111000, "for": "Both"}, {"name": "611 Jeans - PCH", "description": "Peach coloured 611s", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 4499, "hsnCode": 62034990, "for": "Both"}, {"name": "611 Jeans - SHR", "description": "Shark skin 611s", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 6499, "hsnCode": 62034990, "for": "Both"}, {"name": "Bominga Shoes", "description": null, "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 4999, "hsnCode": 640291, "for": "Both"}, {"name": "Cryo Gloves", "description": "Keeps hands cool", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 3499, "hsnCode": 611693, "for": "Both"}, {"name": "Epaulettes - 4POR", "description": "Porcelain epaulettes", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 2499, "hsnCode": 62179090, "for": "Both"}, {"name": "Full Sleeve - BLK", "description": "Black sleeved", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 599, "hsnCode": 100820, "for": "Both"}, {"name": "Full Sleeve - COL", "description": "All color sleeved", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 499, "hsnCode": 100820, "for": "Both"}, {"name": "Jacket - RAW", "description": "Raw baby skinned jackets", "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-12", "rate": 8999, "hsnCode": 100820, "for": "Both"}, {"name": "Jade Slippers", "description": null, "unit": "Unit", "itemType": "Product", "incomeAccount": "Sales", "expenseAccount": "Cost of Goods Sold", "tax": "GST-18", "rate": 2999, "hsnCode": 640520, "for": "Both"}] \ No newline at end of file diff --git a/dummy/logo.ts b/dummy/logo.ts new file mode 100644 index 00000000..d2d2d6ad --- /dev/null +++ b/dummy/logo.ts @@ -0,0 +1 @@ +export default `data:image/jpeg;base64,/9j/4AAQSkZJRgABAgEASABIAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAEsASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD+S6v38/CAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD6K+Av7JH7Sv7T97dWfwF+DPjf4kpYTx22o6to2mra+GNLupQrRWureLtYm03wtpVzIjCSO31HWLaZ4Q0yoYkd14sXmOBwKTxeJpULq8Yylecl3jTipVJLzUWj3Mm4Zz7iGco5NlWLx6g1GpVpU+XD05PaNTE1XTw9OTWqjOrFtapWTZ9i6p/wRY/4KRaZYXt8nwAtNYl0tFfVNJ8P/ABY+Det67p6urvGJdIsPH0t7cSyhG8m2sI7y6mwWihdUdl86PE2SyaX1xx5vhlPD4mMH/wBvOjZersl3PqanhRx3ThOf9ixqumk6lKhmWV1q0L7XpQxrnJu2kYKcn0TSdvzl+IXw2+IXwm8U3/gj4oeCPFfw98YaXsN/4Z8Z6DqfhzW7aOUt5Fw+natbWty1pcqjSWl4kb2t3Fia2mliZXPs0a9HEU1VoVadanLadKcZxfdc0W1ddVutmj4XG4DHZbiJ4TMMJicDiqfx4fFUalCrFPaXJUjGXLK14yScZLWLa1OKrU5AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA+vP2aP28f2rv2PdK8VaJ+zn8Vf+Fd6Z421DTdV8T23/AAg/w38W/wBp3+kW1zaafP53jrwf4nuLL7Pb3dxH5Wny2kMvmb545HRGXzsdlOX5lKnLG4f20qSlGm/a16fKpNNq1KpTTu0t7vsfT5BxjxJwvTxNHIsx+o08XOnUxEfqeAxPtJ0oyjB3xmFxEocsZSVoOKd7tNpH9H3/AARH/wCCgv7Xn7Xnx++LHgn9oj4uf8LC8MeGfg9J4p0TTP8AhAvhj4T+xa8vjTwtpIvvtvgfwX4a1C5xp+pXtv8AZbu7ns/33mm38+OKSP4vijJ8uy7B4erg8P7GpPE+zlL21epeHsqkrWq1ZpaxTuknpvY/dfCXjbifibOsywmeZn9ew+Hyt4ijT+pZfhuSt9bw1Ln58JhMPOXuVJx5ZSlDW/LdJr+mSvhz99P56/8Agud+3H+1J+x1qv7NFt+zj8UP+FdQfEHT/i1P4vT/AIQr4d+Lv7Xl8MXPw5j0Nt3jvwl4new+wpr2rDbpjWS3P2vN4LgwWxh+w4VyrAZlHHPG0PbOjLDqn+9rU+X2irc/8KpTvfkj8V7W0td3/EvF/i/iLhepkEcizD6isbDMnil9UwOJ9q8PLAqi/wDbMNiHDkVap/D5Obm97mtG34J23/Bb3/gp1BPHLL+0jBeRoSWtrn4NfARYJhgjbI1p8L7W5ABIYeVcRNkDLFcqfrXwtkTX+5NeaxOLv+Ndr8D8bj4t+ICabz5TS+zLKsmSfry5fGX3SR+iH7Jf/BxT8SdM8TaR4Y/bD8DeHPFHgm/uLayu/id8NdJuNA8YeGhK3lya1rfhIXl5oXizT4nMb3dn4dh8K6laWn2q6sbfXruO10e48bMODaEqcqmW1Z06qTaoV5KdOf8AdjUsp02+jm6kW7JuCvJfccNeOOPp4ilh+KMHQxGEnKMJZhgKbo4qhfR1a2G5pUcTBO3NGgsNUjHmlCNaSjSl/VJqHivw347+EGpeNPB2taf4j8KeKvh5qPiDw5r+k3CXWm6xo2reHp73TtRsrhPllt7q1mjljPDANtdVcMo+CVOdLExpVIuFSnWjCcJK0oyjNKUWu6aP6KniaGMyypi8LVhXw2JwM69CtTlzU6tKpQc4ThJbxlFpr8dT/Ltr92P89T6x/Zl/bj/ak/Y6g8ZW37OPxQ/4V1B8QZdBn8Xp/wAIV8O/F39ry+GE1ePQ23eO/CXid7D7CmvasNumNZLc/a83guDBbGHz8dlWAzJ0njaHtnRU1T/e1qfL7Tl5/wCFUp3vyR+K9raWu7/ScP8AF/EXC6xUcizD6isa6LxS+qYHE+1eHVVUX/tmGxDhyKtU/h8nNze9zWjb+jn/AIIl/wDBQz9sH9rr9pP4l+AP2hvi9/wsHwl4f+B2r+MNI0n/AIQD4X+E/sniO18e+ANFg1H7f4H8FeGtTuNmma3qlt9kur2axb7V5z2rXENvLF8XxRk+W5dgqFbB4b2NSeKjTlL21epeDo1pONqtWcV70Yu6Selr2bP3Xwm434o4mz7MMFneZ/XcNQyiriqVP6ll+G5a8cZgqSnz4TCYepK1OrUjyym4e9dx5lFr+nSvhj+gD+dv/guV+3V+1T+x34q/Z0039nL4pf8ACurLx34f+JF94rh/4Qj4c+Lv7VutA1HwfBpMvmeOvCHieWx+yRarfps02Szjn8/dcpM0UJj+y4VyrAZlTxssbQ9s6U6Cpv2tanyqcajl/CqU735V8V7W0tqfh/i9xhxFwviMjp5FmP1GGMo4+eJX1TA4n2kqNTCxpO+MwuIcOVVJq1NxTv7ydlb+Zv8AaQ/4KJ/tjftceB9K+G/7Qvxh/wCFg+C9E8V2PjfTNG/4V98LfCn2bxRpuka7oNlqf9o+CPBHhrVZvJ0rxLrVr9iuL6XT5Ptvny2klzbWk0H3GCybLcuqyr4PDexqypulKXtq9S9OUoTceWrVnFXlCLulfS17Np/gWe8ccU8TYSngM7zT67hKWJhi6dL6ll2G5cRTpVqMKnPhMJh6jtTxFWPJKbg+e7i5Ri18U16Z8mFABQAUAftT/wAEi/8AglxcftteMLv4q/F231TSf2avh9q9vaahHbm6069+LPieEC5m8FaJqcDw3Fhoemwm2m8aa7YypfQwXtnomiTQapfXOreHvmOIs+WV01h8O4yx1aLavZrD03oqsou6c5O/soPRtOUk4pRn+r+Gfh5Li3FSzHM41KeQYKqozUeaE8yxC954SjUTUoUaa5Xiq0GppTjSotVJyqUP2l/4LL/tjwfsI/s/fDn9lX9lpNI+FHjL4kaRe29uPA9lBoc3ww+Duk79Nu7rwzFpwtRoWv8AjHWnk0jR/EECzXtpa6T4wv7aS08QjStYtPmeGstebYytj8fzYilQkm/atzVfEy95Kd788KcfelB2Tcqad4c0X+reKnFK4PyTA8OcO+zy3FY+lOMfqcVReX5XSvTlLDqHL7GtiqrdKlWjecY08VOLjX9nVj/IR8I/jl8WvgV8RdJ+K3wo8e+JPBnjzSNRTUo9d0nVLuGbUX+0C4urLXovNMGv6Rqh8yHWdI1iO807V7We4ttQtriGeRG/RcRhcPi6MsPiKMKtKUeXklFNR0snDrCUd4yjaUWk000fzJlmb5lk+OpZlluMr4XGUqiqKtTqSTqPm5pQrK9q1KpqqtKqpQqxcozjJNo/uw8O+Dv2a/8Agsp+w98M/HPxU8Hac2p+JPD11A2uaGLW38cfB74naXONI8aWvhDXriC61DT7A+INJF3FpepJLpninw2dBvNa0u7hmszF+UzqY7hrNK9LD1JcsJp8k7uliaElzUnUgmk3yStzR96nPnUZJpn9g0MLkPinwjgMZmOFp+0xFCUfbUeVYzK8wpy9li44WtJSnCHt6fMqdROniMP7GVWnJONv4of2yv2SPiT+xV8dfE3wT+JESXT2GNY8HeK7WFodK8deB7+6uodC8V6bG0kxtvtYtJ7XVNMeaabRtas9R0qSe5+yLdT/AKfluY0MzwkMVQdr+7Vpt3lSqpJzpy2va6cZWSlFxlZXsv5P4p4Zx/CecYjKcelJw/e4XExVqeMwk5SVHE01d8vNyuNSm23SqxnTblyqUvlau8+cCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD+i7/g20/5Oo+Ov/Zv0v/qxvBFfGca/7hhP+wxf+map+5eA/wDyUecf9iV/+p2EP7MK/NT+pz+Tr/g5o/5Dn7Gv/YJ+PP8A6WfCGv0Hgf4My/xYT8sQfzd4/fxuFv8Ar1nP/pWWH8s1feH87hQB/Xf/AMECP2j9a8cfsu/tGfs4eJdSuL8/BayuvFXgL7XP5r2fgv4haX4iOq6BZIUDR6fonizR73WF3SSET+MpIkEcMMSV+d8XYKNLH4LGwil9Zap1rLerRlDlm/OVOSj6Ur7s/prwYz2tjOHs9yLEVJT/ALJhLEYLmd3HCY6nX9pRiraQpYmlOqtXrimlZJI/kQr9EP5lCgD+hn/g26/5PE+Mv/ZtOv8A/q0fhXXx3Gv/ACLcN/2HQ/8ATGIP27wI/wCSozX/ALEFb/1Y5cf2i1+Zn9Vn8kv/AAcx/wDI8fsjf9ip8YP/AE7/AA/r9C4H/hZj/wBfMN/6TWP5q8ff974Z/wCwbNP/AE7gj+Xuvuz+ewoAKACgD2D9n/4KeMP2jvjV8NPgd4Dg83xR8S/Fem+G7GZonnt9KtZ3afWfEOoRxESnSvDOiW+o+IdXaM+ZHpemXciBmUA82MxVPBYWviqr/d0KcptbOTWkYL+9OTjCP96SPUyXKcVnubYDKMGr4jH4mnQg7Nxpxk+arXmlr7PD0Y1K9W2qp05Nan+lL8C/gr4C/Z2+EvgX4L/DLSU0fwZ4A0G10TSoOHurySPdNqGsanPgG71jXNRlu9X1e8bBudQvLiUBFZUX8TxeKrYzEVcTXlzVa03KT6LtGK6RhG0Yrokj+88nynBZHluDyrL6apYXBUY0aa+1JrWdWo/tVa1Ryq1ZP4pyk9Nj+Cn/AIK+fGyX44/8FBPj/q0N895oPgDxBB8HvDMfnLPBZaf8NLZfD2sxWUiO8ZtL7xrD4q1uMo20vq0jAAsa/WuHML9VyfBxatOtB4melm3XfPG/mqTpx/7dP428Ts2eb8a51VU+ejgq6yvDq94whgI+wqqLTa5Z4tYmqrdajPzQr2z4E/qw/wCDaz44XDD9o39m7ULwNbRDw/8AGzwlY7jvikka28DfEC52sxDRyBfhsieWieU6SmVpDPEI/gONsKv9ixqWvv4Wo/vq0V/6f9fkf0b4C5u3/buQzl7q9hm2Gh1TfLg8bL0f+wJWtZ3ve6t+of8AwWL/AGJbL9rz9lbX9c8N6P8Aa/jX8DbLVvH/AMNLi0gR9T1nT7a3iuPGvgIERPcXMXifRLD7TpVjCySTeLdH8OASLA13HP4XDeaPLsfCM5WwuKcaNdN+7Ft2pVuydOTtJv8A5dyn1tb9C8UeEocTcO1q1ClzZtlEKuNwEoxTqVYRipYvB7OUliKUOanBNN4mlQ1tzJ/wGV+uH8YBQAUAFABQAUAFABQAUAFABQAUAFABQAUAFABQB/Rd/wAG2n/J1Hx1/wCzfpf/AFY3givjONf9wwn/AGGL/wBM1T9y8B/+Sjzj/sSv/wBTsIf2YV+an9Tn8nX/AAc0f8hz9jX/ALBPx5/9LPhDX6DwP8GZf4sJ+WIP5u8fv43C3/XrOf8A0rLD+WavvD+dwoA/bj/ggz4wm0D9rP4qeGzM4tPHf7Lfxa0hrbfiKS/0a68LeKLS4KbG3y29po2pxR/NHtju5zvOfLf5fiymp5fh521pY/Dyv2UlUptejco/cj9a8G8U6PEuY0L+7jOHsypOPRzpSw+IjLbVxjSqJbaSlr0f4j19QfkoUAf0M/8ABt1/yeJ8Zf8As2nX/wD1aPwrr47jX/kW4b/sOh/6YxB+3eBH/JUZr/2IK3/qxy4/tFr8zP6rP5Jf+DmP/keP2Rv+xU+MH/p3+H9foXA/8LMf+vmG/wDSax/NXj7/AL3wz/2DZp/6dwR/L3X3Z/PYUAFABQB/Tz/wbffs4wa344+NX7U+uWPmw+CdPs/hH4AuJCjwp4i8Swwa/wCOr6JDH5kWoaT4dh8NaZBOswVrDxfqsLROWV4/heNca40sLgIPWq3iKy68kLwpL0lNzk1benFn9BeBORKri824irQusJCOWYKTs0q+ISrYyaVrqdOgsPTTv8GKqKzvdf1meIdbsvDOga54k1JtmneH9H1PW799yJsstKsp7+6bfKyRptgt5DukdEXGXZVBI/PoRc5whH4pyjFesmkvxZ/SVetDD0a1eppChSqVpvRe5Tg5y1ei0i99D/LZ8V+JNU8ZeKPEni/W5jc614r1/WPEmr3DMzNPqmuajc6pfzMzlnYy3d1K5ZmLEtliTk1+8U4RpU4U4K0acIwiu0YRUUvuR/njia9TFYiviqz5quJrVa9WXepWnKpN666yk3qYFWYn6/f8ELviK/gL/go18KdMa4NtYfE3wx8Rvh1qTb0WORLnwlf+LtKt5d/LC48R+D9EhiRCHNy8BGVDK3znFVH22S4iVruhUo1o/Koqcn8oVJfK5+neEGOeD46y2nzcsMww+PwNR3STUsNPE04v/FXwtJJb8zR/fBX5Kf2Wf51P/BUv9nCL9l79uD41fD7SbA2HgzXtai+JXw/jBU248I/ECM67HY2QH7xbPw5rkuueE4BOBMRoBctMjpcTfsuQ436/leFrSd6sI+wrd/aUfcu/OcOSo7ae/wBNl/DniJkS4e4uzbBU4OGFrVVj8EtLfVsavbKEOvJQrOthlza/uL3kmpP8969g+JCgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAP6Lv+DbT/k6j46/9m/S/wDqxvBFfGca/wC4YT/sMX/pmqfuXgP/AMlHnH/Ylf8A6nYQ/swr81P6nP5Ov+Dmj/kOfsa/9gn48/8ApZ8Ia/QeB/gzL/FhPyxB/N3j9/G4W/69Zz/6Vlh/LNX3h/O4UAfbH/BP39pnwl+yV+0fpfxg8c6N4k17wzB4F+JXhK+07wlbaVd6483jPwXq2gaZPbw6zq2i2BgtdUu7K4vmfUIpVso5zBHPNshfy84wNTMcFLDUpQhN1aFRSqOShalVjOSbjGTu4ppab2vZH1nBWf4bhrPaeaYulXrYeODx+GnTw0acqzeKwlWjTcVVqUoWjUlCU7zT5FKybsn8T16h8mFAH9DP/Bt1/wAnifGX/s2nX/8A1aPwrr47jX/kW4b/ALDof+mMQft3gR/yVGa/9iCt/wCrHLj+0WvzM/qs/kl/4OY/+R4/ZG/7FT4wf+nf4f1+hcD/AMLMf+vmG/8ASax/NXj7/vfDP/YNmn/p3BH8vdfdn89hQAUAFAH+hZ/wR/8AgvH8Ev8Agnx+z/pc1p9l1v4geH7j4w+IXaMRy3V38TLp/EWiSzoFVhLbeDJvC+mESbpAunqGK4CJ+PcR4r61nGMkneNGaw0PJUFySt61VUl8z+2/DHKllPBOS03Hlq42hLNK7tZylj5OvRcl3jhXh6eutoL0Xvn7e3is+CP2Jf2svEqMEuLH9nr4tW9g7FQqapq3grWNH0p23EBwmpX9qxjBDSgeWhDuprjymn7XNMvh0eMw7f8AhjVjKX/kqfoezxlifqfCXEmITtKGSZlGDfSpVwlWlTfn+8nHTrt1P81ev20/gsKAPrX9gnxgfAf7bP7J/ilpzbW1h+0F8KLbUp1Kgx6NrHjPSNF1tvmBUj+yNRvQynbuUld8ZPmL5+bU/bZXmFO128HiHFf3o0pSj/5NFH0vBuK+p8W8N4i/LGGd5bGb7UquKpUav/lKpP12utz/AEqa/Ej+9D+WH/g5T+CsEmk/s4ftE2FlGt1aX/iP4M+KL9YiZri1vYJfG3ga1klVSBBYz2XxAkjSRx+91NjGpLSEfe8E4p82NwTejUMTTXRNNUqr9WnR/wDAT+dvHrKU6eRZ5CCUozr5ViJ296UZp4vBxb/lg4Y1pPrUdup/J/X6AfzeFABQAUAFABQAUAFABQAUAFABQAUAFABQAUAf0Xf8G2n/ACdR8df+zfpf/VjeCK+M41/3DCf9hi/9M1T9y8B/+Sjzj/sSv/1Owh/ZhX5qf1Ofydf8HNH/ACHP2Nf+wT8ef/Sz4Q1+g8D/AAZl/iwn5Yg/m7x+/jcLf9es5/8ASssP5Zq+8P53CgAoAKACgD+hn/g26/5PE+Mv/ZtOv/8Aq0fhXXx3Gv8AyLcN/wBh0P8A0xiD9u8CP+SozX/sQVv/AFY5cf2i1+Zn9Vn8kv8Awcx/8jx+yN/2Knxg/wDTv8P6/QuB/wCFmP8A18w3/pNY/mrx9/3vhn/sGzT/ANO4I/l7r7s/nsKACgDp/BPhTU/HnjPwj4H0VDJrHjPxPoHhTSY1TzGk1PxFqtppFgioXjDlrq8iUIZE3E43rnIzq1I0qVSrL4aVOdSX+GEXJ/gjowmGqYzFYbB0lerisRRw1Jb3qV6kaUFa6veUl1Xqj/UZ8LeG9K8HeGPDnhDQYPsuh+FdC0jw3o1r8n+jaVoen2+madB+7SOP9zZ2sMfyRony/KijCj8JqTlUnOpN3nUnKcn3lNuUn822f6F4ehTwuHoYajHlo4ajSoUo6e7TowjThHRJaRilokuyR+dn/BYrXW8Of8E1/wBqXUFcxm48MeDtC3KpYlfFPxR8DeGWTAVsCVdXMTNjCK5YsoBYezw3DnzvALtUqz/8F0Ks/wD20+H8UK3sOAuIp3tzYfC0e/8AvGYYTD2+ftbeV7n+elX7CfxIFAHWeAtfbwp468F+KVYo3hrxZ4c19XVzGyNo+sWeohlkGShU22Q4GVI3DpWdaHtKVWn/AD05w/8AAouP6nTgqzw2MwmITs8PiaFZO9rOlVhO9+nw7n+plX4Mf6IH5Qf8FsfhinxL/wCCc/xvkjgE+qfDqfwb8TtHJV2ED+GvFWmW2vT/ACBmBTwdq3iZVYqUDOPMKR75E+g4Yr+wzrC62jWVWhLz56cnBf8AgyMD848Wcv8Ar/A2btK9TAvC5hS30+r4mnGs9L7YWpiLdO9ldr+ACv10/i0KACgAoAKACgAoAKACgAoAKACgAoAKACgAoA/ou/4NtP8Ak6j46/8AZv0v/qxvBFfGca/7hhP+wxf+map+5eA//JR5x/2JX/6nYQ/swr81P6nP5Ov+Dmj/AJDn7Gv/AGCfjz/6WfCGv0Hgf4My/wAWE/LEH83eP38bhb/r1nP/AKVlh/LNX3h/O4UAFABQAUAf0M/8G3X/ACeJ8Zf+zadf/wDVo/CuvjuNf+Rbhv8AsOh/6YxB+3eBH/JUZr/2IK3/AKscuP7Ra/Mz+qz+SX/g5j/5Hj9kb/sVPjB/6d/h/X6FwP8Awsx/6+Yb/wBJrH81ePv+98M/9g2af+ncEfy9192fz2FABQB93/8ABMLwYPHn/BQT9krQmj81bT4zeGPF7JhWBX4evP4/YsHdFKKvhks4ySUB2pI2I28nPavscnzCe18NOn/4OtR/9vPsfD7C/XONeGqNr8ua4fFW30wPNjXu1/0D/wDAex/o31+MH90H47f8F39U/s//AIJu/Fa03hP7b8ZfCXSwpMYMnlfEPQta2KHBZiP7I8zEOJNsbMT5IlB+k4TjfOsO/wCWniJf+UZx/wDbup+X+MVTk4DzKN7e1xWW0+mtsdRq2V/+vV9NdO1z+Cqv1k/jYKACgD/Uv+H2pnWvAXgjWTjOreEPDWpnaVK5v9Gsrr5SpKkfveCpK46HFfg1aPLWqx/lqTj90mj/AEQwVT2uDwlX/n5hqFTT+/ShL9Tzn9qHwQnxL/Zr/aB+HrwG5/4Tb4K/FDwvFCocyNc634K1rT7RovLSWQTx3M8MkDRxySJMiMiOwCnfAVfYY7B1r29liqFT5Rqxb+Vk7+RwcQ4RY/Ic6wLjzfW8pzDDpLdyrYSrCNrJu6k01ZNppaH+YtX7kf5/BQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAH9F3/AAbaf8nUfHX/ALN+l/8AVjeCK+M41/3DCf8AYYv/AEzVP3LwH/5KPOP+xK//AFOwh/ZhX5qf1Ofydf8ABzR/yHP2Nf8AsE/Hn/0s+ENfoPA/wZl/iwn5Yg/m7x+/jcLf9es5/wDSssP5Zq+8P53CgAoAKACgD+hn/g26/wCTxPjL/wBm06//AOrR+FdfHca/8i3Df9h0P/TGIP27wI/5KjNf+xBW/wDVjlx/aLX5mf1WfyS/8HMf/I8fsjf9ip8YP/Tv8P6/QuB/4WY/9fMN/wCk1j+avH3/AHvhn/sGzT/07gj+Xuvuz+ewoAKAP2E/4IS+Hk1r/gpJ8I9QdA//AAinhP4s+IV3bMI83w58Q+GlfD8sVPiL5fL+dWIfhUY185xXPlyTEL/n5Uw8P/K0J/8Ath+oeD1D2vHmWTtf6thsyr9NL4Gvh76/9f8Apr16H97Vfkp/ZJ+Hv/BwXfiz/wCCfdxblwv9qfGr4a2AUozGQxweJdT2KwBEZA04yb2KqVjaPO51U/U8Hq+cJ/y4Wu/xhH/24/I/GufLwVKN/wCJmuAhtvZV6lvL+He77W6n8Mdfqh/IQUAFAH+nt+zheHUP2ePgNfmSOU33wY+F14ZYSpikNz4H0OYyRFCVMb79yFSVKkYJFfheNVsZi1tbE1191WaP9BMinz5Jk07p8+VZdO62fNhKLuvJ30PZZI45Y3ilRJIpEaOSORQ8ckbgq6OjAqyMpKsrAhgSCCDXMeq0mmmk01Zp6pp7prqmf5ZvjrQB4U8beMfC4BA8N+KvEOgANu3AaPq13pwDb2Zs/wCjc7mZs9WJya/eaU/aUqdT+enCf/gUVL9T/O/GUfq2LxWH/wCgfE16P/gqrKHn/KctWhzBQAUAFABQAUAFABQAUAFABQAUAFABQAUAf0Xf8G2n/J1Hx1/7N+l/9WN4Ir4zjX/cMJ/2GL/0zVP3LwH/AOSjzj/sSv8A9TsIf2YV+an9Tn8nX/BzR/yHP2Nf+wT8ef8A0s+ENfoPA/wZl/iwn5Yg/m7x+/jcLf8AXrOf/SssP5Zq+8P53Pc/2X9M03Wv2l/2d9H1jT7LVtI1b45/CTTNV0rU7SC/03U9Nv8Ax94ftb7T9QsbqOW2vLK8tpZba7tLmKSC4gkkhmjeN2U8uPlKOBxkotxlHCYiUZRbUoyVGbTTWqaeqa1T1R7HD1OnVz/I6VWEKlKpnGWU6lOpFTp1Kc8bQjOE4STjOE4txlGSakm000z0v/goD4d8P+Ev23P2qfDPhXQ9H8M+G9C+OfxE0zRPD/h/TLLRtE0fTrXxDex22n6VpOmwW1hp9lbRgRwWlpbwwQoAscaqAKwyic6mV4CdScpznhaMpTnJylKTgruUpNtt9W22zv41oUcNxbxHh8NRpYehRzfG06VChThSo0oRrSUYU6dNRhCEVooxiklsj5Br0T5gKAP6Gf8Ag26/5PE+Mv8A2bTr/wD6tH4V18dxr/yLcN/2HQ/9MYg/bvAj/kqM1/7EFb/1Y5cf2i1+Zn9Vn8kv/BzH/wAjx+yN/wBip8YP/Tv8P6/QuB/4WY/9fMN/6TWP5q8ff974Z/7Bs0/9O4I/l7r7s/nsKACgD93f+Dd2wW7/AG9fEFw2zOl/s8/EK/TcCSGk8WfDnTD5ZHAfZqLgk8eWXHUivlOMnbKYL+bGUV/5TrS/Q/YvA+ClxlXl/wA+8jx016vE4Gnp8pv5H9wNflp/W5+DX/BxPctB+wd4WiChhe/tG/D+2ckkFFTwX8TrzcuOpLWipg8bXY9QK+s4NV82qeWCrP8A8q0F+p+OeOMuXg3Dq3x57go+lsLmE7/+S2+Z/ENX6ifySFABQB/pp/sf3K3v7Jf7Lt4ilFu/2dfglcqjEFkWf4aeGZQrEcEqHwSOMjivw/MVbMMeu2NxS+6vM/v3hiXNw1w9LbmyPKZW7XwGHZ9F1xHuH+ZH+1rpw0j9qr9pnSQQRpf7QXxm04FWZ1IsviN4ktgVdlRmB8rhmRWYclVJwP3HLnzZfgZfzYPDP76MGfwDxLD2XEef0/8An3neaw7/AAY6vHfTt2R8+12HiBQAUAFABQAUAFABQAUAFABQAUAFABQAUAf0Xf8ABtp/ydR8df8As36X/wBWN4Ir4zjX/cMJ/wBhi/8ATNU/cvAf/ko84/7Er/8AU7CH9mFfmp/U5/PX/wAFzv2W9L/aO1X9mifUv2mf2Y/2ev8AhEdP+LUUMP7Q/wAR7jwFP4s/ty5+HLySeE44ND1j+04tF/sqNdadzb/ZH1XS1US/aCU+w4Vx8sFHHKOBx2M9pLD3eDoqqqfKq38S848rlze7vfll2PxLxf4dp57UyB1M/wCH8k+rQzJJZ3jpYN4n20sC28Mo0avtFS9mlVb5eV1Ke/Np+Bn/AA7C8L/9JKf+Caf/AIkRqP8A8xNfXf27U/6Emd/+Ecf/AJafjP8AxD7D/wDRe8Bf+Hup/wDMh7D+zz/wTf8ADnhf4/fA3xNB/wAFDP8Agnd4nn8O/GH4Z67D4a8NfH2+v/EfiGXSPGmiahHofh+xfwdCl7rerPbiw0q0aaJbm/uIIWljDlxzYzOp1MHioPJ85pqeGrwc54RKEOalJc837R2jG95O2iTZ6mR8CUMPnWUYhcb8D4h0M0y+ssPh85nOvXdLF0pqjRg8KlOrVceSnFtc05RV1e58Pf8ABSH/AJP4/a+/7OB+Jf8A6kt7XqZL/wAijLv+wOh/6Qj5Hjz/AJLPif8A7HWP/wDT8j4or1D5MKAP6Gf+Dbr/AJPE+Mv/AGbTr/8A6tH4V18dxr/yLcN/2HQ/9MYg/bvAj/kqM1/7EFb/ANWOXH9otfmZ/VZ/JL/wcx/8jx+yN/2Knxg/9O/w/r9C4H/hZj/18w3/AKTWP5q8ff8Ae+Gf+wbNP/TuCP5e6+7P57CgAoA/oD/4NwP+T4Pip/2an44/9W78Da+Q40/5FeH/AOxhS/8AUfFH7T4E/wDJXZj/ANk5i/8A1Z5Qf2t1+Yn9YH4I/wDBxd/yYn4J/wCzlvAP/qvvizX1vBn/ACNqv/YDW/8AT2HPxrxy/wCSPwn/AGP8F/6hZkfxH1+oH8lhQAUAf6Y/7F//ACZ3+yd/2bT8Cf8A1V3havxDM/8AkZZh/wBh2L/9P1D+++Ff+SX4b/7EGT/+q7Dn0vXCe8f5pX7dUccX7bv7Y8USJHFH+1T+0LHHHGoSOONPi34vVERFAVUVQFVVACgAAACv27Kv+RXlv/YBg/8A1Hpn8E8YJLi3ilJJJcR52kloklmeKskuiR8rV3nzgUAFABQAUAFABQAUAFABQAUAFABQAUAFAH9F3/Btp/ydR8df+zfpf/VjeCK+M41/3DCf9hi/9M1T9y8B/wDko84/7Er/APU7CH9mFfmp/U5/J1/wc0f8hz9jX/sE/Hn/ANLPhDX6DwP8GZf4sJ+WIP5u8fv43C3/AF6zn/0rLD+WavvD+dz6B/ZM/wCTqP2Z/wDs4H4Nf+rG8N1x5h/uGO/7A8T/AOmZnt8Nf8lHkH/Y6yr/ANTqB6t/wUh/5P4/a+/7OB+Jf/qS3tc+S/8AIoy7/sDof+kI9Hjz/ks+J/8AsdY//wBPyPiivUPkwoA/oZ/4Nuv+TxPjL/2bTr//AKtH4V18dxr/AMi3Df8AYdD/ANMYg/bvAj/kqM1/7EFb/wBWOXH9otfmZ/VZ/JL/AMHMf/I8fsjf9ip8YP8A07/D+v0Lgf8AhZj/ANfMN/6TWP5q8ff974Z/7Bs0/wDTuCP5e6+7P57CgAoA/fj/AINyLr7P+3N8RYtm/wC3fsveO7Xdu2+Vs+J/wZvd+Nrb8/ZPL25THmb9x2bG+R40V8qo+WPpP/yhiV+p+z+BcuXi/HK1+fh7GR9LZhlU7+fw26b36WP7Zq/MD+sj8Ef+Di7/AJMT8E/9nLeAf/VffFmvreDP+RtV/wCwGt/6ew5+NeOX/JH4T/sf4L/1CzI/iPr9QP5LCgAoA/0yf2MVZP2Pf2UEdSrL+zV8ClZWBVlZfhd4WBVgcEEEYIPIPBr8PzP/AJGWYf8AYbi//T9Q/vvhXThfhv8A7EOT/wDqvw59K1xHvH+Z9+21erqX7Zv7XOoqYyt/+078e71TC4kiK3XxV8VzgxSAkPGRJ8jgkMuGB5r9vytcuWZdHtgcIvuw9NH8DcWT9pxVxNU09/iDOZ6O697McS9H1WujPmKu4+fCgAoAKACgAoAKACgAoAKACgAoAKACgAoA/ou/4NtP+TqPjr/2b9L/AOrG8EV8Zxr/ALhhP+wxf+map+5eA/8AyUecf9iV/wDqdhD+zCvzU/qc/k6/4OaP+Q5+xr/2Cfjz/wClnwhr9B4H+DMv8WE/LEH83eP38bhb/r1nP/pWWH8s1feH87nWeAvGer/Dnx14L+IXh9bN9e8B+LPDnjPRE1GF7nT31fwvrFnremrfW8U1vJPZte2MIuYY7iB5YC6JNEzB1zrUo1qVWjO/JVpzpTs7PlqRcZWdnZ2bs7PXodOCxVXA4zCY6hyutg8TQxVFTTlB1cPVhWp88U4uUeeC5kpJtXSa3N74x/FXxP8AHL4q/EH4xeNI9Li8W/EvxZrXjPxFHolpNY6Qmr69ey316um2dxdXs9tZieVhBDLd3MkceFaaQjcYw2Hp4XD0cNS5vZ0KcaUOZ3lywVlzNJJu27svQ2zTMcRm+Y43NMWqaxOPxNXFV1Ri4UlVrSc5qnCUpuMbvROUml1Z5rW5wBQB/Qz/AMG3X/J4nxl/7Np1/wD9Wj8K6+O41/5FuG/7Dof+mMQft3gR/wAlRmv/AGIK3/qxy4/tFr8zP6rP5Jf+DmP/AJHj9kb/ALFT4wf+nf4f1+hcD/wsx/6+Yb/0msfzV4+/73wz/wBg2af+ncEfy9192fz2FABQB+3n/Bvxq66b/wAFB7CzJAOv/Br4l6QmcZLQ/wBga9hffbojHjnaG7Zr5fi+PNk7f8mJoS+/nh/7cfrfgrV9nxtCP/P7KsfSXy9hW/Kkz+6Kvyo/r4/Cn/g4btDcfsDaXMIUkFh8fvh1ds7BM24fw949sfOj3ncHZrxYCYsv5c7gjyjIR9Xwc7ZvJX3wlZevv0nb8L/I/H/G6PNwZTdk+TOsDK+nu3o4yF1fr71tNbN9Ln8O9fqR/IwUAFAH+nF+ypBLa/svfs3W067J7f4CfB6CZNytsli+Hnh2ORdyFkba6kblZlOMqSMGvw3MHfH41rZ4vEtfOtM/0A4cTjw9kMWrOOTZWmuzWBoJrTTfse+VyHsn+YB+0JrK+I/j78cPEKOJF174v/EvWVkXG2RdU8aa1fB1xHCMMJwwxDEMHiNPuj90wceTCYWH8mGoR/8AAaUV59u5/n1ndX2+dZvXTuq2aZhVT7+0xdWd9l37L0R5BXSeWFABQAUAFABQAUAFABQAUAFABQAUAFABQB/Rd/wbaf8AJ1Hx1/7N+l/9WN4Ir4zjX/cMJ/2GL/0zVP3LwH/5KPOP+xK//U7CH9mFfmp/U5/J1/wc0f8AIc/Y1/7BPx5/9LPhDX6DwP8ABmX+LCfliD+bvH7+Nwt/16zn/wBKyw/lmr7w/ncKACgAoAKAP6Gf+Dbr/k8T4y/9m06//wCrR+FdfHca/wDItw3/AGHQ/wDTGIP27wI/5KjNf+xBW/8AVjlx/aLX5mf1WfyS/wDBzH/yPH7I3/YqfGD/ANO/w/r9C4H/AIWY/wDXzDf+k1j+avH3/e+Gf+wbNP8A07gj+Xuvuz+ewoAKAP1G/wCCL/imPwn/AMFKv2arq4lMdprGo/EDwtOofYJZPEvwr8b6RpkTHow/tq502TYQdzRqBhyrL4PE1P2mSY5LeMaNReXJiKUpf+SqR+h+FWIWG49yGUnaNWeNw8tbXdfLsXSpr/wbKm7dbd9T/QYr8gP7WPxU/wCC/Oni9/4J3+J7khCdJ+Kfwv1BSzSKVMmr3WlZjCfK77dTYFZcRiMu4/epGK+n4Rds5pr+ahXX/kql/wC2n5R4zw5+B8RLT91mOXT3fWrKnpbd/vNnpa73SP4TK/Vj+PQoAKAP9Q34IaedI+C3wh0ookZ0z4X+ANPMce8JGbLwppNsUTzFR9imPau9VfAG5QcgfhOKlzYnEy/mr1pffUkz/QnKIeyyrLKdkvZ5fgoWWy5MNTjZXs7aaXR2Hi3xDaeEvCvibxXfsiWPhjw/rPiG9eQssa2mi6dc6lcs7KrMEWG2csVVmABIUnis6cHUqQprepOMF6ykor8WdWJrxw2GxGJnZQw9CrXm3ty0qcqkr21taL2P8sq7u7m/u7q+vJnuLu9uJru6nkwZJ7m4kaaeZyAAXkldnbAAyx4r95SSSSVkkkl2S0SP87pSlOUpzblKcnKUnu5Sd235tu5XpkhQAUAFABQAUAFABQAUAFABQAUAFABQAUAf0Xf8G2n/ACdR8df+zfpf/VjeCK+M41/3DCf9hi/9M1T9y8B/+Sjzj/sSv/1Owh/ZhX5qf1Ofydf8HNH/ACHP2Nf+wT8ef/Sz4Q1+g8D/AAZl/iwn5Yg/m7x+/jcLf9es5/8ASssP5Zq+8P53CgAoAKACgD+hn/g26/5PE+Mv/ZtOv/8Aq0fhXXx3Gv8AyLcN/wBh0P8A0xiD9u8CP+SozX/sQVv/AFY5cf2i1+Zn9Vn8kv8Awcx/8jx+yN/2Knxg/wDTv8P6/QuB/wCFmP8A18w3/pNY/mrx9/3vhn/sGzT/ANO4I/l7r7s/nsKACgD6R/Y58fRfC39rL9mr4h3M/wBmsPCPxz+FutatNvZAuiW3jPR/7cVmV4yEl0hr2J8tsKuRIrxllbizKj9Yy/HUUrupha8Yr+86UuT/AMmse9wtjVl3EuQY6UuWGGzjLqtV3t+5jiqXttbrR0udPpZ63Wh/pm1+Hn99n5N/8Fv9IbVf+CZ37QkqKHk0e8+EmroCxQhYvjN4As7hl+dEYpaX1w+19wZVYRoZvKx9BwtLlzzB/wB5YiP/AJbVmvxS/wCGPzbxcpOpwDnbWrpSyyrvbRZrgoy6paRm3Z/JXsfwC1+un8XhQBPbW095c29nbRma5up4ra3iUgNLPPIsUUaliFBeRlUFiBk8kDmk2km3okm2/JbjjFzlGMVeUmoxXdt2S+bZ/qjaTp0WkaVpmkwHMOl6fZadCcMMxWVtHbRnDvK4ykQOGkkYfxO5yx/BJS5pSk95Nyfzdz/RWlBUqdOmtqcIQXpCKit23su79WfHP/BR/wCIP/CsP2EP2r/FyzfZrhfgp408NafcgZa31bx3pzeBNHnjGQPNh1TxJZyQ7gyCVUMkciBo29PJaPt82y+na6+tUptd40pe1kv/AAGDufLcdY3+z+DuJMSnyyWU4uhCX8tTGU3g6Ul5qpXi1fS6V01of5vFftJ/CQUAFABQAUAFABQAUAFABQAUAFABQAUAFABQB/Rd/wAG2n/J1Hx1/wCzfpf/AFY3givjONf9wwn/AGGL/wBM1T9y8B/+Sjzj/sSv/wBTsIf2YV+an9Tn8nX/AAc0f8hz9jX/ALBPx5/9LPhDX6DwP8GZf4sJ+WIP5u8fv43C3/XrOf8A0rLD+WavvD+dwoAKACgAoA/oZ/4Nuv8Ak8T4y/8AZtOv/wDq0fhXXx3Gv/Itw3/YdD/0xiD9u8CP+SozX/sQVv8A1Y5cf2i1+Zn9Vn8kv/BzH/yPH7I3/YqfGD/07/D+v0Lgf+FmP/XzDf8ApNY/mrx9/wB74Z/7Bs0/9O4I/l7r7s/nsKAO18P/AA38feK/Cvjjxz4a8H+INc8HfDO30K7+IPifTdMubrRPBtt4n1J9H8OzeItRiRrbS01vVI5NP0v7VJGb27R4LcPIpAynXo06lKlOpCFSu5qjTlJKdVwjzTUI7y5Y6ytstWddDAY3E4bGYzD4WvWwuXxoyxuIp05SpYWOIqOlQdeaXLTVWonCnzNc0k0rtHFqzIysrFWUhlZSQysDkMpGCCCMgjkHkVqcm2q0aP8ATh/ZY+LEXx1/Zs+BPxhS4S5uPiJ8KfA/ijVWR45DDr+o+H7F/EdjK0X7v7Rp2vjUtPulTAS4tpUwNuB+G4/D/VMbi8Nayo4irTj5wU3yP0lDla8mf6AcO5ks4yHJ80UlJ47LcHiKmqdq1ShB14NrTmp1vaQl/eiz5+/4Km+HW8Uf8E8/2tNMSJ5TbfCTV/ERVMEhfB95p/i15TkEbIU0RppO4jjYgg4I7Mhn7POMvl3xEYf+DE6f48x4viJQeI4I4lppX5csq19O2FlDEt/JUrvyR/nPV+zH8MhQB7H+zr4YPjb9oL4FeDAnmHxd8Y/hj4YEe1m3nX/G2iaUE2p87bvte3anzHOF5xXNjanssHi6v/PvDV6n/gFKUv0PVyPD/W87yfCWv9azTL8PbXX22Lo07aa683TU/wBPuvws/wBBD8Gf+Dhr4tp4I/Yj0T4aW9wg1L41fFjwvo1xZmUI8vhfwRFd+OtUu1j3B5ktPEek+DIHXY0aG/jd3RxEsn1nB2H9rmkq7Xu4XD1JJ/8ATyralFfOEqr+XqfjnjdmawnCVHARa9pm2ZYelKN7N4fCKWMqStu+WvTwsXpZc6badk/4h6/UT+SQoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD+i7/g20/5Oo+Ov/Zv0v8A6sbwRXxnGv8AuGE/7DF/6Zqn7l4D/wDJR5x/2JX/AOp2EP7MK/NT+pz+Tr/g5o/5Dn7Gv/YJ+PP/AKWfCGv0Hgf4My/xYT8sQfzd4/fxuFv+vWc/+lZYfyzV94fzuFABQAUAFAH9DP8Awbdf8nifGX/s2nX/AP1aPwrr47jX/kW4b/sOh/6YxB+3eBH/ACVGa/8AYgrf+rHLj+0WvzM/qs/kl/4OY/8AkeP2Rv8AsVPjB/6d/h/X6FwP/CzH/r5hv/Sax/NXj7/vfDP/AGDZp/6dwR/L3X3Z/PYUAfrr+xrosyf8Ew/+CtXiKRMW9xY/se6LaSB/vTWnxj1e+1FGTGPkS+0tkfP8cgwOp+dzKS/t3h6HVPMpP0eGio/lI/TeFqTXh94lV2tJQ4YpRd+sc0qzmrek6dn6n5FV9EfmR/bZ/wAG9Hx9X4j/ALH3iL4L6jemfXv2fPHl/Y2Vs4G+LwH8R5r/AMXeHpDKTvmP/CUjx7aKrZ+y2lnZQKVhEMa/l/GOE9hmUMVFWhjKSbf/AE+o2pz/APKfsX5tt73P608Es6+v8L18qnO9bJMZOEIvdYPHOeKoO/X/AGj67H+7GEI7WR+xX7R/gwfEf9nn47/D0wm4/wCE6+DXxO8HrCoZnlfxJ4K1vR0RAkcz+YXvF8spFI4faUjdgFPzeCq+xxmErbeyxNCpf/BVjLy7dz9Qz3CfX8kzjA25vrmVZhhba3ft8JVpK1k3e8tLJu+yuf5hNfuh/n2FAH6Ef8EpfA0nxC/4KI/soaFHCJv7N+J9t45cN91I/hno2r/EV5iSCAYh4W3x5xmURqp3MtePxBV9jk2YTvbmoOl/4PlGjb5+0+4+28OMG8dxxw3RSv7PMI4x+SwFKrjr/L6vp52W5/orV+NH9xn8Uf8AwcQ/HmP4hftbeDPgppd+bnSPgF8PbdNWtg+YrTx78TGs/E+tKqgbCT4PtPh8HbczLMJ4WCNEwP6dwbhPY5dVxUlaWLrPlfejQvTj/wCVHW+Vmfyf44ZysbxLhMppz5qWS4JKpG+kcZmHJiKq/wDCWGCv1vdaWP5/a+vPxYKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA/ou/wCDbT/k6j46/wDZv0v/AKsbwRXxnGv+4YT/ALDF/wCmap+5eA//ACUecf8AYlf/AKnYQ/swr81P6nP5Ov8Ag5o/5Dn7Gv8A2Cfjz/6WfCGv0Hgf4My/xYT8sQfzd4/fxuFv+vWc/wDpWWH8s1feH87hQAUAFABQB/Qz/wAG3X/J4nxl/wCzadf/APVo/CuvjuNf+Rbhv+w6H/pjEH7d4Ef8lRmv/Ygrf+rHLj+0WvzM/qs/kl/4OY/+R4/ZG/7FT4wf+nf4f1+hcD/wsx/6+Yb/ANJrH81ePv8AvfDP/YNmn/p3BH8vdfdn89hQB/R18BfhRe+Bv+De79rzx/qdnJb3Pxl+JXh/xHpE8kIiN14T8LfFf4P+B9PZCcyTRp4k0LxgySsVjZZQsUQCtNcfF4vEKrxhl1GLusNQnCSvtUqYfE1X6e5On/Wi/dcmy2eD8E+JsbUi4yzXH0a9JtW5sNh8yyvBw82lXo4pp7a6Lq/5xa+0Pwo/Wv8A4IuftV237MX7avhCz8TaoNN+HHxwth8I/GM07RrZWGoa3eW83gTXrl5NsdtHp3i+LTtPvdQklii0/Q9b1q6mYxowr57ibL3jssqOEeathX9YpJbtRTVWC781NyaW7nGKR+l+FPEceH+K8NDEVPZ4DN4/2Zim7ckKlWUXg60m7KKp4pQhKbaUKNarJ6I/vzr8jP7OP8yL9q/4WS/BH9pr4+fCV7V7S38AfFvx54c0qJ1kUSaBZeI9Q/4Ry8iEoWQ2+oaA+m39q7gGS3uYn/izX7jl+I+tYHCYi93Ww9Kcv8bgudeqnzJ+aP4A4ky55TxBnWW8vJHBZnjaFNa60IV5+wkr68s6Ps5xvvGSPn6uw8U/f7/g3V+Fsni39srxx8S7m083TPhJ8HNbe3vCjMLXxT461jSfDmlRhgyrG914bj8a4ZhJuSB0CfMZI/keMsR7PLaVBP3sRiY3XenSjKcvun7L7z9p8DsueJ4pxmPlG9PLMrquMrfDiMZVpUKav0csOsX30TXmf2JfGn4s+E/gR8JviJ8Y/HV4ll4U+G3hHWvFusyGRY5biHSbOS4h0yz3BvN1PWLsW+k6VaojzXmpXtpawRyTTIjfm+Fw9TF4ijhqSvUr1I04+Tk7OT/uxV5SfSKbeiP6gzXMsNk+W47NMZJQw2Aw1XE1XezkqUXJU4d6lWXLTpxSblUnGKTbSP8AM3+MXxR8S/G74rfEb4v+MZUl8UfEvxn4i8a635WBbwXviHU7nUns7RVSNY7KwWdbKxiVESG0t4YkRVQKP2/DUIYXD0cNT/h0KUKUe7UIqN35u133bZ/AuaZjiM3zLHZpimniMfiq+Lq22U69SVRwjorQhdQgkklGKSWh5vW5wBQAUAFABQAUAFABQAUAFABQAUAFABQAUAFAH9F3/Btp/wAnUfHX/s36X/1Y3givjONf9wwn/YYv/TNU/cvAf/ko84/7Er/9TsIf2YV+an9Tn8nX/BzR/wAhz9jX/sE/Hn/0s+ENfoPA/wAGZf4sJ+WIP5u8fv43C3/XrOf/AErLD+WavvD+dwoAKACgAoA/oZ/4Nuv+TxPjL/2bTr//AKtH4V18dxr/AMi3Df8AYdD/ANMYg/bvAj/kqM1/7EFb/wBWOXH9otfmZ/VZ/JL/AMHMf/I8fsjf9ip8YP8A07/D+v0Lgf8AhZj/ANfMN/6TWP5q8ff974Z/7Bs0/wDTuCP5e6+7P57PsD9iv9i34vftu/GLRPhn8NtGv4dAjv7Cb4i/ESXT5pvDPw68LSXA+3axq12TBazapJax3I8O+HBdw6h4i1GNbW2MNrHfX9j52Z5nhsrw069eSc7NUaN0p1qltIxWrUb2552ahHV3dk/p+FOFMz4tzSll+ApTVFTg8djnBvD4HDt+/VqS0i6jipewocynXmuWNoqc4f2Lf8FQfhX4O+B//BH34yfCH4fad/Zfgz4c+Afg/wCE/D1mz+bP9g0j4u/DW2F1fXBAa71PUJVl1DVL6XM19qN1dXk5aad2P5vkWIqYriPDYms+arWrYmpN9Lyw9d2S6RW0VsopJbH9ReIOXYXKPDDNMswUPZ4TA4LLMNQje8uSlmeAjzTl9qpN3nUm9Z1JSk9Wz+Cmv1k/jYVWZGVlYqykMrKSGVgchlIwQQRkEcg8igNtVo0f3jf8EcP+Chul/tgfAvT/AIZ+P9fgb9ov4NaLYaN4rtr+6VdT+IHhGxSLT9C+JNis8r3Gp3MkK22l+OJYjI9p4lCandx2dp4m0iFvyfiTJpZdinXowf1LEycqbS92jUes6DsrRV7ypd4e6ruEj+xvC3jenxPk9PL8bWX9uZVShSxMZy/eY3DQShRx8LvmqSa5aeLau44j95JRjiKSPw8/4OFv2aL74b/tT+Hv2htH0ox+Dfj/AOFdPg1jUYEb7PB8T/AVlbeH9WtLhIwYbQ6n4Oh8IanZu5ik1a9i8RzrHJLZXk7/AFPB2OVfATwcpfvcHUbjF7uhWbnFrq+Wo6kX/KnBdUj8j8bcgngOIqGd0qdsLnWGgqtRJ8qzDBQjQqxkl7sPaYVYWpDVOrNV5WbhOT/n7r68/FT+43/ggP8Asy3vwV/Y9vfi14jsJbDxZ+0p4jg8ZW8NxG8FxF8NvDMF3ovw9FxDLCj/APE0lu/FXi3T7lJZbe70LxTo88QjYy7/AMs4uxyxWZLDwd6eBg6Ttqvb1GpVrO/2UqdOS3U6ckz+uvBjIJ5TwvPMq8HDE59XWKipJqSwGHUqWBuml/EcsRiYSTanRxFJq2t/y7/4L0f8FD9P+LHiiD9jb4P64l94C+HWu/2n8adf0+aT7N4m+JOkTXFrp/ge3kVI0u9E8BP517rEiz3Vlqfi+5toRBbz+CoLu/8Ae4TyZ4em8yxMLVq0OXCwa1hQkk3VfaVbaOicaabu1VaX574yccQzLELhbK6yngsDW9pmtaDfLiMfScowwkXZKVHBu86rTlCpipRVovCKU/5tq+1PwcKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA+vP2aPjJ+yj8L9K8VWn7Rn7Gf8Aw1Jqerahptz4Y1f/AIaI+JHwR/4RGwtra5i1DTvsHgXS9Qt9e/tO4lt7n7ZqDRzWX2byYFZJnI87HYbMK8qbwWZ/UIxUlUj9ToYr2jbVpXqyThyq6stHe72Pp8gzThvL6eJjnvCv+sVSrOnLD1f7cx+U/VoRjJTp8mDpzjW9pJxlzTs4ctlo2fpP+zZ/wVp/ZD/ZD8Va742/Z3/4Jk/8K98T+JvD58La3qf/AA2f8TvFn23QW1Gx1Y2P2Lxx8N/Eun22dQ02yuPtVpaQXn7nyhceRJLHJ4mN4ezHMacKWMzz21OE/aRj/ZlCnafK43vSrwb0k1ZtrXa595kPiVwxwzia2LyPw/8AqOIxFD6vWqf61Zhieej7SFXk5MXgMRCPv04S5oxjPS3NZtP7M/4ibv8AqyT/AM2S/wDxB15v+o3/AFNP/LL/AO+z6r/iP/8A1SX/AJnv/wADHyd+01/wWL/Zb/bFn8G3P7R3/BNX/hYs/wAPoteg8IP/AMNj/ETwj/ZEXid9Ik1xdvgT4YeGEv8A7c+g6Sd2pretbfZMWZtxPcib0MDw3j8tVVYLO/Yqs4Op/wAJtGpzez5uT+LXqWtzy+G1763srfNcQeKPDvFDwss94C+vPBKssK/9acdhvZLEOk6y/wBjy/DqfO6NP+Jz8vL7vLeV/wA3f2jPjr+xp8SvAlloH7P37CH/AAzP43g8TWGqXnxA/wCGoPip8Zft/hy30/Vra+8Lf8Ip410iw0i1/tG/vNK1L+24pjfWn9j/AGOGNodQuWX2sFhMzoVXPGZt9epODiqP1DD4a024tVPaUpOT5UpR5bWfNd6pHwme5xwrj8HCjkvB39gYtYiFSWN/1hzHNeehGFWM8P8AVsXShSjzzlTqe2T54+y5UrTkfEdeofJBQAUAfWP7Mvxe/Zb+FsHjKP8AaO/Y/wD+Gp59bl0F/CFx/wANA/ET4H/8IRFp6auuuQ+V4E0zUE8S/wDCQveaS/mamYm0n+xttmHGo3O3z8dhsfXdL6lmX1BRU/aL6nRxXtb8vI/3slyclpfD8XNr8KPpOH8z4dy5YpZ7wx/rE6rovCy/trHZR9UUFV9srYOnNV/budN3qW9n7L3b88rfpZ+zd/wVi/Y+/ZF8Y6z4/wD2ef8AgmN/wr7xb4g8M3Hg/V9W/wCG0fih4s+1+HLrVNK1qfTvsHjj4c+JdMt9+p6Jpdz9rtbKG+X7L5KXS281xFL4mN4fzLMacaOMzz21OE1UjH+zKFO01GUVK9KtCT92UlZtrW9rpH32Q+JPC/DOKq43JPD/AOpYmvh5YWrU/wBa8wxPNQlUp1XDkxeBxFON6lKnLmjBT92ylyuSf2l/xE3f9WSf+bJf/iDrzP8AUb/qaf8All/99n1f/Ef/APqkv/M9/wDgY+SP2mf+Cwv7K37Yl94R1L9o3/gml/wsW98CWmsWPhSb/hsn4jeEf7Ktdfm0+fVovL8C/DLwxFffa5dKsH36lHeSQeRttnhWWYSehgeHMflqqRwWd+xVVxdRf2bRqczgmo/xa9S1uZ/Da99b6HzOf+KHDvFE8NUz3gH69PBxqwwz/wBacdhvZxrOEqqtg8vw6nzOnB3qKTVvdau7/POg/trf8E0PDl9HqOn/APBIPRLi4idHWPXv21PjB4qsSU3YEml+J/A2saZMh3HfHNaPHJhd6tsTb2TyzPJrlfEckn/JlmGpv/wKnVjJfJniUeLOAaE1OHhjSlJNO1bivNMRDTvTxGDq02tdU4tPrsj9DPAn/BxZ4C+F3huy8HfDX/gnh4U+H/hPTsmx8NeDPjlpXhnQrVmREeSHStG/Z5s7JJZFijEswh82XYpldyAa8erwZWrzdSvnNStUlvOrhZTm/WUsY3bsr6H2+D8csFl1CGFwHA+GwWGp/Bh8Lm9PD0Y6JNqnSySME3ZXdru2rZ4f+2f/AMF3f+GvP2Zvih+zr/wyx/wr3/hZNp4atf8AhMf+F4f8JZ/Yv/CPeNPDni/f/wAI/wD8Kg8Nf2j9s/4R/wDs/b/blj9n+1/a90/2f7NN1ZZwp/Z2OoY36/7b2Dm/Z/VfZ83PSnT+P6zPltz3+B3tbS915HFXjF/rNkGYZH/q79S+vxoR+tf2v9Z9l7DFUMTf2H9mYfn5vY8n8aHLzc2vLyv+fGvsD8TCgD0P4UfFf4hfA/4heFvip8K/FOp+DfHng3U4tV0DX9KlCT206Bo5re4hkWS21DTNQtpJrDVtJv4bnTtV065udP1C2uLO4mhfHEYejiqNTD4inGrSqx5Zwls10ae6knZxkmpRkk000md2W5ljsox2HzHLsRUwuMwtRVKNam7OLWjjJO8Z05xbhUpzUoVISlCcZRk0/wCkKD/gr7+yB+3X+z7dfs5/8FG/AHiT4faneixu1+K3wv0mXXtC0zxNo6J/Znjjw9ZRR6z4t8G+Id019DPp1rovi7SLvTbvUdKvJ5dK1W600fFPhzMcqxixuS1oVoq6+r15ck5U5fFSm3y06sNE1JypyUlGSSlFSP3ZeJ3DHGGSSyLjrBV8FUnyS/tLL6TrUaeIpJezxdCCVXE4WvrNOEaWJpSpynTm3TqSpnxB4A/Zo/4JB/D7xtD44+Kv/BRHWfjR8ONL1Ianpfwn8F/s7fFnwh4p8W2ttcLcW2ieJvEtxaajHpdtMsT2OqrbWfh241K1Zrmw13w7PPAkfq1sdxHWpOlh8mjhq0o8ssRVxmHqU6bas5QgnHma3jdzUXo4TSZ8lgsg8McFi1jMx44q5rgKc/aU8twmR5lhcTiYxfNGjiK8o1FTi7OFTlhQdSL5oVqDaS+if23f+C8niH4heCrj4HfsVeD9X+Bvw3GkReGJfiLqhsNO+Ikvhq1tI9Ni0PwRoeh3F7pPw505LGI2cOq2+rax4i+wPbnSm8I3tszScWV8Jwo1ViszqRxVfmdT2Mbujzt8zlVnNKVaV3flcYwvfm9ome3xb4x18dhJZRwphauUYD2Sw7x1Tkhjnh4xVNUcJRoynSwNPkXKqkatWvycvs3hpx1/nRZmdmd2LMxLMzEszMxyWYnJJJOSTyTya+zPw3cSgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAA/9k=`; diff --git a/dummy/parties.json b/dummy/parties.json new file mode 100644 index 00000000..6871abf4 --- /dev/null +++ b/dummy/parties.json @@ -0,0 +1 @@ +[{"name": "Roy Rolston", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "roy-rolston@partiesunited.co", "phone": "+91 8364-764417", "address": null, "gstType": "Registered Regular", "gstin": "27YQPCH7868M1Z4"}, {"name": "Aloysius Albuquerque", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "aloysius.albuquerque-418@gmail.com", "phone": "+91 9624-847744", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "James", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Mary", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "mary-654@partiesunited.co", "phone": "+91 6578-469775", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Robert", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "robert-153@gmail.com", "phone": "+91 8187-238152", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Patricia", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "patricia_477@partiesunited.co", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "John", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "john_629@redis.rs", "phone": "+91 9912-497940", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jennifer", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jennifer_241@yahoo.co.in", "phone": "+91 7525-888566", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Michael", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "michael_330@redis.rs", "phone": "+91 6392-754783", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Linda", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "linda.739@redis.rs", "phone": "+91 7645-479546", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "William", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "william_187@gmail.com", "phone": "+91 6040-716971", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Elizabeth", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "elizabeth-739@redis.rs", "phone": "+91 9086-927694", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "David", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "david.231@redis.rs", "phone": "+91 8933-614178", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Barbara", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "barbara-287@yahoo.co.in", "phone": "+91 8884-319024", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Richard", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "richard-971@redis.rs", "phone": "+91 8618-127941", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Susan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "susan-915@redis.rs", "phone": "+91 7251-099321", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Joseph", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "joseph-107@partiesunited.co", "phone": "+91 8663-691738", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jessica", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jessica.990@gmail.com", "phone": "+91 6998-742236", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Thomas", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "thomas.926@generic_indian.in", "phone": "+91 7084-655002", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Sarah", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "sarah@generic_indian.in", "phone": "+91 7550-184329", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Charles", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "charles.73@generic_indian.in", "phone": "+91 9789-506872", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Karen", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "karen.117@redis.rs", "phone": "+91 9267-675391", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Christopher", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 7327-130379", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Nancy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "nancy-257@generic_indian.in", "phone": "+91 8224-410113", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Daniel", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "daniel_417@partiesunited.co", "phone": "+91 7927-461249", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Lisa", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "lisa-919@gmail.com", "phone": "+91 8757-609167", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Matthew", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "matthew_656@gmail.com", "phone": "+91 7885-004818", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Betty", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "betty-750@partiesunited.co", "phone": "+91 6023-939214", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Anthony", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "anthony.855@generic_indian.in", "phone": "+91 7786-783597", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Margaret", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "margaret.476@redis.rs", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Mark", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "mark_883@yahoo.co.in", "phone": "+91 6085-686899", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Sandra", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8370-381962", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Donald", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "donald.588@redis.rs", "phone": "+91 9391-684888", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ashley", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "ashley_875@partiesunited.co", "phone": "+91 8484-066804", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Steven", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "steven.621@gmail.com", "phone": "+91 9508-736407", "address": null, "gstType": "Registered Regular", "gstin": "22YVIPP4362P1Z0"}, {"name": "Kimberly", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 6336-613829", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Paul", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "paul_94@gmail.com", "phone": "+91 9636-016561", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Emily", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "emily.9@partiesunited.co", "phone": "+91 9446-701003", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Andrew", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 6932-393648", "address": null, "gstType": "Registered Regular", "gstin": "30VGGPM2265X1Z7"}, {"name": "Donna", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "donna_761@generic_indian.in", "phone": "+91 8500-322971", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Joshua", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "joshua_519@partiesunited.co", "phone": "+91 9554-755939", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Michelle", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "michelle_12@gmail.com", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kenneth", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "kenneth-395@partiesunited.co", "phone": "+91 9464-655370", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Dorothy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "dorothy-735@partiesunited.co", "phone": "+91 7618-732184", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kevin", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "kevin.407@gmail.com", "phone": "+91 6609-978693", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Carol", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "carol_598@yahoo.co.in", "phone": "+91 6810-371783", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Brian", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "brian-175@yahoo.co.in", "phone": "+91 9108-370654", "address": null, "gstType": "Registered Regular", "gstin": "32WVYPD4263C1Z6"}, {"name": "Amanda", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 7132-399853", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "George", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "george.582@yahoo.co.in", "phone": "+91 9174-381459", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Melissa", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "melissa.138@partiesunited.co", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Edward", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "edward@yahoo.co.in", "phone": "+91 7864-241916", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Deborah", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8420-572864", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ronald", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Stephanie", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "stephanie-760@yahoo.co.in", "phone": "+91 7958-877954", "address": null, "gstType": "Registered Regular", "gstin": "27GVTPF2529Z1Z0"}, {"name": "Timothy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "timothy.870@gmail.com", "phone": "+91 7042-295454", "address": null, "gstType": "Registered Regular", "gstin": "22NYRPJ7193Q1Z3"}, {"name": "Rebecca", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "rebecca-154@generic_indian.in", "phone": "+91 9179-230036", "address": null, "gstType": "Registered Regular", "gstin": "32ZCJPN2356I1Z3"}, {"name": "Jason", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jason-903@partiesunited.co", "phone": "+91 8332-016355", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Sharon", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "sharon.389@yahoo.co.in", "phone": "+91 8857-354880", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jeffrey", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jeffrey_618@gmail.com", "phone": "+91 7636-744410", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Laura", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "laura.821@generic_indian.in", "phone": "+91 6640-756477", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ryan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "ryan-786@yahoo.co.in", "phone": "+91 7598-295525", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Cynthia", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "cynthia.400@generic_indian.in", "phone": "+91 7518-806673", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jacob", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jacob_155@redis.rs", "phone": "+91 7607-980767", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kathleen", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "kathleen.110@yahoo.co.in", "phone": "+91 9288-658480", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Gary", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 9893-841572", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Amy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "amy.309@yahoo.co.in", "phone": "+91 8738-127052", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Nicholas", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "nicholas_204@redis.rs", "phone": "+91 7216-717493", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Shirley", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "shirley.761@redis.rs", "phone": "+91 6820-763351", "address": null, "gstType": "Registered Regular", "gstin": "07SSCCK4795S1Z10"}, {"name": "Eric", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "eric@generic_indian.in", "phone": "+91 8658-962487", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Angela", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 9544-644681", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jonathan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jonathan.581@partiesunited.co", "phone": "+91 8196-534803", "address": null, "gstType": "Registered Regular", "gstin": "07JNQPI1074A1Z2"}, {"name": "Helen", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "helen_972@generic_indian.in", "phone": "+91 7132-446467", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Stephen", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "stephen_100@yahoo.co.in", "phone": "+91 8066-679022", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Anna", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "anna_414@yahoo.co.in", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Larry", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "larry.516@yahoo.co.in", "phone": null, "address": null, "gstType": "Registered Regular", "gstin": "22BRXPB6438V1Z3"}, {"name": "Brenda", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "brenda.815@partiesunited.co", "phone": "+91 7423-101842", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Justin", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "justin.401@redis.rs", "phone": "+91 8866-453732", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Pamela", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "pamela@partiesunited.co", "phone": "+91 7563-921607", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Scott", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "scott.883@partiesunited.co", "phone": "+91 9988-884200", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Nicole", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "nicole@yahoo.co.in", "phone": "+91 7772-518773", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Brandon", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "brandon@generic_indian.in", "phone": "+91 8947-357902", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Emma", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "emma@generic_indian.in", "phone": "+91 8065-422177", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Benjamin", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "benjamin_861@partiesunited.co", "phone": "+91 8785-964341", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Samantha", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "samantha-387@gmail.com", "phone": "+91 6411-733274", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Samuel", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "samuel-67@partiesunited.co", "phone": "+91 8279-383554", "address": null, "gstType": "Registered Regular", "gstin": "30GNLPE7564C1Z0"}, {"name": "Katherine", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "katherine.329@generic_indian.in", "phone": "+91 7806-866367", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Gregory", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "gregory_137@redis.rs", "phone": "+91 7086-527568", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Christine", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "christine@redis.rs", "phone": "+91 9322-241005", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Frank", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 9610-590035", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Debra", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "debra-43@partiesunited.co", "phone": "+91 9320-275225", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Alexander", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "alexander-745@gmail.com", "phone": "+91 6806-881315", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Rachel", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "rachel.9@partiesunited.co", "phone": "+91 8891-016921", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Raymond", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "raymond.674@gmail.com", "phone": "+91 6722-485384", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Catherine", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "catherine@generic_indian.in", "phone": "+91 6087-083904", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Patrick", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "patrick-954@generic_indian.in", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Carolyn", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "carolyn_427@generic_indian.in", "phone": "+91 6559-056027", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jack", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jack.52@generic_indian.in", "phone": "+91 9271-276848", "address": null, "gstType": "Registered Regular", "gstin": "27QTMPP9445O1Z2"}, {"name": "Janet", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "janet-238@generic_indian.in", "phone": "+91 9480-057399", "address": null, "gstType": "Registered Regular", "gstin": "30YVEPM2956X1Z4"}, {"name": "Dennis", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "dennis-538@yahoo.co.in", "phone": "+91 9056-153204", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ruth", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "ruth_952@yahoo.co.in", "phone": "+91 9629-547895", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jerry", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jerry_234@generic_indian.in", "phone": null, "address": null, "gstType": "Registered Regular", "gstin": "27CZCPT5025J1Z0"}, {"name": "Maria", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "maria-332@gmail.com", "phone": "+91 8189-540178", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Tyler", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "tyler.435@partiesunited.co", "phone": "+91 7429-650519", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Heather", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "heather@yahoo.co.in", "phone": "+91 8581-500728", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Aaron", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "aaron-332@gmail.com", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Diane", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "diane@gmail.com", "phone": "+91 7137-826433", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jose", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jose_905@yahoo.co.in", "phone": "+91 6231-344468", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Virginia", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "virginia_959@yahoo.co.in", "phone": "+91 6642-811609", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Adam", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "adam-747@yahoo.co.in", "phone": "+91 9680-421390", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Julie", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "julie_316@redis.rs", "phone": "+91 7135-908088", "address": null, "gstType": "Registered Regular", "gstin": "27JTJPD8067A1Z4"}, {"name": "Henry", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "henry-115@gmail.com", "phone": "+91 9387-229109", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Joyce", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "joyce_116@partiesunited.co", "phone": "+91 6797-685407", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Nathan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "nathan.237@partiesunited.co", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Victoria", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "victoria-362@generic_indian.in", "phone": "+91 8697-929638", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Douglas", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "douglas_940@yahoo.co.in", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Olivia", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "olivia.438@gmail.com", "phone": "+91 9925-579281", "address": null, "gstType": "Registered Regular", "gstin": "07HJRPQ5913I1Z6"}, {"name": "Zachary", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 7752-067279", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kelly", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "kelly-569@generic_indian.in", "phone": "+91 7858-074356", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Peter", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "peter_295@partiesunited.co", "phone": "+91 8471-579894", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kyle", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "kyle_753@gmail.com", "phone": "+91 8638-076686", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Lauren", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "lauren_876@generic_indian.in", "phone": "+91 6701-905380", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Walter", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "walter-392@gmail.com", "phone": "+91 8196-877407", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Joan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8678-760803", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ethan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "ethan-879@redis.rs", "phone": "+91 7878-450479", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Evelyn", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "evelyn@generic_indian.in", "phone": "+91 9856-258902", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jeremy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jeremy_290@redis.rs", "phone": "+91 6383-093707", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Judith", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "judith@partiesunited.co", "phone": "+91 6687-472462", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Harold", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8811-307098", "address": null, "gstType": "Registered Regular", "gstin": "32AZTPA5997N1Z3"}, {"name": "Megan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "megan_864@redis.rs", "phone": "+91 9622-798026", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Keith", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 9827-357442", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Cheryl", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "cheryl.704@gmail.com", "phone": "+91 9470-151094", "address": null, "gstType": "Registered Regular", "gstin": "32EHNPM9327R1Z10"}, {"name": "Christian", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "christian.307@redis.rs", "phone": "+91 7820-659075", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Andrea", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "andrea-66@yahoo.co.in", "phone": "+91 8865-015945", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Roger", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "roger_398@yahoo.co.in", "phone": "+91 9010-018524", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Hannah", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "hannah-742@partiesunited.co", "phone": "+91 8839-110499", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Noah", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "noah-65@generic_indian.in", "phone": "+91 8386-843599", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Martha", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "martha.441@generic_indian.in", "phone": "+91 7881-002204", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Gerald", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8448-134403", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jacqueline", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jacqueline-981@generic_indian.in", "phone": "+91 6343-646106", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Carl", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "carl-878@generic_indian.in", "phone": "+91 7642-940183", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Frances", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "frances_178@generic_indian.in", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Terry", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "terry-434@yahoo.co.in", "phone": "+91 7919-057462", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Gloria", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "gloria-239@yahoo.co.in", "phone": "+91 6633-860264", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Sean", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "sean@yahoo.co.in", "phone": "+91 8597-778037", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ann", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "ann_30@gmail.com", "phone": "+91 7553-381240", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Austin", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "austin-910@partiesunited.co", "phone": "+91 8179-903611", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Teresa", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "teresa.461@yahoo.co.in", "phone": "+91 8654-794198", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Arthur", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "arthur.261@gmail.com", "phone": "+91 8591-727345", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kathryn", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 7553-353763", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Lawrence", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "lawrence_504@redis.rs", "phone": "+91 7800-930823", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Sara", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "sara-874@redis.rs", "phone": "+91 6550-401548", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jesse", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8391-197233", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Janice", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "janice-3@generic_indian.in", "phone": "+91 7838-829539", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Dylan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "dylan_17@partiesunited.co", "phone": "+91 8490-297413", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jean", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jean_67@partiesunited.co", "phone": "+91 6536-623838", "address": null, "gstType": "Registered Regular", "gstin": "30KKJPQ1372G1Z6"}, {"name": "Bryan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "bryan-34@gmail.com", "phone": "+91 9573-313194", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Alice", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "alice.708@redis.rs", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Joe", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "joe.232@generic_indian.in", "phone": "+91 6815-438967", "address": null, "gstType": "Registered Regular", "gstin": "22LGOPA3222S1Z0"}, {"name": "Madison", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "madison_16@partiesunited.co", "phone": "+91 6323-819289", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Jordan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "jordan_329@gmail.com", "phone": "+91 9958-214474", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Doris", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8265-604921", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Billy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "billy.876@partiesunited.co", "phone": "+91 8252-440697", "address": null, "gstType": "Registered Regular", "gstin": "32WTVPO1637Q1Z2"}, {"name": "Abigail", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "abigail-445@generic_indian.in", "phone": "+91 9110-758387", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Bruce", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "bruce.639@generic_indian.in", "phone": "+91 7279-246307", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Julia", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "julia.950@partiesunited.co", "phone": "+91 8121-831374", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Albert", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "albert@yahoo.co.in", "phone": "+91 9380-129478", "address": null, "gstType": "Registered Regular", "gstin": "27LJNPL5438V1Z3"}, {"name": "Judy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "judy-622@redis.rs", "phone": "+91 8288-615356", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Willie", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "willie.482@gmail.com", "phone": "+91 9463-671396", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Grace", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "grace-113@redis.rs", "phone": "+91 7939-158183", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Gabriel", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "gabriel_997@yahoo.co.in", "phone": "+91 8622-240964", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Denise", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "denise.960@redis.rs", "phone": "+91 9377-118903", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Logan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Amber", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "amber-307@partiesunited.co", "phone": "+91 9029-427995", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Alan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "alan.844@yahoo.co.in", "phone": "+91 7506-672233", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Marilyn", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "marilyn@partiesunited.co", "phone": "+91 8471-047686", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Juan", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 7977-135896", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Beverly", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "beverly-993@gmail.com", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Wayne", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "wayne.497@redis.rs", "phone": "+91 9886-432030", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Danielle", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "danielle_813@yahoo.co.in", "phone": "+91 8980-756924", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Roy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "roy.350@gmail.com", "phone": "+91 9930-062360", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Theresa", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "theresa.538@partiesunited.co", "phone": "+91 7079-462390", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Ralph", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "ralph-611@gmail.com", "phone": "+91 6564-488033", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Sophia", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8603-039297", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Randy", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "randy.493@yahoo.co.in", "phone": "+91 6692-244741", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Marie", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "marie.328@partiesunited.co", "phone": "+91 9089-500095", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Eugene", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "eugene-126@redis.rs", "phone": null, "address": null, "gstType": "Registered Regular", "gstin": "27ISQPL0662M1Z9"}, {"name": "Diana", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 8582-063412", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Vincent", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "vincent-551@redis.rs", "phone": "+91 7307-403536", "address": null, "gstType": "Registered Regular", "gstin": "32POPPV9927R1Z9"}, {"name": "Brittany", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "brittany_431@partiesunited.co", "phone": "+91 7124-128260", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Russell", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": null, "phone": "+91 6315-053860", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Natalie", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "natalie-655@generic_indian.in", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Elijah", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "elijah-660@gmail.com", "phone": "+91 7596-290047", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Isabella", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "isabella@yahoo.co.in", "phone": "+91 7294-796072", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Louis", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "louis-336@gmail.com", "phone": "+91 9433-004577", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Charlotte", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "charlotte-886@gmail.com", "phone": "+91 6655-639480", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Bobby", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "bobby_737@yahoo.co.in", "phone": "+91 8534-723705", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Rose", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "rose-274@partiesunited.co", "phone": "+91 9337-991300", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Philip", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "philip.930@gmail.com", "phone": "+91 9925-686539", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Alexis", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "alexis_850@yahoo.co.in", "phone": null, "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Johnny", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "johnny_204@generic_indian.in", "phone": "+91 7187-876740", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Kayla", "role": "Customer", "defaultAccount": "Debtors", "currency": "INR", "email": "kayla-287@generic_indian.in", "phone": "+91 9985-769077", "address": null, "gstType": "Consumer", "gstin": null}, {"name": "Le Socials", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "le-socials_172@yahoo.co.in", "phone": "+91 9879-452232", "address": null, "gstType": "Registered Regular", "gstin": "07RAQCB3836J1Z8"}, {"name": "Janky Office Spaces", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "janky.office.spaces-417@yahoo.co.in", "phone": "+91 7308-863851", "address": null, "gstType": "Registered Regular", "gstin": "32UGFPV2343M1Z4"}, {"name": "Josf\u00e9\u00f1a's 611s", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "josf\u00e9\u00f1a's_611s@redis.rs", "phone": "+91 6085-833491", "address": null, "gstType": "Registered Regular", "gstin": "30GHJPM5774L1Z9"}, {"name": "Lankness Feet Fomenters", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "lankness_feet_fomenters_222@redis.rs", "phone": "+91 7985-215055", "address": null, "gstType": "Registered Regular", "gstin": "30SHWCG9396V1Z6"}, {"name": "The Overclothes Company", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "the-overclothes-company.105@yahoo.co.in", "phone": "+91 7245-972879", "address": null, "gstType": "Registered Regular", "gstin": "07ULPPU8278Q1Z6"}, {"name": "Adani Electricity Mumbai Limited", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "adani-electricity-mumbai-limited-903@yahoo.co.in", "phone": "+91 9955-187016", "address": null, "gstType": "Registered Regular", "gstin": "22KTNCA0958Q1Z4"}, {"name": "Only Fulls", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "only.fulls_465@gmail.com", "phone": "+91 9291-250658", "address": null, "gstType": "Registered Regular", "gstin": "07HWIPM5574P1Z10"}, {"name": "Just Epaulettes", "role": "Supplier", "defaultAccount": "Creditors", "currency": "INR", "email": "just_epaulettes-901@partiesunited.co", "phone": "+91 8233-194054", "address": null, "gstType": "Registered Regular", "gstin": "32OZWPO6297K1Z10"}, {"name": "Maxwell", "role": "Both", "defaultAccount": null, "currency": "INR", "email": "maxwell.688@gmail.com", "phone": "+91 9065-184842", "address": null, "gstType": "Unregistered", "gstin": null}] \ No newline at end of file diff --git a/dummy/tests/testDummy.spec.ts b/dummy/tests/testDummy.spec.ts new file mode 100644 index 00000000..a756b223 --- /dev/null +++ b/dummy/tests/testDummy.spec.ts @@ -0,0 +1,49 @@ +import * as assert from 'assert'; +import { DatabaseManager } from 'backend/database/manager'; +import { assertDoesNotThrow } from 'backend/database/tests/helpers'; +import { purchaseItemPartyMap } from 'dummy/helpers'; +import { Fyo } from 'fyo'; +import { DummyAuthDemux } from 'fyo/tests/helpers'; +import 'mocha'; +import { getTestDbPath } from 'tests/helpers'; +import { setupDummyInstance } from '..'; + +describe('dummy', function () { + const dbPath = getTestDbPath(); + + let fyo: Fyo; + + this.beforeAll(function () { + fyo = new Fyo({ + DatabaseDemux: DatabaseManager, + AuthDemux: DummyAuthDemux, + isTest: true, + isElectron: false, + }); + }); + + this.afterAll(async function () { + await fyo.close(); + }); + + specify('setupDummyInstance', async function () { + await assertDoesNotThrow(async () => { + await setupDummyInstance(dbPath, fyo, 1, 25); + }, 'setup instance failed'); + + for (const item in purchaseItemPartyMap) { + assert.strictEqual( + await fyo.db.exists('Item', item), + true, + `not found ${item}` + ); + + const party = purchaseItemPartyMap[item]; + assert.strictEqual( + await fyo.db.exists('Party', party), + true, + `not found ${party}` + ); + } + }).timeout(120_000); +}); diff --git a/electron-builder.yml b/electron-builder.yml index acb5245a..9efe367a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,12 +1,11 @@ productName: Frappe Books appId: io.frappe.books afterSign: build/notarize.js -extraResources: [ - { - from: 'log_creds.txt', - to: '../creds/log_creds.txt', - } -] +extraResources: + [ + { from: 'log_creds.txt', to: '../creds/log_creds.txt' }, + { from: 'translations', to: '../translations' }, + ] mac: type: distribution category: public.app-category.finance diff --git a/fixtures/countryInfo.json b/fixtures/countryInfo.json index 1ed66118..1b5c9d01 100644 --- a/fixtures/countryInfo.json +++ b/fixtures/countryInfo.json @@ -61,16 +61,13 @@ }, "Angola": { - "code": "ao", - "currency": "KZ", - "currency_fraction": "Cêntimo", - "currency_fraction_units": 100, - "currency_symbol": "AOA", - "currency_name": "Kwanza", - "timezones": - [ - "Africa/Luanda" - ] + "code": "ao", + "currency": "AOA", + "currency_fraction": "Cêntimo", + "currency_fraction_units": 100, + "currency_symbol": "Kz", + "currency_name": "Kwanza", + "timezones": ["Africa/Luanda"] }, "Anguilla": { diff --git a/fixtures/standardCOA.js b/fixtures/standardCOA.js deleted file mode 100644 index ecb85c85..00000000 --- a/fixtures/standardCOA.js +++ /dev/null @@ -1,172 +0,0 @@ -import { t } from 'frappe'; - -export default { - [t`Application of Funds (Assets)`]: { - [t`Current Assets`]: { - [t`Accounts Receivable`]: { - [t`Debtors`]: { - accountType: 'Receivable', - }, - }, - [t`Bank Accounts`]: { - accountType: 'Bank', - isGroup: 1, - }, - [t`Cash In Hand`]: { - [t`Cash`]: { - accountType: 'Cash', - }, - accountType: 'Cash', - }, - [t`Loans and Advances (Assets)`]: { - isGroup: 1, - }, - [t`Securities and Deposits`]: { - [t`Earnest Money`]: {}, - }, - [t`Stock Assets`]: { - [t`Stock In Hand`]: { - accountType: 'Stock', - }, - accountType: 'Stock', - }, - [t`Tax Assets`]: { - isGroup: 1, - }, - }, - [t`Fixed Assets`]: { - [t`Capital Equipments`]: { - accountType: 'Fixed Asset', - }, - [t`Electronic Equipments`]: { - accountType: 'Fixed Asset', - }, - [t`Furnitures and Fixtures`]: { - accountType: 'Fixed Asset', - }, - [t`Office Equipments`]: { - accountType: 'Fixed Asset', - }, - [t`Plants and Machineries`]: { - accountType: 'Fixed Asset', - }, - [t`Buildings`]: { - accountType: 'Fixed Asset', - }, - [t`Softwares`]: { - accountType: 'Fixed Asset', - }, - [t`Accumulated Depreciation`]: { - accountType: 'Accumulated Depreciation', - }, - }, - [t`Investments`]: { - isGroup: 1, - }, - [t`Temporary Accounts`]: { - [t`Temporary Opening`]: { - accountType: 'Temporary', - }, - }, - rootType: 'Asset', - }, - [t`Expenses`]: { - [t`Direct Expenses`]: { - [t`Stock Expenses`]: { - [t`Cost of Goods Sold`]: { - accountType: 'Cost of Goods Sold', - }, - [t`Expenses Included In Valuation`]: { - accountType: 'Expenses Included In Valuation', - }, - [t`Stock Adjustment`]: { - accountType: 'Stock Adjustment', - }, - }, - }, - [t`Indirect Expenses`]: { - [t`Administrative Expenses`]: {}, - [t`Commission on Sales`]: {}, - [t`Depreciation`]: { - accountType: 'Depreciation', - }, - [t`Entertainment Expenses`]: {}, - [t`Freight and Forwarding Charges`]: { - accountType: 'Chargeable', - }, - [t`Legal Expenses`]: {}, - [t`Marketing Expenses`]: { - accountType: 'Chargeable', - }, - [t`Miscellaneous Expenses`]: { - accountType: 'Chargeable', - }, - [t`Office Maintenance Expenses`]: {}, - [t`Office Rent`]: {}, - [t`Postal Expenses`]: {}, - [t`Print and Stationery`]: {}, - [t`Round Off`]: { - accountType: 'Round Off', - }, - [t`Salary`]: {}, - [t`Sales Expenses`]: {}, - [t`Telephone Expenses`]: {}, - [t`Travel Expenses`]: {}, - [t`Utility Expenses`]: {}, - [t`Write Off`]: {}, - [t`Exchange Gain/Loss`]: {}, - [t`Gain/Loss on Asset Disposal`]: {}, - }, - rootType: 'Expense', - }, - [t`Income`]: { - [t`Direct Income`]: { - [t`Sales`]: {}, - [t`Service`]: {}, - }, - [t`Indirect Income`]: { - isGroup: 1, - }, - rootType: 'Income', - }, - [t`Source of Funds (Liabilities)`]: { - [t`Current Liabilities`]: { - [t`Accounts Payable`]: { - [t`Creditors`]: { - accountType: 'Payable', - }, - [t`Payroll Payable`]: {}, - }, - [t`Stock Liabilities`]: { - [t`Stock Received But Not Billed`]: { - accountType: 'Stock Received But Not Billed', - }, - }, - [t`Duties and Taxes`]: { - accountType: 'Tax', - isGroup: 1, - }, - [t`Loans (Liabilities)`]: { - [t`Secured Loans`]: {}, - [t`Unsecured Loans`]: {}, - [t`Bank Overdraft Account`]: {}, - }, - }, - rootType: 'Liability', - }, - [t`Equity`]: { - [t`Capital Stock`]: { - accountType: 'Equity', - }, - [t`Dividends Paid`]: { - accountType: 'Equity', - }, - [t`Opening Balance Equity`]: { - accountType: 'Equity', - }, - [t`Retained Earnings`]: { - accountType: 'Equity', - }, - rootType: 'Equity', - }, -}; diff --git a/fixtures/verified/ae.json b/fixtures/verified/ae.json index 9141556c..1935a3de 100644 --- a/fixtures/verified/ae.json +++ b/fixtures/verified/ae.json @@ -386,7 +386,7 @@ }, "Duties and Taxes": { "accountType": "Tax", - "isGroup": 1 + "isGroup": true }, "Reservations & Credit Notes": { "Credit Notes": { diff --git a/fixtures/verified/ca.json b/fixtures/verified/ca.json index bf36c047..b270293d 100644 --- a/fixtures/verified/ca.json +++ b/fixtures/verified/ca.json @@ -11,7 +11,7 @@ }, "Banque": { "accountType": "Bank", - "isGroup": 1 + "isGroup": true }, "Comptes \u00e0 recevoir": { "Comptes clients": { @@ -20,7 +20,7 @@ "Provision pour cr\u00e9ances douteuses": {} }, "Encaisse": { - "isGroup": 1 + "isGroup": true }, "Frais pay\u00e9s d\u2019avance": { "Assurances pay\u00e9s d'avance": {}, @@ -29,7 +29,7 @@ }, "Petite caisse": { "accountType": "Cash", - "isGroup": 1 + "isGroup": true }, "Stocks": { "Mati\u00e8res premi\u00e8res": {}, @@ -175,7 +175,7 @@ "Loyer - b\u00e2timent": {}, "Loyer - entrep\u00f4t": {}, "Op\u00e9rations indirectes de la main-d\u2019\u0153uvre directe": { - "isGroup": 1 + "isGroup": true }, "R\u00e9parations et entretien - b\u00e2timent": {}, "R\u00e9parations et entretien - machinerie et \u00e9quipement": {}, diff --git a/fixtures/verified/gt.json b/fixtures/verified/gt.json index 69a6a788..a07b65fd 100644 --- a/fixtures/verified/gt.json +++ b/fixtures/verified/gt.json @@ -9,12 +9,12 @@ "Animales": { "accountNumber": "1.5.2.1", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Plantas": { "accountNumber": "1.5.2.2", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.5.2", "accountType": "Stock" @@ -22,7 +22,7 @@ "Activos Biol\u00f3gicos al Costo": { "accountNumber": "1.5.1", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.5", "accountType": "Stock" @@ -32,7 +32,7 @@ "Cr\u00e9dito Fiscal (IVA Por Cobrar)": { "accountNumber": "1.1.2.1", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.1.2", "accountType": "Chargeable" @@ -47,22 +47,22 @@ "Activos Adicionales y Otros": { "accountNumber": "1.6.6", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Cobrables Relacionados con Impuestos": { "accountNumber": "1.6.2", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Contratos de Construccion": { "accountNumber": "1.6.4", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Costos de Montaje": { "accountNumber": "1.6.5", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Pagos Anticipados y Otros Activos Circulantes": { "Seguro Pagado Anticipadamente": { @@ -75,7 +75,7 @@ "Proveedores de Servicio": { "accountNumber": "1.6.3", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.6", "accountType": "Chargeable" @@ -84,32 +84,32 @@ "Activos Financieros Clasificados por Designaci\u00f3n": { "accountNumber": "1.4.6", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Activos Financieros Derivados": { "accountNumber": "1.4.3", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Inversion o Participaci\u00f3n Accionaria en Empresas Afiliadas": { "accountNumber": "1.4.1", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Inversiones Burs\u00e1tiles e Instrumentos Financieros": { "accountNumber": "1.4.2", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Otros Activos Financieros": { "accountNumber": "1.4.4", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Provisi\u00f3n por Riesgo de Cr\u00e9dito (agregado) (Contra-activo)": { "accountNumber": "1.4.5", "accountType": "Round Off", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.4", "accountType": "Chargeable" @@ -117,13 +117,13 @@ "Activos Intangibles": { "accountNumber": "1.3", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Caja y Equivalentes": { "Caja": { "accountNumber": "1.9.1", "accountType": "Cash", - "isGroup": 1 + "isGroup": true }, "Equivalentes de Efectivo (Bancos)": { "Bancos Internacionales": { @@ -146,7 +146,7 @@ "Banco Industrial": { "accountNumber": "1.9.2.1.1", "accountType": "Bank", - "isGroup": 1 + "isGroup": true }, "Banco Internacional": { "accountNumber": "1.9.2.1.6", @@ -189,12 +189,12 @@ "Inversiones a Corto Plazo": { "accountNumber": "1.9.3", "accountType": "Bank", - "isGroup": 1 + "isGroup": true }, "Otros Equivalentes de Caja y Bancos": { "accountNumber": "1.9.4", "accountType": "Cash", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.9", "accountType": "Bank" @@ -203,12 +203,12 @@ "Activos bajo Contrato": { "accountNumber": "1.8.2", "accountType": "Receivable", - "isGroup": 1 + "isGroup": true }, "Ajustes": { "accountNumber": "1.8.4", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "Otras Cuentas por Cobrar": { "Cuentas Por Cobrar Compa\u00f1\u00edas Afiliadas": { @@ -241,7 +241,7 @@ "Ventas al Cr\u00e9dito": { "accountNumber": "1.8.1", "accountType": "Receivable", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.8", "accountType": "Receivable" @@ -253,38 +253,38 @@ "Art\u00edculos de Inventario Adicionales": { "accountNumber": "1.7.8", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Combustibles": { "accountNumber": "1.7.5", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Inventarios Pignorados Como Garant\u00eda de Pasivo": { "accountNumber": "1.7.10", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Inventarios a Valor Razonable Menos Costos de Venta": { "accountNumber": "1.7.11", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Materia Prima": { "accountNumber": "1.7.1", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Mercader\u00eda (Mercanc\u00edas)": { "accountNumber": "1.7.2", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Otros Inventarios": { "Merma o Ajuste de Inventario": { "accountNumber": "1.7.9.1", "accountType": "Stock Adjustment", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.7.9", "accountType": "Stock" @@ -292,13 +292,13 @@ "Producto Terminado": { "accountNumber": "1.7.7", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Repuestos": { "Respuestos en Transito": { "accountNumber": "1.7.4.0", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.7.4", "accountType": "Stock" @@ -306,12 +306,12 @@ "Suministros de Producci\u00f3n y Consumibles": { "accountNumber": "1.7.3", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Trabajo en Progeso": { "accountNumber": "1.7.6", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.7", "accountType": "Stock" @@ -324,7 +324,7 @@ "Inversion Inmobiliaria Construida": { "accountNumber": "1.2.2", "accountType": "Chargeable", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1.2", "accountType": "Chargeable" diff --git a/fixtures/verified/hu.json b/fixtures/verified/hu.json index 5434e520..3166eda6 100644 --- a/fixtures/verified/hu.json +++ b/fixtures/verified/hu.json @@ -29,16 +29,16 @@ }, "123. \u00c9p\u00fcletek, \u00e9p\u00fcletr\u00e9szek, tulajdoni h\u00e1nyadok ": { "accountType": "Fixed Asset", - "isGroup": 1 + "isGroup": true }, "124. Egy\u00e9b ingatlanok": { - "isGroup": 1 + "isGroup": true }, "125. \u00dczemk\u00f6r\u00f6n k\u00edv\u00fcli ingatlanok, \u00e9p\u00fcletek ": { - "isGroup": 1 + "isGroup": true }, "126. Ingatlanokhoz kapcsol\u00f3d\u00f3 vagyoni \u00e9rt\u00e9k\u0171 jogok": { - "isGroup": 1 + "isGroup": true }, "127. Ingatlanok \u00e9rt\u00e9khelyesb\u00edt\u00e9se": {}, "129. Kis \u00e9rt\u00e9k\u0171 ingatlanok": {} @@ -148,7 +148,7 @@ "239. Befejezetlen termel\u00e9s \u00e9s f\u00e9lk\u00e9sz term\u00e9kek \u00e9rt\u00e9kveszt\u00e9se \u00e9s annak vissza\u00edr\u00e1sa": {} }, "24. N\u00d6VEND\u00c9K-, H\u00cdZ\u00d3- \u00c9S EGY\u00c9B \u00c1LLATOK": { - "isGroup": 1 + "isGroup": true }, "25. K\u00c9SZTERM\u00c9KEK": { "251-257. K\u00e9szterm\u00e9kek": {}, @@ -158,23 +158,23 @@ "26-28. \u00c1RUK ": { "261. Kereskedelmi \u00e1ruk": { "accountType": "Stock", - "isGroup": 0 + "isGroup": false }, "262. Idegen helyen t\u00e1rolt, bizom\u00e1nyba \u00e1tadott \u00e1ruk": { "accountType": "Stock", - "isGroup": 0 + "isGroup": false }, "263. T\u00e1rgyi eszk\u00f6z\u00f6k k\u00f6z\u00fcl \u00e1tsorolt \u00e1ruk": { "accountType": "Stock", - "isGroup": 0 + "isGroup": false }, "264. Bels\u0151 (egys\u00e9gek, tev\u00e9kenys\u00e9gek k\u00f6z\u00f6tti) \u00e1tad\u00e1s-\u00e1tv\u00e9tel \u00fctk\u00f6z\u0151sz\u00e1mla": { "accountType": "Stock", - "isGroup": 0 + "isGroup": false }, "269. Kereskedelmi \u00e1ruk \u00e9rt\u00e9kveszt\u00e9se \u00e9s annak vissza\u00edr\u00e1sa": { "accountType": "Stock", - "isGroup": 0 + "isGroup": false }, "accountType": "Stock" }, @@ -183,7 +183,7 @@ "279. K\u00f6zvet\u00edtett szolg\u00e1ltat\u00e1sok \u00e9rt\u00e9kveszt\u00e9se \u00e9s annak vissza\u00edr\u00e1sa": {} }, "28. BET\u00c9TD\u00cdJAS G\u00d6NGY\u00d6LEGEK": { - "isGroup": 1 + "isGroup": true }, "rootType": "Asset" }, @@ -205,13 +205,13 @@ "319. K\u00fclf\u00f6ldi k\u00f6vetel\u00e9sek \u00e9rt\u00e9kveszt\u00e9se \u00e9s annak vissza\u00edr\u00e1sa": {} }, "32. K\u00d6VETEL\u00c9SEK KAPCSOLT V\u00c1LLALKOZ\u00c1SSAL SZEMBEN": { - "isGroup": 1 + "isGroup": true }, "33. K\u00d6VETEL\u00c9SEK EGY\u00c9B R\u00c9SZESED\u00c9SI VISZONYBAN L\u00c9V\u00d5 V\u00c1LLALKOZ\u00c1SSAL SZEMBEN ": { - "isGroup": 1 + "isGroup": true }, "34. V\u00c1LT\u00d3K\u00d6VETEL\u00c9SEK": { - "isGroup": 1 + "isGroup": true }, "35. ADOTT EL\u00d5LEGEK": { "351. Immateri\u00e1lis javakra adott el\u0151legek": {}, @@ -227,17 +227,17 @@ "3613. Egy\u00e9b elsz\u00e1mol\u00e1sok a munkav\u00e1llal\u00f3kkal": {} }, "362. K\u00f6lts\u00e9gvet\u00e9ssel szembeni k\u00f6vetel\u00e9sek": { - "isGroup": 1 + "isGroup": true }, "363. R\u00f6vid lej\u00e1ratra k\u00f6lcs\u00f6nadott p\u00e9nzeszk\u00f6z\u00f6k": { - "isGroup": 1 + "isGroup": true }, "364. R\u00e9szesed\u00e9sekkel, \u00e9rt\u00e9kpap\u00edrokkal kapcsolatos k\u00f6vetel\u00e9sek": { "3641. R\u00f6vid lej\u00e1rat\u00fa k\u00f6lcs\u00f6n\u00f6k": {}, "3642. Tart\u00f3san adott k\u00f6lcs\u00f6n\u00f6kb\u0151l \u00e1tsorolt k\u00f6vetel\u00e9sek": {} }, "365. V\u00e1s\u00e1rolt \u00e9s kapott k\u00f6vetel\u00e9sek ": { - "isGroup": 1 + "isGroup": true }, "366. R\u00e9szesed\u00e9sekkel, \u00e9rt\u00e9kpap\u00edrokkal kapcsolatos k\u00f6vetel\u00e9sek": {}, "367. Hat\u00e1rid\u0151s, opci\u00f3s \u00e9s swap \u00fcgyletekkel kapcsolatos k\u00f6vetel\u00e9sek": {}, @@ -285,7 +285,7 @@ "383. Csekkek": {}, "384. Elsz\u00e1mol\u00e1si bet\u00e9tsz\u00e1mla ": { "accountType": "Bank", - "isGroup": 1 + "isGroup": true }, "385. Elk\u00fcl\u00f6n\u00edtett bet\u00e9tsz\u00e1ml\u00e1k ": { "3851. Kamatoz\u00f3 bet\u00e9tsz\u00e1ml\u00e1k": {}, @@ -373,7 +373,7 @@ "4452. Egy\u00e9b hossz\u00fa lej\u00e1rat\u00fa hitelek deviz\u00e1ban": {} }, "446. Tart\u00f3s k\u00f6telezetts\u00e9gek kapcsolt v\u00e1llalkoz\u00e1ssal szemben ": { - "isGroup": 1 + "isGroup": true }, "447. Tart\u00f3s k\u00f6telezetts\u00e9gek egy\u00e9b r\u00e9szesed\u00e9si viszonyban l\u00e9v\u0151 v\u00e1llalkoz\u00e1ssal szemben": {}, "448. P\u00e9nz\u00fcgyi l\u00edzing miatti k\u00f6telezetts\u00e9gek ": {}, @@ -449,7 +449,7 @@ "463-9. C\u00e9gaut\u00f3ad\u00f3 ": {} }, "464. G\u00e9pj\u00e1rm\u0171 ad\u00f3 (c\u00e9gaut\u00f3ad\u00f3) elsz\u00e1mol\u00e1sa": { - "isGroup": 1 + "isGroup": true }, "465. V\u00e1m- \u00e9s p\u00e9nz\u00fcgy\u0151rs\u00e9g elsz\u00e1mol\u00e1si sz\u00e1mla ": { "4651. V\u00e1mk\u00f6lts\u00e9gek \u00e9s egy\u00e9b v\u00e1mterhek elsz\u00e1mol\u00e1si sz\u00e1mla": {}, @@ -619,7 +619,7 @@ "589. Aktiv\u00e1lt saj\u00e1t teljes\u00edtm\u00e9nyek \u00e1tvezet\u00e9si sz\u00e1mla": {} }, "59. K\u00d6LTS\u00c9GNEM \u00c1TVEZET\u00c9SI SZ\u00c1MLA": { - "isGroup": 1 + "isGroup": true }, "rootType": "Expense" }, @@ -647,7 +647,7 @@ "rootType": "Expense" }, "7. SZ\u00c1MLAOSZT\u00c1LY TEV\u00c9KENYS\u00c9GEKK\u00d6LTS\u00c9GEI": { - "isGroup": 1, + "isGroup": true, "rootType": "Expense" }, "8. SZ\u00c1MLAOSZT\u00c1LY \u00c9RT\u00c9KES\u00cdT\u00c9S ELSZ\u00c1MOLT \u00d6NK\u00d6LTS\u00c9GE \u00c9S R\u00c1FORD\u00cdT\u00c1SOK": { @@ -812,7 +812,7 @@ "9684. R\u00e9szesed\u00e9sek \u00e9rt\u00e9kveszt\u00e9s\u00e9nek vissza\u00edr\u00e1sa": {} }, "969. K\u00fcl\u00f6nf\u00e9le egy\u00e9b bev\u00e9telek": { - "isGroup": 1 + "isGroup": true } }, "97. P\u00c9NZ\u00dcGYI M\u0170VELETEK BEV\u00c9TELEI": { diff --git a/fixtures/verified/id.json b/fixtures/verified/id.json index 4ad611a9..543bc6d9 100644 --- a/fixtures/verified/id.json +++ b/fixtures/verified/id.json @@ -14,11 +14,11 @@ "Bank ": { "Bank Other Currency": { "accountNumber": "1122.000", - "isGroup": 1 + "isGroup": true }, "Bank Rupiah": { "accountNumber": "1121.000", - "isGroup": 1 + "isGroup": true }, "accountNumber": "1120.000", "accountType": "Bank" @@ -70,7 +70,7 @@ "Persediaan Barang": { "accountNumber": "1141.000", "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "Uang Muka Pembelian": { "Uang Muka Pembelian": { @@ -122,7 +122,7 @@ "Investasi": { "Deposito": { "accountNumber": "1231.003", - "isGroup": 1 + "isGroup": true }, "Investai Saham": { "Investasi Saham": { diff --git a/fixtures/verified/in.json b/fixtures/verified/in.json index 203684d2..f2afda4d 100644 --- a/fixtures/verified/in.json +++ b/fixtures/verified/in.json @@ -6,13 +6,13 @@ "Current Assets": { "Accounts Receivable": { "Debtors": { - "isGroup": 0, + "isGroup": false, "accountType": "Receivable" } }, "Bank Accounts": { "accountType": "Bank", - "isGroup": 1 + "isGroup": true }, "Cash In Hand": { "Cash": { @@ -21,7 +21,7 @@ "accountType": "Cash" }, "Loans and Advances (Assets)": { - "isGroup": 1 + "isGroup": true }, "Securities and Deposits": { "Earnest Money": {} @@ -33,7 +33,7 @@ "accountType": "Stock" }, "Tax Assets": { - "isGroup": 1 + "isGroup": true } }, "Fixed Assets": { @@ -60,7 +60,7 @@ } }, "Investments": { - "isGroup": 1 + "isGroup": true }, "Temporary Accounts": { "Temporary Opening": { @@ -126,7 +126,7 @@ }, "Indirect Income": { "accountType": "Income Account", - "isGroup": 1 + "isGroup": true }, "rootType": "Income" }, diff --git a/fixtures/verified/mx.json b/fixtures/verified/mx.json index 7aa93777..9c0badbd 100644 --- a/fixtures/verified/mx.json +++ b/fixtures/verified/mx.json @@ -110,7 +110,7 @@ }, "INVENTARIOS": { "accountType": "Stock", - "isGroup": 1 + "isGroup": true } }, "ACTIVO LARGO PLAZO": { diff --git a/fixtures/verified/ni.json b/fixtures/verified/ni.json index 22333489..7bcf43b9 100644 --- a/fixtures/verified/ni.json +++ b/fixtures/verified/ni.json @@ -57,7 +57,7 @@ }, "Otros Equivalentes a Efectivo": { "accountType": "Cash", - "isGroup": 1 + "isGroup": true } }, "Impuestos Acreditables": { @@ -91,41 +91,41 @@ }, "Todos los Almacenes": { "accountType": "Stock", - "isGroup": 1 + "isGroup": true }, "accountType": "Stock" }, "Otras Cuentas por Cobrar": { "accountType": "Receivable", - "isGroup": 1 + "isGroup": true } }, "Activo no Corriente": { "Activo por Impuestos Diferidos": { - "isGroup": 1 + "isGroup": true }, "Activos Intangibles": { "Amortizacion de Activos Intangibles": { - "isGroup": 1 + "isGroup": true }, "Concesiones": { - "isGroup": 1 + "isGroup": true }, "Derechos de Autor": { - "isGroup": 1 + "isGroup": true }, "Deterioro de Valor de Activos Intangibles": {}, "Gastos de investigacion": { - "isGroup": 1 + "isGroup": true }, "Licencias": { - "isGroup": 1 + "isGroup": true }, "Marcas Registradas": { - "isGroup": 1 + "isGroup": true }, "Patentes": { - "isGroup": 1 + "isGroup": true } }, "Amortizables": { @@ -138,7 +138,7 @@ "accountType": "Expenses Included In Valuation" }, "Mejoras en Bienes Arrendados": { - "isGroup": 1 + "isGroup": true } }, "Bienes en Arrendamiento Financiero": { @@ -147,28 +147,28 @@ }, "Cuentas por Cobrar a Largo Plazo": { "Creditos a Largo Plazo": { - "isGroup": 1 + "isGroup": true } }, "Inversiones Permanentes": { "Inversiones Permanentes 1": { "accountType": "Fixed Asset", - "isGroup": 1 + "isGroup": true }, "Negocios Conjuntos": { "accountType": "Fixed Asset", - "isGroup": 1 + "isGroup": true } }, "Inversiones a Largo Plazo": { "Depositos Bancarios a Plazo": { - "isGroup": 1 + "isGroup": true }, "Intereses percibidos por adelantado": { - "isGroup": 1 + "isGroup": true }, "Titulos y Acciones": { - "isGroup": 1 + "isGroup": true } }, "Propiedad Planta y Equipo": { @@ -203,7 +203,7 @@ } }, "Donaciones": { - "isGroup": 1 + "isGroup": true }, "Ganancias Acumuladas": { "Reservas": { @@ -319,7 +319,7 @@ }, "Pasivo": { "Obligaciones por Arrendamiento Financiero a Largo Plazo": { - "isGroup": 1 + "isGroup": true }, "Pasivo Corriente": { "Anticipos de Clientes": {}, @@ -345,11 +345,11 @@ }, "Gastos por Pagar": { "Prestaciones Sociales": { - "isGroup": 1 + "isGroup": true }, "Salarios por Pagar": {}, "Servicios Basicos 1": { - "isGroup": 1 + "isGroup": true } }, "Impuestos por Pagar": { @@ -372,17 +372,17 @@ } }, "Otras Cuentas por Pagar": { - "isGroup": 1 + "isGroup": true }, "Pasivos Financieros a Corto Plazo": { "Otras Deudas Bancarias": { - "isGroup": 1 + "isGroup": true }, "Prestamos por Pagar a Corto Plazo": { - "isGroup": 1 + "isGroup": true }, "Sobregiros Bancarios": { - "isGroup": 1 + "isGroup": true } }, "Provisiones por Pagar": { @@ -473,20 +473,20 @@ }, "Pasivo No Corriente": { "Cuentas por Pagar a Largo Plaso": { - "isGroup": 1 + "isGroup": true }, "Otras Cuentas por Pagar a Largo Plazo": { - "isGroup": 1 + "isGroup": true }, "Otros Pasivos Financieros a Largo Plaso": { - "isGroup": 1 + "isGroup": true }, "Prestamos a Largo Plazo": { - "isGroup": 1 + "isGroup": true } }, "Pasivo por Impuestos Diferidos": { - "isGroup": 1 + "isGroup": true }, "rootType": "Liability" } diff --git a/fixtures/verified/nl.json b/fixtures/verified/nl.json index 7e5607d1..5c7584ce 100644 --- a/fixtures/verified/nl.json +++ b/fixtures/verified/nl.json @@ -3,7 +3,7 @@ "name": "Netherlands - Grootboekschema", "tree": { "FABRIKAGEREKENINGEN": { - "isGroup": 1, + "isGroup": true, "rootType": "Expense" }, "FINANCIELE REKENINGEN, KORTLOPENDE VORDERINGEN EN SCHULDEN": { @@ -106,7 +106,7 @@ "rootType": "Asset" }, "INDIRECTE KOSTEN": { - "isGroup": 1, + "isGroup": true, "rootType": "Expense" }, "KOSTENREKENINGEN": { diff --git a/fixtures/verified/standardCOA.json b/fixtures/verified/standardCOA.json index 8d0877f1..e57e2dbb 100644 --- a/fixtures/verified/standardCOA.json +++ b/fixtures/verified/standardCOA.json @@ -7,8 +7,8 @@ } }, "Bank Accounts": { - "accountType": "Bank", - "isGroup": 1 + "accountType": "Bank", + "isGroup": true }, "Cash In Hand": { "Cash": { @@ -17,7 +17,7 @@ "accountType": "Cash" }, "Loans and Advances (Assets)": { - "isGroup": 1 + "isGroup": true }, "Securities and Deposits": { "Earnest Money": {} @@ -29,7 +29,7 @@ "accountType": "Stock" }, "Tax Assets": { - "isGroup": 1 + "isGroup": true } }, "Fixed Assets": { @@ -59,7 +59,7 @@ } }, "Investments": { - "isGroup": 1 + "isGroup": true }, "Temporary Accounts": { "Temporary Opening": { @@ -123,7 +123,7 @@ "Service": {} }, "Indirect Income": { - "isGroup": 1 + "isGroup": true }, "rootType": "Income" }, @@ -141,8 +141,8 @@ } }, "Duties and Taxes": { - "accountType": "Tax", - "isGroup": 1 + "accountType": "Tax", + "isGroup": true }, "Loans (Liabilities)": { "Secured Loans": {}, diff --git a/frappe/backends/database.js b/frappe/backends/database.js deleted file mode 100644 index f1bc6874..00000000 --- a/frappe/backends/database.js +++ /dev/null @@ -1,830 +0,0 @@ -import frappe from 'frappe'; -import Observable from 'frappe/utils/observable'; -import Knex from 'knex'; -import CacheManager from '../utils/cacheManager'; - -export default class Database extends Observable { - constructor() { - super(); - this.initTypeMap(); - this.connectionParams = {}; - this.cache = new CacheManager(); - } - - connect() { - this.knex = Knex(this.connectionParams); - this.knex.on('query-error', (error) => { - error.type = this.getError(error); - }); - this.executePostDbConnect(); - } - - close() { - // - } - - async migrate() { - for (let doctype in frappe.models) { - // check if controller module - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - if (!meta.isSingle) { - if (await this.tableExists(baseDoctype)) { - await this.alterTable(baseDoctype); - } else { - await this.createTable(baseDoctype); - } - } - } - await this.commit(); - await this.initializeSingles(); - } - - async initializeSingles() { - let singleDoctypes = frappe - .getModels((model) => model.isSingle) - .map((model) => model.name); - - for (let doctype of singleDoctypes) { - if (await this.singleExists(doctype)) { - const singleValues = await this.getSingleFieldsToInsert(doctype); - singleValues.forEach(({ fieldname, value }) => { - let singleValue = frappe.newDoc({ - doctype: 'SingleValue', - parent: doctype, - fieldname, - value, - }); - singleValue.insert(); - }); - continue; - } - let meta = frappe.getMeta(doctype); - if (meta.fields.every((df) => df.default == null)) { - continue; - } - let defaultValues = meta.fields.reduce((doc, df) => { - if (df.default != null) { - doc[df.fieldname] = df.default; - } - return doc; - }, {}); - await this.updateSingle(doctype, defaultValues); - } - } - - async singleExists(doctype) { - let res = await this.knex('SingleValue') - .count('parent as count') - .where('parent', doctype) - .first(); - return res.count > 0; - } - - async getSingleFieldsToInsert(doctype) { - const existingFields = ( - await frappe.db - .knex('SingleValue') - .where({ parent: doctype }) - .select('fieldname') - ).map(({ fieldname }) => fieldname); - - return frappe - .getMeta(doctype) - .fields.map(({ fieldname, default: value }) => ({ - fieldname, - value, - })) - .filter( - ({ fieldname, value }) => - !existingFields.includes(fieldname) && value !== undefined - ); - } - - tableExists(table) { - return this.knex.schema.hasTable(table); - } - - async createTable(doctype, tableName = null) { - let fields = this.getValidFields(doctype); - return await this.runCreateTableQuery(tableName || doctype, fields); - } - - runCreateTableQuery(doctype, fields) { - return this.knex.schema.createTable(doctype, (table) => { - for (let field of fields) { - this.buildColumnForTable(table, field); - } - }); - } - - async alterTable(doctype) { - // get columns - let diff = await this.getColumnDiff(doctype); - let newForeignKeys = await this.getNewForeignKeys(doctype); - - return this.knex.schema - .table(doctype, (table) => { - if (diff.added.length) { - for (let field of diff.added) { - this.buildColumnForTable(table, field); - } - } - - if (diff.removed.length) { - this.removeColumns(doctype, diff.removed); - } - }) - .then(() => { - if (newForeignKeys.length) { - return this.addForeignKeys(doctype, newForeignKeys); - } - }); - } - - buildColumnForTable(table, field) { - let columnType = this.getColumnType(field); - if (!columnType) { - // In case columnType is "Table" - // childTable links are handled using the childTable's "parent" field - return; - } - - let column = table[columnType](field.fieldname); - - // primary key - if (field.fieldname === 'name') { - column.primary(); - } - - // default value - if (!!field.default && !(field.default instanceof Function)) { - column.defaultTo(field.default); - } - - // required - if ( - (!!field.required && !(field.required instanceof Function)) || - field.fieldtype === 'Currency' - ) { - column.notNullable(); - } - - // link - if (field.fieldtype === 'Link' && field.target) { - let meta = frappe.getMeta(field.target); - table - .foreign(field.fieldname) - .references('name') - .inTable(meta.getBaseDocType()) - .onUpdate('CASCADE') - .onDelete('RESTRICT'); - } - } - - async getColumnDiff(doctype) { - const tableColumns = await this.getTableColumns(doctype); - const validFields = this.getValidFields(doctype); - const diff = { added: [], removed: [] }; - - for (let field of validFields) { - if ( - !tableColumns.includes(field.fieldname) && - this.getColumnType(field) - ) { - diff.added.push(field); - } - } - - const validFieldNames = validFields.map((field) => field.fieldname); - for (let column of tableColumns) { - if (!validFieldNames.includes(column)) { - diff.removed.push(column); - } - } - - return diff; - } - - async removeColumns(doctype, removed) { - for (let column of removed) { - await this.runRemoveColumnQuery(doctype, column); - } - } - - async getNewForeignKeys(doctype) { - let foreignKeys = await this.getForeignKeys(doctype); - let newForeignKeys = []; - let meta = frappe.getMeta(doctype); - for (let field of meta.getValidFields({ withChildren: false })) { - if ( - field.fieldtype === 'Link' && - !foreignKeys.includes(field.fieldname) - ) { - newForeignKeys.push(field); - } - } - return newForeignKeys; - } - - async addForeignKeys(doctype, newForeignKeys) { - for (let field of newForeignKeys) { - this.addForeignKey(doctype, field); - } - } - - async getForeignKeys(doctype, field) { - return []; - } - - async getTableColumns(doctype) { - return []; - } - - async get(doctype, name = null, fields = '*') { - let meta = frappe.getMeta(doctype); - let doc; - if (meta.isSingle) { - doc = await this.getSingle(doctype); - doc.name = doctype; - } else { - if (!name) { - throw new frappe.errors.ValueError('name is mandatory'); - } - doc = await this.getOne(doctype, name, fields); - } - if (!doc) { - return; - } - await this.loadChildren(doc, meta); - return doc; - } - - async loadChildren(doc, meta) { - // load children - let tableFields = meta.getTableFields(); - for (let field of tableFields) { - doc[field.fieldname] = await this.getAll({ - doctype: field.childtype, - fields: ['*'], - filters: { parent: doc.name }, - orderBy: 'idx', - order: 'asc', - }); - } - } - - async getSingle(doctype) { - let values = await this.getAll({ - doctype: 'SingleValue', - fields: ['fieldname', 'value'], - filters: { parent: doctype }, - orderBy: 'fieldname', - order: 'asc', - }); - let doc = {}; - for (let row of values) { - doc[row.fieldname] = row.value; - } - return doc; - } - - /** - * Get list of values from the singles table. - * @param {...string | Object} fieldnames list of fieldnames to get the values of - * @returns {Array} array of {parent, value, fieldname}. - * @example - * Database.getSingleValues('internalPrecision'); - * // returns [{ fieldname: 'internalPrecision', parent: 'SystemSettings', value: '12' }] - * @example - * Database.getSingleValues({fieldname:'internalPrecision', parent: 'SystemSettings'}); - * // returns [{ fieldname: 'internalPrecision', parent: 'SystemSettings', value: '12' }] - */ - async getSingleValues(...fieldnames) { - fieldnames = fieldnames.map((fieldname) => { - if (typeof fieldname === 'string') { - return { fieldname }; - } - return fieldname; - }); - - let builder = frappe.db.knex('SingleValue'); - builder = builder.where(fieldnames[0]); - - fieldnames.slice(1).forEach(({ fieldname, parent }) => { - if (typeof parent === 'undefined') { - builder = builder.orWhere({ fieldname }); - } else { - builder = builder.orWhere({ fieldname, parent }); - } - }); - - let values = []; - try { - values = await builder.select('fieldname', 'value', 'parent'); - } catch (error) { - if (error.message.includes('no such table')) { - return []; - } - throw error; - } - - return values.map((value) => { - const fields = frappe.getMeta(value.parent).fields; - return this.getDocFormattedDoc(fields, values); - }); - } - - async getOne(doctype, name, fields = '*') { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - - const doc = await this.knex - .select(fields) - .from(baseDoctype) - .where('name', name) - .first(); - - if (!doc) { - return doc; - } - - return this.getDocFormattedDoc(meta.fields, doc); - } - - getDocFormattedDoc(fields, doc) { - // format for usage, not going into the db - const docFields = Object.keys(doc); - const filteredFields = fields.filter(({ fieldname }) => - docFields.includes(fieldname) - ); - - const formattedValues = filteredFields.reduce((d, field) => { - const { fieldname } = field; - d[fieldname] = this.getDocFormattedValues(field, doc[fieldname]); - return d; - }, {}); - - return Object.assign(doc, formattedValues); - } - - getDocFormattedValues(field, value) { - // format for usage, not going into the db - try { - if (field.fieldtype === 'Currency') { - return frappe.pesa(value); - } - } catch (err) { - err.message += ` value: '${value}' of type: ${typeof value}, fieldname: '${ - field.fieldname - }', label: '${field.label}'`; - throw err; - } - return value; - } - - triggerChange(doctype, name) { - this.trigger(`change:${doctype}`, { name }, 500); - this.trigger(`change`, { doctype, name }, 500); - // also trigger change for basedOn doctype - let meta = frappe.getMeta(doctype); - if (meta.basedOn) { - this.triggerChange(meta.basedOn, name); - } - } - - async insert(doctype, doc) { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - doc = this.applyBaseDocTypeFilters(doctype, doc); - - // insert parent - if (meta.isSingle) { - await this.updateSingle(doctype, doc); - } else { - await this.insertOne(baseDoctype, doc); - } - - // insert children - await this.insertChildren(meta, doc, baseDoctype); - - this.triggerChange(doctype, doc.name); - - return doc; - } - - async insertChildren(meta, doc, doctype) { - let tableFields = meta.getTableFields(); - for (let field of tableFields) { - let idx = 0; - for (let child of doc[field.fieldname] || []) { - this.prepareChild(doctype, doc.name, child, field, idx); - await this.insertOne(field.childtype, child); - idx++; - } - } - } - - insertOne(doctype, doc) { - let fields = this.getValidFields(doctype); - - if (!doc.name) { - doc.name = frappe.getRandomString(); - } - - let formattedDoc = this.getFormattedDoc(fields, doc); - return this.knex(doctype).insert(formattedDoc); - } - - async update(doctype, doc) { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - doc = this.applyBaseDocTypeFilters(doctype, doc); - - // update parent - if (meta.isSingle) { - await this.updateSingle(doctype, doc); - } else { - await this.updateOne(baseDoctype, doc); - } - - // insert or update children - await this.updateChildren(meta, doc, baseDoctype); - - this.triggerChange(doctype, doc.name); - - return doc; - } - - async updateChildren(meta, doc, doctype) { - let tableFields = meta.getTableFields(); - for (let field of tableFields) { - let added = []; - for (let child of doc[field.fieldname] || []) { - this.prepareChild(doctype, doc.name, child, field, added.length); - if (await this.exists(field.childtype, child.name)) { - await this.updateOne(field.childtype, child); - } else { - await this.insertOne(field.childtype, child); - } - added.push(child.name); - } - await this.runDeleteOtherChildren(field, doc.name, added); - } - } - - updateOne(doctype, doc) { - let validFields = this.getValidFields(doctype); - let fieldsToUpdate = Object.keys(doc).filter((f) => f !== 'name'); - let fields = validFields.filter((df) => - fieldsToUpdate.includes(df.fieldname) - ); - let formattedDoc = this.getFormattedDoc(fields, doc); - - return this.knex(doctype) - .where('name', doc.name) - .update(formattedDoc) - .then(() => { - let cacheKey = `${doctype}:${doc.name}`; - if (this.cache.hexists(cacheKey)) { - for (let fieldname in formattedDoc) { - let value = formattedDoc[fieldname]; - this.cache.hset(cacheKey, fieldname, value); - } - } - }); - } - - runDeleteOtherChildren(field, parent, added) { - // delete other children - return this.knex(field.childtype) - .where('parent', parent) - .andWhere('name', 'not in', added) - .delete(); - } - - async updateSingle(doctype, doc) { - let meta = frappe.getMeta(doctype); - await this.deleteSingleValues(doctype); - for (let field of meta.getValidFields({ withChildren: false })) { - let value = doc[field.fieldname]; - if (value != null) { - let singleValue = frappe.newDoc({ - doctype: 'SingleValue', - parent: doctype, - fieldname: field.fieldname, - value: value, - }); - await singleValue.insert(); - } - } - } - - deleteSingleValues(name) { - return this.knex('SingleValue').where('parent', name).delete(); - } - - async rename(doctype, oldName, newName) { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - await this.knex(baseDoctype) - .update({ name: newName }) - .where('name', oldName) - .then(() => { - this.clearValueCache(doctype, oldName); - }); - await frappe.db.commit(); - - this.triggerChange(doctype, newName); - } - - prepareChild(parenttype, parent, child, field, idx) { - if (!child.name) { - child.name = frappe.getRandomString(); - } - child.parent = parent; - child.parenttype = parenttype; - child.parentfield = field.fieldname; - child.idx = idx; - } - - getValidFields(doctype) { - return frappe.getMeta(doctype).getValidFields({ withChildren: false }); - } - - getFormattedDoc(fields, doc) { - // format for storage, going into the db - let formattedDoc = {}; - fields.map((field) => { - let value = doc[field.fieldname]; - formattedDoc[field.fieldname] = this.getFormattedValue(field, value); - }); - return formattedDoc; - } - - getFormattedValue(field, value) { - // format for storage, going into the db - const type = typeof value; - if (field.fieldtype === 'Currency') { - let currency = value; - - if (type === 'number' || type === 'string') { - currency = frappe.pesa(value); - } - - const currencyValue = currency.store; - if (typeof currencyValue !== 'string') { - throw new Error( - `invalid currencyValue '${currencyValue}' of type '${typeof currencyValue}' on converting from '${value}' of type '${type}'` - ); - } - - return currencyValue; - } - - if (value instanceof Date) { - if (field.fieldtype === 'Date') { - // date - return value.toISOString().substr(0, 10); - } else { - // datetime - return value.toISOString(); - } - } else if (field.fieldtype === 'Link' && !value) { - // empty value must be null to satisfy - // foreign key constraint - return null; - } else { - return value; - } - } - - applyBaseDocTypeFilters(doctype, doc) { - let meta = frappe.getMeta(doctype); - if (meta.filters) { - for (let fieldname in meta.filters) { - let value = meta.filters[fieldname]; - if (typeof value !== 'object') { - doc[fieldname] = value; - } - } - } - return doc; - } - - async deleteMany(doctype, names) { - for (const name of names) { - await this.delete(doctype, name); - } - } - - async delete(doctype, name) { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - await this.deleteOne(baseDoctype, name); - - // delete children - let tableFields = frappe.getMeta(doctype).getTableFields(); - for (let field of tableFields) { - await this.deleteChildren(field.childtype, name); - } - - this.triggerChange(doctype, name); - } - - async deleteOne(doctype, name) { - return this.knex(doctype) - .where('name', name) - .delete() - .then(() => { - this.clearValueCache(doctype, name); - }); - } - - deleteChildren(parenttype, parent) { - return this.knex(parenttype).where('parent', parent).delete(); - } - - async exists(doctype, name) { - return (await this.getValue(doctype, name)) ? true : false; - } - - async getValue(doctype, filters, fieldname = 'name') { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - if (typeof filters === 'string') { - filters = { name: filters }; - } - if (meta.filters) { - Object.assign(filters, meta.filters); - } - - let row = await this.getAll({ - doctype: baseDoctype, - fields: [fieldname], - filters: filters, - start: 0, - limit: 1, - orderBy: 'name', - order: 'asc', - }); - return row.length ? row[0][fieldname] : null; - } - - async setValue(doctype, name, fieldname, value) { - return await this.setValues(doctype, name, { - [fieldname]: value, - }); - } - - async setValues(doctype, name, fieldValuePair) { - let doc = Object.assign({}, fieldValuePair, { name }); - return this.updateOne(doctype, doc); - } - - async getCachedValue(doctype, name, fieldname) { - let value = this.cache.hget(`${doctype}:${name}`, fieldname); - if (value == null) { - value = await this.getValue(doctype, name, fieldname); - } - return value; - } - - async getAll({ - doctype, - fields, - filters, - start, - limit, - groupBy, - orderBy = 'creation', - order = 'desc', - } = {}) { - let meta = frappe.getMeta(doctype); - let baseDoctype = meta.getBaseDocType(); - if (!fields) { - fields = meta.getKeywordFields(); - fields.push('name'); - } - if (typeof fields === 'string') { - fields = [fields]; - } - if (meta.filters) { - filters = Object.assign({}, filters, meta.filters); - } - - let builder = this.knex.select(fields).from(baseDoctype); - - this.applyFiltersToBuilder(builder, filters); - - if (orderBy) { - builder.orderBy(orderBy, order); - } - - if (groupBy) { - builder.groupBy(groupBy); - } - - if (start) { - builder.offset(start); - } - - if (limit) { - builder.limit(limit); - } - - const docs = await builder; - return docs.map((doc) => this.getDocFormattedDoc(meta.fields, doc)); - } - - applyFiltersToBuilder(builder, filters) { - // {"status": "Open"} => `status = "Open"` - - // {"status": "Open", "name": ["like", "apple%"]} - // => `status="Open" and name like "apple%" - - // {"date": [">=", "2017-09-09", "<=", "2017-11-01"]} - // => `date >= 2017-09-09 and date <= 2017-11-01` - - let filtersArray = []; - - for (let field in filters) { - let value = filters[field]; - let operator = '='; - let comparisonValue = value; - - if (Array.isArray(value)) { - operator = value[0]; - comparisonValue = value[1]; - operator = operator.toLowerCase(); - - if (operator === 'includes') { - operator = 'like'; - } - - if (operator === 'like' && !comparisonValue.includes('%')) { - comparisonValue = `%${comparisonValue}%`; - } - } - - filtersArray.push([field, operator, comparisonValue]); - - if (Array.isArray(value) && value.length > 2) { - // multiple conditions - let operator = value[2]; - let comparisonValue = value[3]; - filtersArray.push([field, operator, comparisonValue]); - } - } - - filtersArray.map((filter) => { - const [field, operator, comparisonValue] = filter; - if (operator === '=') { - builder.where(field, comparisonValue); - } else { - builder.where(field, operator, comparisonValue); - } - }); - } - - run(query, params) { - // run query - return this.sql(query, params); - } - - sql(query, params) { - // run sql - return this.knex.raw(query, params); - } - - async commit() { - try { - await this.sql('commit'); - } catch (e) { - if (e.type !== frappe.errors.CannotCommitError) { - throw e; - } - } - } - - clearValueCache(doctype, name) { - let cacheKey = `${doctype}:${name}`; - this.cache.hclear(cacheKey); - } - - getColumnType(field) { - return this.typeMap[field.fieldtype]; - } - - getError(err) { - return frappe.errors.DatabaseError; - } - - initTypeMap() { - this.typeMap = {}; - } - - executePostDbConnect() { - frappe.initializeMoneyMaker(); - } -} diff --git a/frappe/backends/sqlite.js b/frappe/backends/sqlite.js deleted file mode 100644 index 0d76cf5b..00000000 --- a/frappe/backends/sqlite.js +++ /dev/null @@ -1,143 +0,0 @@ -import frappe from 'frappe'; -import Database from './database'; - -export default class SqliteDatabase extends Database { - constructor({ dbPath }) { - super(); - this.dbPath = dbPath; - this.connectionParams = { - client: 'sqlite3', - connection: { - filename: this.dbPath, - }, - pool: { - afterCreate(conn, done) { - conn.run('PRAGMA foreign_keys=ON'); - done(); - }, - }, - useNullAsDefault: true, - asyncStackTraces: process.env.NODE_ENV === 'development', - }; - } - - async addForeignKeys(doctype, newForeignKeys) { - await this.sql('PRAGMA foreign_keys=OFF'); - await this.sql('BEGIN TRANSACTION'); - - const tempName = 'TEMP' + doctype; - - // create temp table - await this.createTable(doctype, tempName); - - try { - // copy from old to new table - await this.knex(tempName).insert(this.knex.select().from(doctype)); - } catch (err) { - await this.sql('ROLLBACK'); - await this.sql('PRAGMA foreign_keys=ON'); - - const rows = await this.knex.select().from(doctype); - await this.prestigeTheTable(doctype, rows); - return; - } - - // drop old table - await this.knex.schema.dropTable(doctype); - - // rename new table - await this.knex.schema.renameTable(tempName, doctype); - - await this.sql('COMMIT'); - await this.sql('PRAGMA foreign_keys=ON'); - } - - removeColumns() { - // pass - } - - async getTableColumns(doctype) { - return (await this.sql(`PRAGMA table_info(${doctype})`)).map((d) => d.name); - } - - async getForeignKeys(doctype) { - return (await this.sql(`PRAGMA foreign_key_list(${doctype})`)).map( - (d) => d.from - ); - } - - initTypeMap() { - // prettier-ignore - this.typeMap = { - 'AutoComplete': 'text', - 'Currency': 'text', - 'Int': 'integer', - 'Float': 'float', - 'Percent': 'float', - 'Check': 'integer', - 'Small Text': 'text', - 'Long Text': 'text', - 'Code': 'text', - 'Text Editor': 'text', - 'Date': 'text', - 'Datetime': 'text', - 'Time': 'text', - 'Text': 'text', - 'Data': 'text', - 'Link': 'text', - 'DynamicLink': 'text', - 'Password': 'text', - 'Select': 'text', - 'Read Only': 'text', - 'File': 'text', - 'Attach': 'text', - 'AttachImage': 'text', - 'Signature': 'text', - 'Color': 'text', - 'Barcode': 'text', - 'Geolocation': 'text' - }; - } - - getError(err) { - let errorType = frappe.errors.DatabaseError; - if (err.message.includes('FOREIGN KEY')) { - errorType = frappe.errors.LinkValidationError; - } - if (err.message.includes('SQLITE_ERROR: cannot commit')) { - errorType = frappe.errors.CannotCommitError; - } - if (err.message.includes('SQLITE_CONSTRAINT: UNIQUE constraint failed:')) { - errorType = frappe.errors.DuplicateEntryError; - } - return errorType; - } - - async prestigeTheTable(tableName, tableRows) { - const max = 200; - - // Alter table hacx for sqlite in case of schema change. - const tempName = `__${tableName}`; - await this.knex.schema.dropTableIfExists(tempName); - - await this.knex.raw('PRAGMA foreign_keys=OFF'); - await this.createTable(tableName, tempName); - - if (tableRows.length > 200) { - const fi = Math.floor(tableRows.length / max); - for (let i = 0; i <= fi; i++) { - const rowSlice = tableRows.slice(i * max, i + 1 * max); - if (rowSlice.length === 0) { - break; - } - await this.knex.batchInsert(tempName, rowSlice); - } - } else { - await this.knex.batchInsert(tempName, tableRows); - } - - await this.knex.schema.dropTable(tableName); - await this.knex.schema.renameTable(tempName, tableName); - await this.knex.raw('PRAGMA foreign_keys=ON'); - } -} diff --git a/frappe/common/errors.js b/frappe/common/errors.js deleted file mode 100644 index 3d1f4da2..00000000 --- a/frappe/common/errors.js +++ /dev/null @@ -1,101 +0,0 @@ -const frappe = require('frappe'); - -class BaseError extends Error { - constructor(statusCode, message) { - super(message); - this.name = 'BaseError'; - this.statusCode = statusCode; - this.message = message; - } -} - -class ValidationError extends BaseError { - constructor(message) { - super(417, message); - this.name = 'ValidationError'; - } -} - -class NotFoundError extends BaseError { - constructor(message) { - super(404, message); - this.name = 'NotFoundError'; - } -} - -class ForbiddenError extends BaseError { - constructor(message) { - super(403, message); - this.name = 'ForbiddenError'; - } -} - -class DuplicateEntryError extends ValidationError { - constructor(message) { - super(message); - this.name = 'DuplicateEntryError'; - } -} - -class LinkValidationError extends ValidationError { - constructor(message) { - super(message); - this.name = 'LinkValidationError'; - } -} - -class MandatoryError extends ValidationError { - constructor(message) { - super(message); - this.name = 'MandatoryError'; - } -} - -class DatabaseError extends BaseError { - constructor(message) { - super(500, message); - this.name = 'DatabaseError'; - } -} - -class CannotCommitError extends DatabaseError { - constructor(message) { - super(message); - this.name = 'CannotCommitError'; - } -} - -class ValueError extends ValidationError {} -class Conflict extends ValidationError {} -class InvalidFieldError extends ValidationError {} - -function throwError(message, error = 'ValidationError') { - const errorClass = { - ValidationError: ValidationError, - NotFoundError: NotFoundError, - ForbiddenError: ForbiddenError, - ValueError: ValueError, - Conflict: Conflict, - }; - const err = new errorClass[error](message); - frappe.events.trigger('throw', { message, stackTrace: err.stack }); - throw err; -} - -frappe.throw = throwError; - -module.exports = { - BaseError, - ValidationError, - ValueError, - Conflict, - NotFoundError, - ForbiddenError, - DuplicateEntryError, - LinkValidationError, - DatabaseError, - CannotCommitError, - MandatoryError, - InvalidFieldError, - throw: throwError, -}; diff --git a/frappe/common/index.js b/frappe/common/index.js deleted file mode 100644 index ac11c0ce..00000000 --- a/frappe/common/index.js +++ /dev/null @@ -1,13 +0,0 @@ -export default async function initLibs(frappe) { - const utils = await import('../utils'); - const format = await import('../utils/format'); - const errors = await import('./errors'); - const BaseMeta = await import('frappe/model/meta'); - const BaseDocument = await import('frappe/model/document'); - - Object.assign(frappe, utils.default); - Object.assign(frappe, format.default); - frappe.errors = errors.default; - frappe.BaseDocument = BaseDocument.default; - frappe.BaseMeta = BaseMeta.default; -} diff --git a/frappe/index.js b/frappe/index.js deleted file mode 100644 index 454445f2..00000000 --- a/frappe/index.js +++ /dev/null @@ -1,391 +0,0 @@ -import initLibs from 'frappe/common'; -import { getMoneyMaker } from 'pesa'; -import { markRaw } from 'vue'; -import utils from './utils'; -import { - DEFAULT_DISPLAY_PRECISION, - DEFAULT_INTERNAL_PRECISION, -} from './utils/consts'; -import Observable from './utils/observable'; -import { t, T } from './utils/translation'; - -class Frappe { - isElectron = false; - isServer = false; - - async initializeAndRegister(customModels = {}, force = false) { - this.init(force); - await initLibs(this); - const coreModels = await import('frappe/models'); - this.registerModels(coreModels.default); - this.registerModels(customModels); - } - - async initializeMoneyMaker(currency) { - currency ??= 'XXX'; - - // to be called after db initialization - const values = - (await frappe.db?.getSingleValues( - { - fieldname: 'internalPrecision', - parent: 'SystemSettings', - }, - { - fieldname: 'displayPrecision', - parent: 'SystemSettings', - } - )) ?? []; - - let { internalPrecision: precision, displayPrecision: display } = - values.reduce((acc, { fieldname, value }) => { - acc[fieldname] = value; - return acc; - }, {}); - - if (typeof precision === 'undefined') { - precision = DEFAULT_INTERNAL_PRECISION; - } - - if (typeof precision === 'string') { - precision = parseInt(precision); - } - - if (typeof display === 'undefined') { - display = DEFAULT_DISPLAY_PRECISION; - } - - if (typeof display === 'string') { - display = parseInt(display); - } - - this.pesa = getMoneyMaker({ - currency, - precision, - display, - wrapper: markRaw, - }); - } - - init(force) { - if (this._initialized && !force) return; - - // Initialize Config - this.config = { - serverURL: '', - backend: 'sqlite', - port: 8000, - }; - - // Initialize Globals - this.metaCache = {}; - this.models = {}; - this.forms = {}; - this.views = {}; - this.flags = {}; - this.methods = {}; - this.errorLog = []; - - // temp params while calling routes - this.temp = {}; - this.params = {}; - - this.docs = new Observable(); - this.events = new Observable(); - this._initialized = true; - } - - registerModels(models) { - // register models from app/models/index.js - for (let doctype in models) { - let metaDefinition = models[doctype]; - if (!metaDefinition.name) { - throw new Error(`Name is mandatory for ${doctype}`); - } - if (metaDefinition.name !== doctype) { - throw new Error( - `Model name mismatch for ${doctype}: ${metaDefinition.name}` - ); - } - let fieldnames = (metaDefinition.fields || []) - .map((df) => df.fieldname) - .sort(); - let duplicateFieldnames = utils.getDuplicates(fieldnames); - if (duplicateFieldnames.length > 0) { - throw new Error( - `Duplicate fields in ${doctype}: ${duplicateFieldnames.join(', ')}` - ); - } - - this.models[doctype] = metaDefinition; - } - } - - getModels(filterFunction) { - let models = []; - for (let doctype in this.models) { - models.push(this.models[doctype]); - } - return filterFunction ? models.filter(filterFunction) : models; - } - - registerView(view, name, module) { - if (!this.views[view]) this.views[view] = {}; - this.views[view][name] = module; - } - - registerMethod({ method, handler }) { - this.methods[method] = handler; - if (this.app) { - // add to router if client-server - this.app.post( - `/api/method/${method}`, - this.asyncHandler(async function (request, response) { - let data = await handler(request.body); - if (data === undefined) { - data = {}; - } - return response.json(data); - }) - ); - } - } - - async call({ method, args }) { - if (this.isServer) { - if (this.methods[method]) { - return await this.methods[method](args); - } else { - throw new Error(`${method} not found`); - } - } - - let url = `/api/method/${method}`; - let response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(args || {}), - }); - return await response.json(); - } - - addToCache(doc) { - if (!this.docs) return; - - // add to `docs` cache - if (doc.doctype && doc.name) { - if (!this.docs[doc.doctype]) { - this.docs[doc.doctype] = {}; - } - this.docs[doc.doctype][doc.name] = doc; - - // singles available as first level objects too - if (doc.doctype === doc.name) { - this[doc.name] = doc; - } - - // propogate change to `docs` - doc.on('change', (params) => { - this.docs.trigger('change', params); - }); - } - } - - removeFromCache(doctype, name) { - try { - delete this.docs[doctype][name]; - } catch (e) { - console.warn(`Document ${doctype} ${name} does not exist`); - } - } - - isDirty(doctype, name) { - return ( - (this.docs && - this.docs[doctype] && - this.docs[doctype][name] && - this.docs[doctype][name]._dirty) || - false - ); - } - - getDocFromCache(doctype, name) { - if (this.docs && this.docs[doctype] && this.docs[doctype][name]) { - return this.docs[doctype][name]; - } - } - - getMeta(doctype) { - if (!this.metaCache[doctype]) { - let model = this.models[doctype]; - if (!model) { - throw new Error(`${doctype} is not a registered doctype`); - } - - let metaClass = model.metaClass || this.BaseMeta; - this.metaCache[doctype] = new metaClass(model); - } - - return this.metaCache[doctype]; - } - - async getDoc(doctype, name, options = { skipDocumentCache: false }) { - let doc = options.skipDocumentCache - ? null - : this.getDocFromCache(doctype, name); - if (!doc) { - doc = new (this.getDocumentClass(doctype))({ - doctype: doctype, - name: name, - }); - await doc.load(); - this.addToCache(doc); - } - return doc; - } - - getDocumentClass(doctype) { - const meta = this.getMeta(doctype); - return meta.documentClass || this.BaseDocument; - } - - async getSingle(doctype) { - return await this.getDoc(doctype, doctype); - } - - async getDuplicate(doc) { - const newDoc = await this.getNewDoc(doc.doctype); - for (let field of this.getMeta(doc.doctype).getValidFields()) { - if (['name', 'submitted'].includes(field.fieldname)) continue; - if (field.fieldtype === 'Table') { - newDoc[field.fieldname] = (doc[field.fieldname] || []).map((d) => { - let newd = Object.assign({}, d); - newd.name = ''; - return newd; - }); - } else { - newDoc[field.fieldname] = doc[field.fieldname]; - } - } - return newDoc; - } - - getNewDoc(doctype, cacheDoc = true) { - let doc = this.newDoc({ doctype: doctype }); - doc._notInserted = true; - doc.name = frappe.getRandomString(); - if (cacheDoc) { - this.addToCache(doc); - } - return doc; - } - - async newCustomDoc(fields) { - let doc = new this.BaseDocument({ isCustom: 1, fields }); - doc._notInserted = true; - doc.name = this.getRandomString(); - this.addToCache(doc); - return doc; - } - - createMeta(fields) { - let meta = new this.BaseMeta({ isCustom: 1, fields }); - return meta; - } - - newDoc(data) { - let doc = new (this.getDocumentClass(data.doctype))(data); - doc.setDefaults(); - return doc; - } - - async insert(data) { - return await this.newDoc(data).insert(); - } - - async syncDoc(data) { - let doc; - if (await this.db.exists(data.doctype, data.name)) { - doc = await this.getDoc(data.doctype, data.name); - Object.assign(doc, data); - await doc.update(); - } else { - doc = this.newDoc(data); - await doc.insert(); - } - } - - // only for client side - async login(email, password) { - if (email === 'Administrator') { - this.session = { - user: 'Administrator', - }; - return; - } - - let response = await fetch(this.getServerURL() + '/api/login', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, password }), - }); - - if (response.status === 200) { - const res = await response.json(); - - this.session = { - user: email, - token: res.token, - }; - - return res; - } - - return response; - } - - async signup(email, fullName, password) { - let response = await fetch(this.getServerURL() + '/api/signup', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, fullName, password }), - }); - - if (response.status === 200) { - return await response.json(); - } - - return response; - } - - getServerURL() { - return this.config.serverURL || ''; - } - - close() { - this.db.close(); - - if (this.server) { - this.server.close(); - } - } - - store = { - isDevelopment: false, - appVersion: '', - }; - t = t; - T = T; -} - -export { T, t }; -export default new Frappe(); diff --git a/frappe/model/document.js b/frappe/model/document.js deleted file mode 100644 index d91f983d..00000000 --- a/frappe/model/document.js +++ /dev/null @@ -1,762 +0,0 @@ -import telemetry from '@/telemetry/telemetry'; -import { Verb } from '@/telemetry/types'; -import frappe from 'frappe'; -import Observable from 'frappe/utils/observable'; -import { DEFAULT_INTERNAL_PRECISION } from '../utils/consts'; -import { isPesa } from '../utils/index'; -import { setName } from './naming'; - -export default class Document extends Observable { - constructor(data) { - super(); - this.fetchValuesCache = {}; - this.flags = {}; - this.setup(); - this.setValues(data); - } - - setup() { - // add listeners - } - - setValues(data) { - for (let fieldname in data) { - let value = data[fieldname]; - if (fieldname.startsWith('_')) { - // private property - this[fieldname] = value; - } else if (Array.isArray(value)) { - for (let row of value) { - this.push(fieldname, row); - } - } else { - this[fieldname] = value; - } - } - // set unset fields as null - for (let field of this.meta.getValidFields()) { - // check for null or undefined - if (this[field.fieldname] == null) { - this[field.fieldname] = null; - } - } - } - - get meta() { - if (this.isCustom) { - this._meta = frappe.createMeta(this.fields); - } - if (!this._meta) { - this._meta = frappe.getMeta(this.doctype); - } - return this._meta; - } - - async getSettings() { - if (!this._settings) { - this._settings = await frappe.getSingle(this.meta.settings); - } - return this._settings; - } - - // set value and trigger change - async set(fieldname, value) { - if (typeof fieldname === 'object') { - const valueDict = fieldname; - for (let fieldname in valueDict) { - await this.set(fieldname, valueDict[fieldname]); - } - return; - } - - if (fieldname === 'numberSeries' && !this._notInserted) { - return; - } - - if (this[fieldname] !== value) { - this._dirty = true; - // if child is dirty, parent is dirty too - if (this.meta.isChild && this.parentdoc) { - this.parentdoc._dirty = true; - } - - if (Array.isArray(value)) { - this[fieldname] = []; - value.forEach((row, i) => { - this.append(fieldname, row); - row.idx = i; - }); - } else { - await this.validateField(fieldname, value); - this[fieldname] = value; - } - - // always run applyChange from the parentdoc - if (this.meta.isChild && this.parentdoc) { - await this.applyChange(fieldname); - await this.parentdoc.applyChange(this.parentfield); - } else { - await this.applyChange(fieldname); - } - } - } - - async applyChange(fieldname) { - await this.applyFormula(fieldname); - this.roundFloats(); - await this.trigger('change', { - doc: this, - changed: fieldname, - }); - } - - setDefaults() { - for (let field of this.meta.fields) { - if (this[field.fieldname] == null) { - let defaultValue = getPreDefaultValues(field.fieldtype); - - if (typeof field.default === 'function') { - defaultValue = field.default(this); - } else if (field.default !== undefined) { - defaultValue = field.default; - } - - if (field.fieldtype === 'Currency' && !isPesa(defaultValue)) { - defaultValue = frappe.pesa(defaultValue); - } - - this[field.fieldname] = defaultValue; - } - } - - if (this.meta.basedOn && this.meta.filters) { - this.setValues(this.meta.filters); - } - } - - castValues() { - for (let field of this.meta.fields) { - let value = this[field.fieldname]; - if (value == null) { - continue; - } - if (['Int', 'Check'].includes(field.fieldtype)) { - value = parseInt(value, 10); - } else if (field.fieldtype === 'Float') { - value = parseFloat(value); - } else if (field.fieldtype === 'Currency' && !isPesa(value)) { - value = frappe.pesa(value); - } - this[field.fieldname] = value; - } - } - - setKeywords() { - let keywords = []; - for (let fieldname of this.meta.getKeywordFields()) { - keywords.push(this[fieldname]); - } - this.keywords = keywords.join(', '); - } - - append(key, document = {}) { - // push child row and trigger change - this.push(key, document); - this._dirty = true; - this.applyChange(key); - } - - push(key, document = {}) { - // push child row without triggering change - if (!this[key]) { - this[key] = []; - } - this[key].push(this._initChild(document, key)); - } - - _initChild(data, key) { - if (data instanceof Document) { - return data; - } - - data.doctype = this.meta.getField(key).childtype; - data.parent = this.name; - data.parenttype = this.doctype; - data.parentfield = key; - data.parentdoc = this; - - if (!data.idx) { - data.idx = (this[key] || []).length; - } - - if (!data.name) { - data.name = frappe.getRandomString(); - } - - const childDoc = new Document(data); - childDoc.setDefaults(); - return childDoc; - } - - async validateInsert() { - this.validateMandatory(); - await this.validateFields(); - } - - validateMandatory() { - let checkForMandatory = [this]; - let tableFields = this.meta.fields.filter((df) => df.fieldtype === 'Table'); - tableFields.map((df) => { - let rows = this[df.fieldname]; - checkForMandatory = [...checkForMandatory, ...rows]; - }); - - let missingMandatory = checkForMandatory - .map((doc) => getMissingMandatory(doc)) - .filter(Boolean); - - if (missingMandatory.length > 0) { - let fields = missingMandatory.join('\n'); - let message = frappe.t`Value missing for ${fields}`; - throw new frappe.errors.MandatoryError(message); - } - - function getMissingMandatory(doc) { - let mandatoryFields = doc.meta.fields.filter((df) => { - if (df.required instanceof Function) { - return df.required(doc); - } - return df.required; - }); - let message = mandatoryFields - .filter((df) => { - let value = doc[df.fieldname]; - if (df.fieldtype === 'Table') { - return value == null || value.length === 0; - } - return value == null || value === ''; - }) - .map((df) => { - return `"${df.label}"`; - }) - .join(', '); - - if (message && doc.meta.isChild) { - let parentfield = doc.parentdoc.meta.getField(doc.parentfield); - message = `${parentfield.label} Row ${doc.idx + 1}: ${message}`; - } - - return message; - } - } - - async validateFields() { - let fields = this.meta.fields; - for (let field of fields) { - await this.validateField(field.fieldname, this.get(field.fieldname)); - } - } - - async validateField(key, value) { - let field = this.meta.getField(key); - if (!field) { - throw new frappe.errors.InvalidFieldError(`Invalid field ${key}`); - } - if (field.fieldtype == 'Select') { - this.meta.validateSelect(field, value); - } - if (field.validate && value != null) { - let validator = null; - if (typeof field.validate === 'object') { - validator = this.getValidateFunction(field.validate); - } - if (typeof field.validate === 'function') { - validator = field.validate; - } - if (validator) { - await validator(value, this); - } - } - } - - getValidateFunction(validator) { - let functions = { - email(value) { - let isValid = /(.+)@(.+){2,}\.(.+){2,}/.test(value); - if (!isValid) { - throw new frappe.errors.ValidationError(`Invalid email: ${value}`); - } - }, - phone(value) { - let isValid = /[+]{0,1}[\d ]+/.test(value); - if (!isValid) { - throw new frappe.errors.ValidationError(`Invalid phone: ${value}`); - } - }, - }; - - return functions[validator.type]; - } - - getValidDict() { - let data = {}; - for (let field of this.meta.getValidFields()) { - let value = this[field.fieldname]; - if (Array.isArray(value)) { - value = value.map((doc) => - doc.getValidDict ? doc.getValidDict() : doc - ); - } - data[field.fieldname] = value; - } - return data; - } - - setStandardValues() { - // set standard values on server-side only - if (frappe.isServer) { - if (this.isSubmittable && this.submitted == null) { - this.submitted = 0; - } - - let now = new Date().toISOString(); - if (!this.owner) { - this.owner = frappe.session.user; - } - - if (!this.creation) { - this.creation = now; - } - - this.updateModified(); - } - } - - updateModified() { - if (frappe.isServer) { - let now = new Date().toISOString(); - this.modifiedBy = frappe.session.user; - this.modified = now; - } - } - - async load() { - let data = await frappe.db.get(this.doctype, this.name); - if (data && data.name) { - this.syncValues(data); - if (this.meta.isSingle) { - this.setDefaults(); - this.castValues(); - } - await this.loadLinks(); - } else { - throw new frappe.errors.NotFoundError( - `Not Found: ${this.doctype} ${this.name}` - ); - } - } - - async loadLinks() { - this._links = {}; - let inlineLinks = this.meta.fields.filter((df) => df.inline); - for (let df of inlineLinks) { - await this.loadLink(df.fieldname); - } - } - - async loadLink(fieldname) { - this._links = this._links || {}; - let df = this.meta.getField(fieldname); - if (this[df.fieldname]) { - this._links[df.fieldname] = await frappe.getDoc( - df.target, - this[df.fieldname] - ); - } - } - - getLink(fieldname) { - return this._links ? this._links[fieldname] : null; - } - - syncValues(data) { - this.clearValues(); - this.setValues(data); - this._dirty = false; - this.trigger('change', { - doc: this, - }); - } - - clearValues() { - let toClear = ['_dirty', '_notInserted'].concat( - this.meta.getValidFields().map((df) => df.fieldname) - ); - for (let key of toClear) { - this[key] = null; - } - } - - setChildIdx() { - // renumber children - for (let field of this.meta.getValidFields()) { - if (field.fieldtype === 'Table') { - for (let i = 0; i < (this[field.fieldname] || []).length; i++) { - this[field.fieldname][i].idx = i; - } - } - } - } - - async compareWithCurrentDoc() { - if (frappe.isServer && !this.isNew()) { - let currentDoc = await frappe.db.get(this.doctype, this.name); - - // check for conflict - if (currentDoc && this.modified != currentDoc.modified) { - throw new frappe.errors.Conflict( - frappe.t`Document ${this.doctype} ${this.name} has been modified after loading` - ); - } - - if (this.submitted && !this.meta.isSubmittable) { - throw new frappe.errors.ValidationError( - frappe.t`Document type ${this.doctype} is not submittable` - ); - } - - // set submit action flag - this.flags = {}; - if (this.submitted && !currentDoc.submitted) { - this.flags.submitAction = true; - } - - if (currentDoc.submitted && !this.submitted) { - this.flags.revertAction = true; - } - } - } - - async applyFormula(fieldname) { - if (!this.meta.hasFormula()) { - return false; - } - - let doc = this; - let changed = false; - - // children - for (let tablefield of this.meta.getTableFields()) { - let formulaFields = frappe - .getMeta(tablefield.childtype) - .getFormulaFields(); - if (formulaFields.length) { - const value = this[tablefield.fieldname] || []; - for (let row of value) { - for (let field of formulaFields) { - if (shouldApplyFormula(field, row)) { - let val = await this.getValueFromFormula(field, row); - let previousVal = row[field.fieldname]; - if (val !== undefined && previousVal !== val) { - row[field.fieldname] = val; - changed = true; - } - } - } - } - } - } - - // parent or child row - for (let field of this.meta.getFormulaFields()) { - if (shouldApplyFormula(field, doc)) { - let previousVal = doc[field.fieldname]; - let val = await this.getValueFromFormula(field, doc); - if (val !== undefined && previousVal !== val) { - doc[field.fieldname] = val; - changed = true; - } - } - } - - return changed; - - function shouldApplyFormula(field, doc) { - if (field.readOnly) { - return true; - } - if ( - fieldname && - field.formulaDependsOn && - field.formulaDependsOn.includes(fieldname) - ) { - return true; - } - - if (!frappe.isServer || frappe.isElectron) { - if (doc[field.fieldname] == null || doc[field.fieldname] == '') { - return true; - } - } - return false; - } - } - - async getValueFromFormula(field, doc) { - let value; - - if (doc.meta.isChild) { - value = await field.formula(doc, doc.parentdoc); - } else { - value = await field.formula(doc); - } - - if (value === undefined) { - return; - } - - if ('Float' === field.fieldtype) { - value = this.round(value, field); - } - - if (field.fieldtype === 'Table' && Array.isArray(value)) { - value = value.map((row) => { - let doc = this._initChild(row, field.fieldname); - doc.roundFloats(); - return doc; - }); - } - - return value; - } - - roundFloats() { - let fields = this.meta - .getValidFields() - .filter((df) => ['Float', 'Table'].includes(df.fieldtype)); - - for (let df of fields) { - let value = this[df.fieldname]; - if (value == null) { - continue; - } - // child - if (Array.isArray(value)) { - value.map((row) => row.roundFloats()); - continue; - } - // field - let roundedValue = this.round(value, df); - if (roundedValue && value !== roundedValue) { - this[df.fieldname] = roundedValue; - } - } - } - - async commit() { - // re-run triggers - this.setKeywords(); - this.setChildIdx(); - await this.applyFormula(); - await this.trigger('validate'); - } - - async insert() { - await setName(this); - this.setStandardValues(); - await this.commit(); - await this.validateInsert(); - await this.trigger('beforeInsert'); - - let oldName = this.name; - const data = await frappe.db.insert(this.doctype, this.getValidDict()); - this.syncValues(data); - - if (oldName !== this.name) { - frappe.removeFromCache(this.doctype, oldName); - } - - await this.trigger('afterInsert'); - await this.trigger('afterSave'); - - telemetry.log(Verb.Created, this.doctype); - return this; - } - - async update(...args) { - if (args.length) { - await this.set(...args); - } - await this.compareWithCurrentDoc(); - await this.commit(); - await this.trigger('beforeUpdate'); - - // before submit - if (this.flags.submitAction) await this.trigger('beforeSubmit'); - if (this.flags.revertAction) await this.trigger('beforeRevert'); - - // update modifiedBy and modified - this.updateModified(); - - const data = await frappe.db.update(this.doctype, this.getValidDict()); - this.syncValues(data); - - await this.trigger('afterUpdate'); - await this.trigger('afterSave'); - - // after submit - if (this.flags.submitAction) await this.trigger('afterSubmit'); - if (this.flags.revertAction) await this.trigger('afterRevert'); - - return this; - } - - async insertOrUpdate() { - if (this._notInserted) { - return await this.insert(); - } else { - return await this.update(); - } - } - - async delete() { - await this.trigger('beforeDelete'); - await frappe.db.delete(this.doctype, this.name); - await this.trigger('afterDelete'); - - telemetry.log(Verb.Deleted, this.doctype); - } - - async submitOrRevert(isSubmit) { - const wasSubmitted = this.submitted; - this.submitted = isSubmit; - try { - await this.update(); - } catch (e) { - this.submitted = wasSubmitted; - throw e; - } - } - - async submit() { - this.cancelled = 0; - await this.submitOrRevert(1); - } - - async revert() { - await this.submitOrRevert(0); - } - - async rename(newName) { - await this.trigger('beforeRename'); - await frappe.db.rename(this.doctype, this.name, newName); - this.name = newName; - await this.trigger('afterRename'); - } - - // trigger methods on the class if they match - // with the trigger name - async trigger(event, params) { - if (this[event]) { - await this[event](params); - } - await super.trigger(event, params); - } - - // helper functions - getSum(tablefield, childfield, convertToFloat = true) { - const sum = (this[tablefield] || []) - .map((d) => { - const value = d[childfield] ?? 0; - if (!isPesa(value)) { - try { - return frappe.pesa(value); - } catch (err) { - err.message += ` value: '${value}' of type: ${typeof value}, fieldname: '${tablefield}', childfield: '${childfield}'`; - throw err; - } - } - return value; - }) - .reduce((a, b) => a.add(b), frappe.pesa(0)); - - if (convertToFloat) { - return sum.float; - } - return sum; - } - - getFrom(doctype, name, fieldname) { - if (!name) return ''; - return frappe.db.getCachedValue(doctype, name, fieldname); - } - - round(value, df = null) { - if (typeof df === 'string') { - df = this.meta.getField(df); - } - const precision = - frappe.SystemSettings.internalPrecision ?? DEFAULT_INTERNAL_PRECISION; - return frappe.pesa(value).clip(precision).float; - } - - isNew() { - return this._notInserted; - } - - getFieldMetaMap() { - return this.meta.fields.reduce((obj, meta) => { - obj[meta.fieldname] = meta; - return obj; - }, {}); - } - - async duplicate() { - const updateMap = {}; - const fieldValueMap = this.getValidDict(); - const keys = this.meta.fields.map((f) => f.fieldname); - for (const key of keys) { - let value = fieldValueMap[key]; - if (!value) { - continue; - } - - if (isPesa(value)) { - value = value.copy(); - } - - if (value instanceof Array) { - value.forEach((row) => { - delete row.name; - delete row.parent; - }); - } - - updateMap[key] = value; - } - - if (this.numberSeries) { - delete updateMap.name; - } else { - updateMap.name = updateMap.name + ' CPY'; - } - - const doc = frappe.getNewDoc(this.doctype, false); - await doc.set(updateMap); - await doc.insert(); - } -} - -function getPreDefaultValues(fieldtype) { - switch (fieldtype) { - case 'Table': - return []; - case 'Currency': - return frappe.pesa(0.0); - case 'Int': - case 'Float': - return 0; - default: - return null; - } -} diff --git a/frappe/model/index.js b/frappe/model/index.js deleted file mode 100644 index 415a5005..00000000 --- a/frappe/model/index.js +++ /dev/null @@ -1,114 +0,0 @@ -const cloneDeep = require('lodash/cloneDeep'); - -module.exports = { - extend: (base, target, options = {}) => { - base = cloneDeep(base); - const fieldsToMerge = (target.fields || []).map(df => df.fieldname); - const fieldsToRemove = options.skipFields || []; - const overrideProps = options.overrideProps || []; - for (let prop of overrideProps) { - if (base.hasOwnProperty(prop)) { - delete base[prop]; - } - } - - let mergeFields = (baseFields, targetFields) => { - let fields = cloneDeep(baseFields); - fields = fields - .filter(df => !fieldsToRemove.includes(df.fieldname)) - .map(df => { - if (fieldsToMerge.includes(df.fieldname)) { - let copy = cloneDeep(df); - return Object.assign( - copy, - targetFields.find(tdf => tdf.fieldname === df.fieldname) - ); - } - return df; - }); - let fieldsAdded = fields.map(df => df.fieldname); - let fieldsToAdd = targetFields.filter( - df => !fieldsAdded.includes(df.fieldname) - ); - return fields.concat(fieldsToAdd); - }; - - let fields = mergeFields(base.fields, target.fields || []); - let out = Object.assign(base, target); - out.fields = fields; - - return out; - }, - commonFields: [ - { - fieldname: 'name', - fieldtype: 'Data', - required: 1 - } - ], - submittableFields: [ - { - fieldname: 'submitted', - fieldtype: 'Check', - required: 1 - } - ], - parentFields: [ - { - fieldname: 'owner', - fieldtype: 'Data', - required: 1 - }, - { - fieldname: 'modifiedBy', - fieldtype: 'Data', - required: 1 - }, - { - fieldname: 'creation', - fieldtype: 'Datetime', - required: 1 - }, - { - fieldname: 'modified', - fieldtype: 'Datetime', - required: 1 - }, - { - fieldname: 'keywords', - fieldtype: 'Text' - } - ], - childFields: [ - { - fieldname: 'idx', - fieldtype: 'Int', - required: 1 - }, - { - fieldname: 'parent', - fieldtype: 'Data', - required: 1 - }, - { - fieldname: 'parenttype', - fieldtype: 'Data', - required: 1 - }, - { - fieldname: 'parentfield', - fieldtype: 'Data', - required: 1 - } - ], - treeFields: [ - { - fieldname: 'lft', - fieldtype: 'Int' - }, - { - fieldname: 'rgt', - fieldtype: 'Int' - } - ] -}; diff --git a/frappe/model/meta.js b/frappe/model/meta.js deleted file mode 100644 index 5b3dc5c4..00000000 --- a/frappe/model/meta.js +++ /dev/null @@ -1,328 +0,0 @@ -import frappe from 'frappe'; -import { indicators as indicatorColor } from '../../src/colors'; -import Document from './document'; -import model from './index'; - -export default class BaseMeta extends Document { - constructor(data) { - if (data.basedOn) { - let config = frappe.models[data.basedOn]; - Object.assign(data, config, { - name: data.name, - label: data.label, - filters: data.filters, - }); - } - super(data); - this.setDefaultIndicators(); - if (this.setupMeta) { - this.setupMeta(); - } - if (!this.titleField) { - this.titleField = 'name'; - } - } - - setValues(data) { - Object.assign(this, data); - this.processFields(); - } - - processFields() { - // add name field - if (!this.fields.find((df) => df.fieldname === 'name') && !this.isSingle) { - this.fields = [ - { - label: frappe.t`ID`, - fieldname: 'name', - fieldtype: 'Data', - required: 1, - readOnly: 1, - }, - ].concat(this.fields); - } - - this.fields = this.fields.map((df) => { - // name field is always required - if (df.fieldname === 'name') { - df.required = 1; - } - - return df; - }); - } - - hasField(fieldname) { - return this.getField(fieldname) ? true : false; - } - - getField(fieldname) { - if (!this._field_map) { - this._field_map = {}; - for (let field of this.fields) { - this._field_map[field.fieldname] = field; - } - } - return this._field_map[fieldname]; - } - - /** - * Get fields filtered by filters - * @param {Object} filters - * - * Usage: - * meta = frappe.getMeta('ToDo') - * dataFields = meta.getFieldsWith({ fieldtype: 'Data' }) - */ - getFieldsWith(filters) { - return this.fields.filter((df) => { - let match = true; - for (const key in filters) { - const value = filters[key]; - match = df[key] === value; - } - return match; - }); - } - - getLabel(fieldname) { - let df = this.getField(fieldname); - return df.getLabel || df.label; - } - - getTableFields() { - if (this._tableFields === undefined) { - this._tableFields = this.fields.filter( - (field) => field.fieldtype === 'Table' - ); - } - return this._tableFields; - } - - getFormulaFields() { - if (this._formulaFields === undefined) { - this._formulaFields = this.fields.filter((field) => field.formula); - } - return this._formulaFields; - } - - hasFormula() { - if (this._hasFormula === undefined) { - this._hasFormula = false; - if (this.getFormulaFields().length) { - this._hasFormula = true; - } else { - for (let tablefield of this.getTableFields()) { - if (frappe.getMeta(tablefield.childtype).getFormulaFields().length) { - this._hasFormula = true; - break; - } - } - } - } - return this._hasFormula; - } - - getBaseDocType() { - return this.basedOn || this.name; - } - - async set(fieldname, value) { - this[fieldname] = value; - await this.trigger(fieldname); - } - - get(fieldname) { - return this[fieldname]; - } - - getValidFields({ withChildren = true } = {}) { - if (!this._validFields) { - this._validFields = []; - this._validFieldsWithChildren = []; - - const _add = (field) => { - this._validFields.push(field); - this._validFieldsWithChildren.push(field); - }; - - // fields validation - this.fields.forEach((df, i) => { - if (!df.fieldname) { - throw new frappe.errors.ValidationError( - `DocType ${this.name}: "fieldname" is required for field at index ${i}` - ); - } - if (!df.fieldtype) { - throw new frappe.errors.ValidationError( - `DocType ${this.name}: "fieldtype" is required for field "${df.fieldname}"` - ); - } - }); - - const doctypeFields = this.fields.map((field) => field.fieldname); - - // standard fields - for (let field of model.commonFields) { - if ( - frappe.db.typeMap[field.fieldtype] && - !doctypeFields.includes(field.fieldname) - ) { - _add(field); - } - } - - if (this.isSubmittable) { - _add({ - fieldtype: 'Check', - fieldname: 'submitted', - label: frappe.t`Submitted`, - }); - } - - if (this.isChild) { - // child fields - for (let field of model.childFields) { - if ( - frappe.db.typeMap[field.fieldtype] && - !doctypeFields.includes(field.fieldname) - ) { - _add(field); - } - } - } else { - // parent fields - for (let field of model.parentFields) { - if ( - frappe.db.typeMap[field.fieldtype] && - !doctypeFields.includes(field.fieldname) - ) { - _add(field); - } - } - } - - if (this.isTree) { - // tree fields - for (let field of model.treeFields) { - if ( - frappe.db.typeMap[field.fieldtype] && - !doctypeFields.includes(field.fieldname) - ) { - _add(field); - } - } - } - - // doctype fields - for (let field of this.fields) { - let include = frappe.db.typeMap[field.fieldtype]; - - if (include) { - _add(field); - } - - // include tables if (withChildren = True) - if (!include && field.fieldtype === 'Table') { - this._validFieldsWithChildren.push(field); - } - } - } - - if (withChildren) { - return this._validFieldsWithChildren; - } else { - return this._validFields; - } - } - - getKeywordFields() { - if (!this._keywordFields) { - this._keywordFields = this.keywordFields; - if (!(this._keywordFields && this._keywordFields.length && this.fields)) { - this._keywordFields = this.fields - .filter((field) => field.fieldtype !== 'Table' && field.required) - .map((field) => field.fieldname); - } - if (!(this._keywordFields && this._keywordFields.length)) { - this._keywordFields = ['name']; - } - } - return this._keywordFields; - } - - getQuickEditFields() { - if (this.quickEditFields) { - return this.quickEditFields.map((fieldname) => this.getField(fieldname)); - } - return this.getFieldsWith({ required: 1 }); - } - - validateSelect(field, value) { - let options = field.options; - if (!options) return; - if (!field.required && value == null) { - return; - } - - let validValues = options; - - if (typeof options === 'string') { - // values given as string - validValues = options.split('\n'); - } - - if (typeof options[0] === 'object') { - // options as array of {label, value} pairs - validValues = options.map((o) => o.value); - } - - if (!validValues.includes(value)) { - throw new frappe.errors.ValueError( - // prettier-ignore - `DocType ${this.name}: Invalid value "${value}" for "${field.label}". Must be one of ${options.join(', ')}` - ); - } - return value; - } - - async trigger(event, params = {}) { - Object.assign(params, { - doc: this, - name: event, - }); - - await super.trigger(event, params); - } - - setDefaultIndicators() { - if (!this.indicators) { - if (this.isSubmittable) { - this.indicators = { - key: 'submitted', - colors: { - 0: indicatorColor.GRAY, - 1: indicatorColor.BLUE, - }, - }; - } - } - } - - getIndicatorColor(doc) { - if (frappe.isDirty(this.name, doc.name)) { - return indicatorColor.ORANGE; - } else { - if (this.indicators) { - let value = doc[this.indicators.key]; - if (value) { - return this.indicators.colors[value] || indicatorColor.GRAY; - } else { - return indicatorColor.GRAY; - } - } else { - return indicatorColor.GRAY; - } - } - } -} diff --git a/frappe/model/naming.js b/frappe/model/naming.js deleted file mode 100644 index 1a333cbf..00000000 --- a/frappe/model/naming.js +++ /dev/null @@ -1,119 +0,0 @@ -import frappe from 'frappe'; -import { getRandomString } from 'frappe/utils'; - -export async function isNameAutoSet(doctype) { - const doc = frappe.getNewDoc(doctype); - if (doc.meta.naming === 'autoincrement') { - return true; - } - - if (!doc.meta.settings) { - return false; - } - - const { numberSeries } = await doc.getSettings(); - if (numberSeries) { - return true; - } - - return false; -} - -export async function setName(doc) { - if (frappe.isServer) { - // if is server, always name again if autoincrement or other - if (doc.meta.naming === 'autoincrement') { - doc.name = await getNextId(doc.doctype); - return; - } - - // Current, per doc number series - if (doc.numberSeries) { - doc.name = await getSeriesNext(doc.numberSeries, doc.doctype); - return; - } - - // Legacy, using doc settings for number series - if (doc.meta.settings) { - const numberSeries = (await doc.getSettings()).numberSeries; - if (!numberSeries) { - return; - } - - doc.name = await getSeriesNext(numberSeries, doc.doctype); - return; - } - } - - if (doc.name) { - return; - } - - // name === doctype for Single - if (doc.meta.isSingle) { - doc.name = doc.meta.name; - return; - } - - // assign a random name by default - // override doc to set a name - if (!doc.name) { - doc.name = getRandomString(); - } -} - -export async function getNextId(doctype) { - // get the last inserted row - let lastInserted = await getLastInserted(doctype); - let name = 1; - if (lastInserted) { - let lastNumber = parseInt(lastInserted.name); - if (isNaN(lastNumber)) lastNumber = 0; - name = lastNumber + 1; - } - return (name + '').padStart(9, '0'); -} - -export async function getLastInserted(doctype) { - const lastInserted = await frappe.db.getAll({ - doctype: doctype, - fields: ['name'], - limit: 1, - order_by: 'creation', - order: 'desc', - }); - return lastInserted && lastInserted.length ? lastInserted[0] : null; -} - -export async function getSeriesNext(prefix, doctype) { - let series; - - try { - series = await frappe.getDoc('NumberSeries', prefix); - } catch (e) { - if (!e.statusCode || e.statusCode !== 404) { - throw e; - } - - await createNumberSeries(prefix, doctype); - series = await frappe.getDoc('NumberSeries', prefix); - } - - return await series.next(doctype); -} - -export async function createNumberSeries(prefix, referenceType, start = 1001) { - const exists = await frappe.db.exists('NumberSeries', prefix); - if (exists) { - return; - } - - const series = frappe.newDoc({ - doctype: 'NumberSeries', - name: prefix, - start, - referenceType, - }); - - await series.insert(); -} diff --git a/frappe/model/runPatches.js b/frappe/model/runPatches.js deleted file mode 100644 index dfdcceec..00000000 --- a/frappe/model/runPatches.js +++ /dev/null @@ -1,26 +0,0 @@ -import frappe from 'frappe'; - -export default async function runPatches(patchList) { - const patchesAlreadyRun = ( - await frappe.db.knex('PatchRun').select('name') - ).map(({ name }) => name); - - for (let patch of patchList) { - if (patchesAlreadyRun.includes(patch.patchName)) { - continue; - } - - await runPatch(patch); - } -} - -async function runPatch({ patchName, patchFunction }) { - try { - await patchFunction(); - const patchRun = frappe.getNewDoc('PatchRun'); - patchRun.name = patchName; - await patchRun.insert(); - } catch (error) { - console.error(`could not run ${patchName}`, error); - } -} diff --git a/frappe/models/doctype/File/File.js b/frappe/models/doctype/File/File.js deleted file mode 100644 index 3f41637d..00000000 --- a/frappe/models/doctype/File/File.js +++ /dev/null @@ -1,61 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'File', - doctype: 'DocType', - isSingle: 0, - keywordFields: ['name', 'filename'], - fields: [ - { - fieldname: 'name', - label: t`File Path`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'filename', - label: t`File Name`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'mimetype', - label: t`MIME Type`, - fieldtype: 'Data', - }, - { - fieldname: 'size', - label: t`File Size`, - fieldtype: 'Int', - }, - { - fieldname: 'referenceDoctype', - label: t`Reference DocType`, - fieldtype: 'Data', - }, - { - fieldname: 'referenceName', - label: t`Reference Name`, - fieldtype: 'Data', - }, - { - fieldname: 'referenceField', - label: t`Reference Field`, - fieldtype: 'Data', - }, - ], - layout: [ - { - columns: [{ fields: ['filename'] }], - }, - { - columns: [{ fields: ['mimetype'] }, { fields: ['size'] }], - }, - { - columns: [ - { fields: ['referenceDoctype'] }, - { fields: ['referenceName'] }, - ], - }, - ], -}; diff --git a/frappe/models/doctype/NumberSeries/NumberSeries.js b/frappe/models/doctype/NumberSeries/NumberSeries.js deleted file mode 100644 index 51b1282d..00000000 --- a/frappe/models/doctype/NumberSeries/NumberSeries.js +++ /dev/null @@ -1,66 +0,0 @@ -import { t } from 'frappe'; -import NumberSeries from './NumberSeriesDocument.js'; - -const referenceTypeMap = { - SalesInvoice: t`Invoice`, - PurchaseInvoice: t`Bill`, - Payment: t`Payment`, - JournalEntry: t`Journal Entry`, - Quotation: t`Quotation`, - SalesOrder: t`SalesOrder`, - Fulfillment: t`Fulfillment`, - PurchaseOrder: t`PurchaseOrder`, - PurchaseReceipt: t`PurchaseReceipt`, - '-': t`None`, -}; - -export default { - name: 'NumberSeries', - label: t`Number Series`, - documentClass: NumberSeries, - doctype: 'DocType', - isSingle: 0, - isChild: 0, - keywordFields: [], - fields: [ - { - fieldname: 'name', - label: t`Prefix`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'start', - label: t`Start`, - fieldtype: 'Int', - default: 1001, - required: 1, - minvalue: 0, - }, - { - fieldname: 'padZeros', - label: t`Pad Zeros`, - fieldtype: 'Int', - default: 4, - required: 1, - }, - { - fieldname: 'referenceType', - label: t`Reference Type`, - fieldtype: 'Select', - options: Object.keys(referenceTypeMap), - map: referenceTypeMap, - default: '-', - required: 1, - readOnly: 1, - }, - { - fieldname: 'current', - label: t`Current`, - fieldtype: 'Int', - required: 1, - readOnly: 1, - }, - ], - quickEditFields: ['start', 'padZeros', 'referenceType'], -}; diff --git a/frappe/models/doctype/NumberSeries/NumberSeriesDocument.js b/frappe/models/doctype/NumberSeries/NumberSeriesDocument.js deleted file mode 100644 index 7b83ab6c..00000000 --- a/frappe/models/doctype/NumberSeries/NumberSeriesDocument.js +++ /dev/null @@ -1,37 +0,0 @@ -import { getPaddedName } from '@/utils'; -import frappe from 'frappe'; -import BaseDocument from 'frappe/model/document'; - -export default class NumberSeries extends BaseDocument { - validate() { - if (!this.current) { - this.current = this.start; - } - } - - async next(doctype) { - this.validate(); - - const exists = await this.checkIfCurrentExists(doctype); - if (!exists) { - return this.getPaddedName(this.current); - } - - this.current++; - await this.update(); - return this.getPaddedName(this.current); - } - - async checkIfCurrentExists(doctype) { - if (!doctype) { - return true; - } - - const name = this.getPaddedName(this.current); - return await frappe.db.exists(doctype, name); - } - - getPaddedName(next) { - return getPaddedName(this.name, next, this.padZeros); - } -} diff --git a/frappe/models/doctype/PatchRun/PatchRun.js b/frappe/models/doctype/PatchRun/PatchRun.js deleted file mode 100644 index 8ec73bce..00000000 --- a/frappe/models/doctype/PatchRun/PatchRun.js +++ /dev/null @@ -1,12 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'PatchRun', - fields: [ - { - fieldname: 'name', - fieldtype: 'Data', - label: t`Name`, - }, - ], -}; diff --git a/frappe/models/doctype/PrintFormat/PrintFormat.js b/frappe/models/doctype/PrintFormat/PrintFormat.js deleted file mode 100644 index 728c9f13..00000000 --- a/frappe/models/doctype/PrintFormat/PrintFormat.js +++ /dev/null @@ -1,33 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'PrintFormat', - label: t`Print Format`, - doctype: 'DocType', - isSingle: 0, - isChild: 0, - keywordFields: [], - fields: [ - { - fieldname: 'name', - label: t`Name`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'for', - label: t`For`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'template', - label: t`Template`, - fieldtype: 'Code', - required: 1, - options: { - mode: 'text/html', - }, - }, - ], -}; diff --git a/frappe/models/doctype/Role/Role.js b/frappe/models/doctype/Role/Role.js deleted file mode 100644 index fc228cef..00000000 --- a/frappe/models/doctype/Role/Role.js +++ /dev/null @@ -1,17 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'Role', - doctype: 'DocType', - isSingle: 0, - isChild: 0, - keywordFields: [], - fields: [ - { - fieldname: 'name', - label: t`Name`, - fieldtype: 'Data', - required: 1, - }, - ], -}; diff --git a/frappe/models/doctype/Session/Session.js b/frappe/models/doctype/Session/Session.js deleted file mode 100644 index c5835a19..00000000 --- a/frappe/models/doctype/Session/Session.js +++ /dev/null @@ -1,23 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'Session', - doctype: 'DocType', - isSingle: 0, - isChild: 0, - keywordFields: [], - fields: [ - { - fieldname: 'username', - label: t`Username`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'password', - label: t`Password`, - fieldtype: 'Password', - required: 1, - }, - ], -}; diff --git a/frappe/models/doctype/SingleValue/SingleValue.js b/frappe/models/doctype/SingleValue/SingleValue.js deleted file mode 100644 index 721a7b30..00000000 --- a/frappe/models/doctype/SingleValue/SingleValue.js +++ /dev/null @@ -1,29 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'SingleValue', - doctype: 'DocType', - isSingle: 0, - isChild: 0, - keywordFields: [], - fields: [ - { - fieldname: 'parent', - label: t`Parent`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'fieldname', - label: t`Fieldname`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'value', - label: t`Value`, - fieldtype: 'Data', - required: 1, - }, - ], -}; diff --git a/frappe/models/doctype/SystemSettings/SystemSettings.js b/frappe/models/doctype/SystemSettings/SystemSettings.js deleted file mode 100644 index 45603b80..00000000 --- a/frappe/models/doctype/SystemSettings/SystemSettings.js +++ /dev/null @@ -1,94 +0,0 @@ -const { DateTime } = require('luxon'); -const { t } = require('frappe'); -const { - DEFAULT_DISPLAY_PRECISION, - DEFAULT_INTERNAL_PRECISION, - DEFAULT_LOCALE, -} = require('../../../utils/consts'); - -let dateFormatOptions = (() => { - let formats = [ - 'dd/MM/yyyy', - 'MM/dd/yyyy', - 'dd-MM-yyyy', - 'MM-dd-yyyy', - 'yyyy-MM-dd', - 'd MMM, y', - 'MMM d, y', - ]; - - let today = DateTime.local(); - - return formats.map((format) => { - return { - label: today.toFormat(format), - value: format, - }; - }); -})(); - -module.exports = { - name: 'SystemSettings', - label: t`System Settings`, - doctype: 'DocType', - isSingle: 1, - isChild: 0, - keywordFields: [], - fields: [ - { - fieldname: 'dateFormat', - label: t`Date Format`, - fieldtype: 'Select', - options: dateFormatOptions, - default: 'MMM d, y', - required: 1, - description: t`Sets the app-wide date display format.`, - }, - { - fieldname: 'locale', - label: t`Locale`, - fieldtype: 'Data', - default: DEFAULT_LOCALE, - description: t`Set the local code. This is used for number formatting.`, - }, - { - fieldname: 'displayPrecision', - label: t`Display Precision`, - fieldtype: 'Int', - default: DEFAULT_DISPLAY_PRECISION, - required: 1, - minValue: 0, - maxValue: 9, - validate(value, doc) { - if (value >= 0 && value <= 9) { - return; - } - throw new frappe.errors.ValidationError( - t`Display Precision should have a value between 0 and 9.` - ); - }, - description: t`Sets how many digits are shown after the decimal point.`, - }, - { - fieldname: 'internalPrecision', - label: t`Internal Precision`, - fieldtype: 'Int', - minValue: 0, - default: DEFAULT_INTERNAL_PRECISION, - description: t`Sets the internal precision used for monetary calculations. Above 6 should be sufficient for most currencies.`, - }, - { - fieldname: 'hideGetStarted', - label: t`Hide Get Started`, - fieldtype: 'Check', - default: 0, - description: t`Hides the Get Started section from the sidebar. Change will be visible on restart or refreshing the app.`, - }, - ], - quickEditFields: [ - 'locale', - 'dateFormat', - 'displayPrecision', - 'hideGetStarted', - ], -}; diff --git a/frappe/models/doctype/ToDo/ToDo.js b/frappe/models/doctype/ToDo/ToDo.js deleted file mode 100644 index 4ea3d7ce..00000000 --- a/frappe/models/doctype/ToDo/ToDo.js +++ /dev/null @@ -1,62 +0,0 @@ -const { indicators } = require('../../../../src/colors'); -const { BLUE, GREEN } = indicators; -const { t } = require('frappe'); - -module.exports = { - name: 'ToDo', - label: t`To Do`, - naming: 'autoincrement', - isSingle: 0, - keywordFields: ['subject', 'description'], - titleField: 'subject', - indicators: { - key: 'status', - colors: { - Open: BLUE, - Closed: GREEN, - }, - }, - fields: [ - { - fieldname: 'subject', - label: t`Subject`, - placeholder: t`Subject`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'status', - label: t`Status`, - fieldtype: 'Select', - options: ['Open', 'Closed'], - default: 'Open', - required: 1, - }, - { - fieldname: 'description', - label: t`Description`, - fieldtype: 'Text', - }, - ], - - quickEditFields: ['status', 'description'], - - actions: [ - { - label: t`Close`, - condition: (doc) => doc.status !== 'Closed', - action: async (doc) => { - await doc.set('status', 'Closed'); - await doc.update(); - }, - }, - { - label: t`Re-Open`, - condition: (doc) => doc.status !== 'Open', - action: async (doc) => { - await doc.set('status', 'Open'); - await doc.update(); - }, - }, - ], -}; diff --git a/frappe/models/doctype/ToDo/ToDoList.js b/frappe/models/doctype/ToDo/ToDoList.js deleted file mode 100644 index 893d6475..00000000 --- a/frappe/models/doctype/ToDo/ToDoList.js +++ /dev/null @@ -1,7 +0,0 @@ -const BaseList = require('frappe/client/view/list'); - -module.exports = class ToDoList extends BaseList { - getFields(list) { - return ['name', 'subject', 'status']; - } -}; diff --git a/frappe/models/doctype/User/User.js b/frappe/models/doctype/User/User.js deleted file mode 100644 index 9553e9e9..00000000 --- a/frappe/models/doctype/User/User.js +++ /dev/null @@ -1,42 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'User', - doctype: 'DocType', - isSingle: 0, - isChild: 0, - keywordFields: ['name', 'fullName'], - fields: [ - { - fieldname: 'name', - label: t`Email`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'password', - label: t`Password`, - fieldtype: 'Password', - required: 1, - hidden: 1, - }, - { - fieldname: 'fullName', - label: t`Full Name`, - fieldtype: 'Data', - required: 1, - }, - { - fieldname: 'roles', - label: t`Roles`, - fieldtype: 'Table', - childtype: 'UserRole', - }, - { - fieldname: 'userId', - label: t`User ID`, - fieldtype: 'Data', - hidden: 1, - }, - ], -}; diff --git a/frappe/models/doctype/UserRole/UserRole.js b/frappe/models/doctype/UserRole/UserRole.js deleted file mode 100644 index 54f4e37c..00000000 --- a/frappe/models/doctype/UserRole/UserRole.js +++ /dev/null @@ -1,17 +0,0 @@ -const { t } = require('frappe'); - -module.exports = { - name: 'UserRole', - doctype: 'DocType', - isSingle: 0, - isChild: 1, - keywordFields: [], - fields: [ - { - fieldname: 'role', - label: t`Role`, - fieldtype: 'Link', - target: 'Role', - }, - ], -}; diff --git a/frappe/models/index.js b/frappe/models/index.js deleted file mode 100644 index f4b69e46..00000000 --- a/frappe/models/index.js +++ /dev/null @@ -1,25 +0,0 @@ -import File from './doctype/File/File.js'; -import NumberSeries from './doctype/NumberSeries/NumberSeries.js'; -import PatchRun from './doctype/PatchRun/PatchRun.js'; -import PrintFormat from './doctype/PrintFormat/PrintFormat.js'; -import Role from './doctype/Role/Role.js'; -import Session from './doctype/Session/Session.js'; -import SingleValue from './doctype/SingleValue/SingleValue.js'; -import SystemSettings from './doctype/SystemSettings/SystemSettings.js'; -import ToDo from './doctype/ToDo/ToDo.js'; -import User from './doctype/User/User.js'; -import UserRole from './doctype/UserRole/UserRole.js'; - -export default { - NumberSeries, - PrintFormat, - Role, - Session, - SingleValue, - SystemSettings, - ToDo, - User, - UserRole, - File, - PatchRun, -}; diff --git a/frappe/utils/consts.js b/frappe/utils/consts.js deleted file mode 100644 index 88e741fa..00000000 --- a/frappe/utils/consts.js +++ /dev/null @@ -1,15 +0,0 @@ -export const DEFAULT_INTERNAL_PRECISION = 11; -export const DEFAULT_DISPLAY_PRECISION = 2; -export const DEFAULT_LOCALE = 'en-IN'; -export const DEFAULT_LANGUAGE = 'English'; -export const DEFAULT_NUMBER_SERIES = { - SalesInvoice: 'SINV-', - PurchaseInvoice: 'PINV-', - Payment: 'PAY-', - JournalEntry: 'JV-', - Quotation: 'QTN-', - SalesOrder: 'SO-', - Fulfillment: 'OF-', - PurchaseOrder: 'PO-', - PurchaseReceipt: 'PREC-', -}; diff --git a/frappe/utils/format.js b/frappe/utils/format.js deleted file mode 100644 index a7b0f55d..00000000 --- a/frappe/utils/format.js +++ /dev/null @@ -1,117 +0,0 @@ -import frappe from 'frappe'; -import { DateTime } from 'luxon'; -import { DEFAULT_DISPLAY_PRECISION, DEFAULT_LOCALE } from './consts'; - -export default { - format(value, df, doc) { - if (!df) { - return value; - } - - if (typeof df === 'string') { - df = { fieldtype: df }; - } - - if (df.fieldtype === 'Currency') { - const currency = getCurrency(df, doc); - value = formatCurrency(value, currency); - } else if (df.fieldtype === 'Date') { - let dateFormat; - if (!frappe.SystemSettings) { - dateFormat = 'yyyy-MM-dd'; - } else { - dateFormat = frappe.SystemSettings.dateFormat; - } - - if (typeof value === 'string') { - // ISO String - value = DateTime.fromISO(value); - } else if (Object.prototype.toString.call(value) === '[object Date]') { - // JS Date - value = DateTime.fromJSDate(value); - } - - value = value.toFormat(dateFormat); - if (value === 'Invalid DateTime') { - value = ''; - } - } else if (df.fieldtype === 'Check') { - typeof parseInt(value) === 'number' - ? (value = parseInt(value)) - : (value = Boolean(value)); - } else { - if (value === null || value === undefined) { - value = ''; - } else { - value = value + ''; - } - } - return value; - }, - formatCurrency, - formatNumber, -}; - -function formatCurrency(value, currency) { - let valueString; - try { - valueString = formatNumber(value); - } catch (err) { - err.message += ` value: '${value}', type: ${typeof value}`; - throw err; - } - - const currencySymbol = frappe.currencySymbols[currency]; - if (currencySymbol) { - return currencySymbol + ' ' + valueString; - } - - return valueString; -} - -function formatNumber(value) { - const numberFormatter = getNumberFormatter(); - if (typeof value === 'number') { - return numberFormatter.format(value); - } - - if (value.round) { - return numberFormatter.format(value.round()); - } - - const formattedNumber = numberFormatter.format(value); - if (formattedNumber === 'NaN') { - throw Error( - `invalid value passed to formatNumber: '${value}' of type ${typeof value}` - ); - } - - return formattedNumber; -} - -function getNumberFormatter() { - if (frappe.currencyFormatter) { - return frappe.currencyFormatter; - } - - const locale = frappe.SystemSettings.locale ?? DEFAULT_LOCALE; - const display = - frappe.SystemSettings.displayPrecision ?? DEFAULT_DISPLAY_PRECISION; - - return (frappe.currencyFormatter = Intl.NumberFormat(locale, { - style: 'decimal', - minimumFractionDigits: display, - })); -} - -function getCurrency(df, doc) { - if (!(doc && df.getCurrency)) { - return df.currency || frappe.AccountingSettings.currency || ''; - } - - if (doc.meta && doc.meta.isChild) { - return df.getCurrency(doc, doc.parentdoc); - } - - return df.getCurrency(doc); -} diff --git a/frappe/utils/index.js b/frappe/utils/index.js deleted file mode 100644 index 0abdacf7..00000000 --- a/frappe/utils/index.js +++ /dev/null @@ -1,99 +0,0 @@ -const { pesa } = require('pesa'); - -Array.prototype.equals = function (array) { - return ( - this.length == array.length && - this.every(function (item, i) { - return item == array[i]; - }) - ); -}; - -function slug(str) { - return str - .replace(/(?:^\w|[A-Z]|\b\w)/g, function (letter, index) { - return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); - }) - .replace(/\s+/g, ''); -} - -function getRandomString() { - return Math.random().toString(36).substr(3); -} - -async function sleep(seconds) { - return new Promise((resolve) => { - setTimeout(resolve, seconds * 1000); - }); -} - -function getQueryString(params) { - if (!params) return ''; - let parts = []; - for (let key in params) { - if (key != null && params[key] != null) { - parts.push( - encodeURIComponent(key) + '=' + encodeURIComponent(params[key]) - ); - } - } - return parts.join('&'); -} - -function asyncHandler(fn) { - return (req, res, next) => - Promise.resolve(fn(req, res, next)).catch((err) => { - console.log(err); - // handle error - res.status(err.statusCode || 500).send({ error: err.message }); - }); -} - -/** - * Returns array from 0 to n - 1 - * @param {Number} n - */ -function range(n) { - return Array(n) - .fill() - .map((_, i) => i); -} - -function unique(list, key = (it) => it) { - var seen = {}; - return list.filter((item) => { - var k = key(item); - return seen.hasOwnProperty(k) ? false : (seen[k] = true); - }); -} - -function getDuplicates(array) { - let duplicates = []; - for (let i in array) { - let previous = array[i - 1]; - let current = array[i]; - - if (current === previous) { - if (!duplicates.includes(current)) { - duplicates.push(current); - } - } - } - return duplicates; -} - -function isPesa(value) { - return value instanceof pesa().constructor; -} - -module.exports = { - slug, - getRandomString, - sleep, - getQueryString, - asyncHandler, - range, - unique, - getDuplicates, - isPesa, -}; diff --git a/frappe/utils/noop.js b/frappe/utils/noop.js deleted file mode 100644 index bd375ffa..00000000 --- a/frappe/utils/noop.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = function () { return function () {}; }; \ No newline at end of file diff --git a/frappe/utils/translation.js b/frappe/utils/translation.js deleted file mode 100644 index dd5e1089..00000000 --- a/frappe/utils/translation.js +++ /dev/null @@ -1,86 +0,0 @@ -import { - getIndexFormat, - getIndexList, - getSnippets, - getWhitespaceSanitized, -} from '../../scripts/helpers'; -import { ValueError } from '../common/errors'; - -class TranslationString { - constructor(...args) { - this.args = args; - } - - get s() { - return this.toString(); - } - - ctx(context) { - this.context = context; - return this; - } - - #formatArg(arg) { - return arg ?? ''; - } - - #translate() { - let indexFormat = getIndexFormat(this.args[0]); - indexFormat = getWhitespaceSanitized(indexFormat); - - const translatedIndexFormat = - this.languageMap[indexFormat]?.translation ?? indexFormat; - - this.argList = getIndexList(translatedIndexFormat).map( - (i) => this.argList[i] - ); - this.strList = getSnippets(translatedIndexFormat); - } - - #stitch() { - if (!(this.args[0] instanceof Array)) { - throw new ValueError( - `invalid args passed to TranslationString ${ - this.args - } of type ${typeof this.args[0]}` - ); - } - - this.strList = this.args[0]; - this.argList = this.args.slice(1); - - if (this.languageMap) { - this.#translate(); - } - - return this.strList - .map((s, i) => s + this.#formatArg(this.argList[i])) - .join('') - .replace(/\s+/g, ' ') - .trim(); - } - - toString() { - return this.#stitch(); - } - - toJSON() { - return this.#stitch(); - } - - valueOf() { - return this.#stitch(); - } -} - -export function T(...args) { - return new TranslationString(...args); -} - -export function t(...args) { - return new TranslationString(...args).s; -} - -export function setLanguageMapOnTranslationString(languageMap) { - TranslationString.prototype.languageMap = languageMap; -} diff --git a/fyo/README.md b/fyo/README.md new file mode 100644 index 00000000..511363ac --- /dev/null +++ b/fyo/README.md @@ -0,0 +1,112 @@ +# Fyo + +This is the underlying framework that runs **Books**, at some point it may be +removed into a separate repo, but as of now it's in gestation. + +The reason for maintaining a framework is to allow for varied backends. +Currently Books runs on the electron renderer process and all db stuff happens +on the electron main process which has access to nodelibs. As the development +of `Fyo` progresses it will allow for a browser frontend and a node server +backend. + +This platform variablity will be handled by code in the `fyo/demux` subdirectory. + +## Pre Req + +**Singleton**: The `Fyo` class is used as a singleton throughout Books, this +allows for a single source of truth and a common interface to access different +modules such as `db`, `doc` an `auth`. + +**Localization**: Since Books' functionality changes depending on region, +regional information (`countryCode`) is required in the initialization process. + +**`Doc`**: This is `fyo`'s abstraction for an ORM, the associated files are +located in `model/doc.ts`, all classes exported from `books/models` extend this. + +### Terminology + +- **Schema**: object that defines shape of the data in the database. +- **Model**: the controller class that extends the `Doc` class, or the `Doc` + class itself (if a specific controller doesn't exist). +- **doc** (not `Doc`): instance of a Model, i.e. what has the data. + +If you are confused, I understand. + +## Initialization + +There are a set of core models which are maintained in the `fyo/models` +subdirectory, from this the _SystemSettings_ field `countryCode` is used to +config regional information. + +A few things have to be done on initialization: + +#### 1. Connect To DB + +If creating a new instance then `fyo.db.createNewDatabase` or if loading an +instance `fyo.db.connectToDatabase`. + +Both of them take `countryCode` as an argument, `fyo.db.createNewDatabase` +should be passed the `countryCode` as the schemas are built on the basis of +this. + +#### 2. Initialize and Register + +Done using `fyo.initializeAndRegister` after a database is connected, this should be +passed the models and regional models. + +This sets the schemas and associated models on the `fyo` object along with a few +other things. + +### Sequence + +**First Load**: i.e. registering or creating a new instance. + +- Get `countryCode` from the setup wizard. +- Create a new DB using `fyo.db.createNewDatabase` with the `countryCode`. +- Get models and `regionalModels` using `countryCode` from `models/index.ts/getRegionalModels`. +- Call `fyo.initializeAndRegister` with the all models. + +**Next Load**: i.e. logging in or opening an existing instance. + +- Connect to DB using `fyo.db.connectToDatabase` and get `countryCode` from the return. +- Get models and `regionalModels` using `countryCode` from `models/index.ts/getRegionalModels`. +- Call `fyo.initializeAndRegister` with the all models. + +_Note: since **SystemSettings** are initialized on `fyo.initializeAndRegister` +db needs to be set first else an error will be thrown_ + +## Testing + +For testing the `fyo` class, `mocha` is used (`node` side). So for this the +demux classes are directly replaced by `node` side managers such as +`DatabaseManager`. + +For this to work the class signatures of the demux class and the manager have to +be the same which is maintained by abstract demux classes. + +`DatabaseManager` is used as the `DatabaseDemux` for testing without API or IPC +calls. For `AuthDemux` the `DummyAuthDemux` class is used. + +## Translations + +All translations take place during runtime, for translations to work, a +`LanguageMap` (for def check `utils/types.ts`) has to be set. + +This can be done using `fyo/utils/translation.ts/setLanguageMapOnTranslationString`. + +Since translations are runtime, if the code is evaluated before the language map +is loaded, translations won't work. To prevent this, don't maintain translation +strings globally since this will be evaluated before the map is loaded. + +## Observers + +The doc and db handlers have observers (instances of `Observable`) as +properties, these can be accessed using +- `fyo.db.observer` +- `fyo.doc.observer` + +The purpose of the observer is to trigger registered callbacks when some `doc` +operation or `db` operation takes place. + +These are schema level observers i.e. they are registered like so: +`method:schemaName`. The callbacks receive args passed to the functions. \ No newline at end of file diff --git a/fyo/core/authHandler.ts b/fyo/core/authHandler.ts new file mode 100644 index 00000000..8f8c2b10 --- /dev/null +++ b/fyo/core/authHandler.ts @@ -0,0 +1,117 @@ +import { Fyo } from 'fyo'; +import { AuthDemux } from 'fyo/demux/auth'; +import { AuthDemuxBase, TelemetryCreds } from 'utils/auth/types'; +import { AuthDemuxConstructor } from './types'; + +interface AuthConfig { + serverURL: string; + backend: string; + port: number; +} + +interface Session { + user: string; + token: string; +} + +export class AuthHandler { + #config: AuthConfig; + #session: Session; + fyo: Fyo; + #demux: AuthDemuxBase; + + constructor(fyo: Fyo, Demux?: AuthDemuxConstructor) { + this.fyo = fyo; + this.#config = { + serverURL: '', + backend: 'sqlite', + port: 8000, + }; + + this.#session = { + user: '', + token: '', + }; + + if (Demux !== undefined) { + this.#demux = new Demux(fyo.isElectron); + } else { + this.#demux = new AuthDemux(fyo.isElectron); + } + } + + set user(value: string) { + this.#session.user = value; + } + + get user(): string { + return this.#session.user; + } + + get session(): Readonly { + return { ...this.#session }; + } + + get config(): Readonly { + return { ...this.#config }; + } + + init() {} + async login(email: string, password: string) { + if (email === 'Administrator') { + this.#session.user = 'Administrator'; + return; + } + + const response = await fetch(this.#getServerURL() + '/api/login', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (response.status === 200) { + const res = await response.json(); + + this.#session.user = email; + this.#session.token = res.token; + + return res; + } + + return response; + } + + async signup(email: string, fullName: string, password: string) { + const response = await fetch(this.#getServerURL() + '/api/signup', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, fullName, password }), + }); + + if (response.status === 200) { + return await response.json(); + } + + return response; + } + + async logout() { + // TODO: Implement this with auth flow + } + + purgeCache() {} + + #getServerURL() { + return this.#config.serverURL || ''; + } + + async getTelemetryCreds(): Promise { + return await this.#demux.getTelemetryCreds(); + } +} diff --git a/fyo/core/converter.ts b/fyo/core/converter.ts new file mode 100644 index 00000000..03c2ed58 --- /dev/null +++ b/fyo/core/converter.ts @@ -0,0 +1,401 @@ +import { Fyo } from 'fyo'; +import { Doc } from 'fyo/model/doc'; +import { isPesa } from 'fyo/utils'; +import { ValueError } from 'fyo/utils/errors'; +import { DateTime } from 'luxon'; +import { Money } from 'pesa'; +import { Field, FieldTypeEnum, RawValue, TargetField } from 'schemas/types'; +import { getIsNullOrUndef } from 'utils'; +import { DatabaseHandler } from './dbHandler'; +import { DocValue, DocValueMap, RawValueMap } from './types'; + +/** + * # Converter + * + * Basically converts serializable RawValues from the db to DocValues used + * by the frontend and vice versa. + * + * ## Value Conversion + * It exposes two static methods: `toRawValue` and `toDocValue` that can be + * used elsewhere given the fieldtype. + * + * ## Map Conversion + * Two methods `toDocValueMap` and `toRawValueMap` are exposed but should be + * used only from the `dbHandler`. + */ + +export class Converter { + db: DatabaseHandler; + fyo: Fyo; + + constructor(db: DatabaseHandler, fyo: Fyo) { + this.db = db; + this.fyo = fyo; + } + + toDocValueMap( + schemaName: string, + rawValueMap: RawValueMap | RawValueMap[] + ): DocValueMap | DocValueMap[] { + if (Array.isArray(rawValueMap)) { + return rawValueMap.map((dv) => this.#toDocValueMap(schemaName, dv)); + } else { + return this.#toDocValueMap(schemaName, rawValueMap); + } + } + + toRawValueMap( + schemaName: string, + docValueMap: DocValueMap | DocValueMap[] + ): RawValueMap | RawValueMap[] { + if (Array.isArray(docValueMap)) { + return docValueMap.map((dv) => this.#toRawValueMap(schemaName, dv)); + } else { + return this.#toRawValueMap(schemaName, docValueMap); + } + } + + static toDocValue(value: RawValue, field: Field, fyo: Fyo): DocValue { + switch (field.fieldtype) { + case FieldTypeEnum.Currency: + return toDocCurrency(value, field, fyo); + case FieldTypeEnum.Date: + return toDocDate(value, field); + case FieldTypeEnum.Datetime: + return toDocDate(value, field); + case FieldTypeEnum.Int: + return toDocInt(value, field); + case FieldTypeEnum.Float: + return toDocFloat(value, field); + case FieldTypeEnum.Check: + return toDocCheck(value, field); + default: + return toDocString(value, field); + } + } + + static toRawValue(value: DocValue, field: Field, fyo: Fyo): RawValue { + switch (field.fieldtype) { + case FieldTypeEnum.Currency: + return toRawCurrency(value, fyo, field); + case FieldTypeEnum.Date: + return toRawDate(value, field); + case FieldTypeEnum.Datetime: + return toRawDateTime(value, field); + case FieldTypeEnum.Int: + return toRawInt(value, field); + case FieldTypeEnum.Float: + return toRawFloat(value, field); + case FieldTypeEnum.Check: + return toRawCheck(value, field); + case FieldTypeEnum.Link: + return toRawLink(value, field); + default: + return toRawString(value, field); + } + } + + #toDocValueMap(schemaName: string, rawValueMap: RawValueMap): DocValueMap { + const fieldValueMap = this.db.fieldValueMap[schemaName]; + const docValueMap: DocValueMap = {}; + + for (const fieldname in rawValueMap) { + const field = fieldValueMap[fieldname]; + const rawValue = rawValueMap[fieldname]; + if (!field) { + continue; + } + + if (Array.isArray(rawValue)) { + const parentSchemaName = (field as TargetField).target; + docValueMap[fieldname] = rawValue.map((rv) => + this.#toDocValueMap(parentSchemaName, rv) + ); + } else { + docValueMap[fieldname] = Converter.toDocValue( + rawValue, + field, + this.fyo + ); + } + } + + return docValueMap; + } + + #toRawValueMap(schemaName: string, docValueMap: DocValueMap): RawValueMap { + const fieldValueMap = this.db.fieldValueMap[schemaName]; + const rawValueMap: RawValueMap = {}; + + for (const fieldname in docValueMap) { + const field = fieldValueMap[fieldname]; + const docValue = docValueMap[fieldname]; + + if (Array.isArray(docValue)) { + const parentSchemaName = (field as TargetField).target; + + rawValueMap[fieldname] = docValue.map((value) => { + if (value instanceof Doc) { + return this.#toRawValueMap(parentSchemaName, value.getValidDict()); + } + + return this.#toRawValueMap(parentSchemaName, value as DocValueMap); + }); + } else { + rawValueMap[fieldname] = Converter.toRawValue( + docValue, + field, + this.fyo + ); + } + } + + return rawValueMap; + } +} + +function toDocString(value: RawValue, field: Field) { + if (value === null) { + return null; + } + + if (value === undefined) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + throwError(value, field, 'doc'); +} + +function toDocDate(value: RawValue, field: Field) { + if ((value as any) instanceof Date) { + return value; + } + + if (value === null || value === '') { + return null; + } + + if (typeof value !== 'number' && typeof value !== 'string') { + throwError(value, field, 'doc'); + } + + const date = new Date(value); + if (date.toString() === 'Invalid Date') { + throwError(value, field, 'doc'); + } + + return date; +} + +function toDocCurrency(value: RawValue, field: Field, fyo: Fyo) { + if (isPesa(value)) { + return value; + } + + if (value === '') { + return fyo.pesa(0); + } + + if (typeof value === 'string') { + return fyo.pesa(value); + } + + if (typeof value === 'number') { + return fyo.pesa(value); + } + + if (typeof value === 'boolean') { + return fyo.pesa(Number(value)); + } + + if (value === null) { + return fyo.pesa(0); + } + + throwError(value, field, 'doc'); +} + +function toDocInt(value: RawValue, field: Field): number { + if (value === '') { + return 0; + } + + if (typeof value === 'string') { + value = parseInt(value); + } + + return toDocFloat(value, field); +} + +function toDocFloat(value: RawValue, field: Field): number { + if (value === '') { + return 0; + } + + if (typeof value === 'boolean') { + return Number(value); + } + + if (typeof value === 'string') { + value = parseFloat(value); + } + + if (value === null) { + value = 0; + } + + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + + throwError(value, field, 'doc'); +} + +function toDocCheck(value: RawValue, field: Field): boolean { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + return !!parseFloat(value); + } + + if (typeof value === 'number') { + return Boolean(value); + } + + throwError(value, field, 'doc'); +} + +function toRawCurrency(value: DocValue, fyo: Fyo, field: Field): string { + if (isPesa(value)) { + return (value as Money).store; + } + + if (getIsNullOrUndef(value)) { + return fyo.pesa(0).store; + } + + if (typeof value === 'number') { + return fyo.pesa(value).store; + } + + if (typeof value === 'string') { + return fyo.pesa(value).store; + } + + throwError(value, field, 'raw'); +} + +function toRawInt(value: DocValue, field: Field): number { + if (typeof value === 'string') { + return parseInt(value); + } + + if (getIsNullOrUndef(value)) { + return 0; + } + + if (typeof value === 'number') { + return Math.floor(value as number); + } + + throwError(value, field, 'raw'); +} + +function toRawFloat(value: DocValue, field: Field): number { + if (typeof value === 'string') { + return parseFloat(value); + } + + if (getIsNullOrUndef(value)) { + return 0; + } + + if (typeof value === 'number') { + return value; + } + + throwError(value, field, 'raw'); +} + +function toRawDate(value: DocValue, field: Field): string | null { + const dateTime = toRawDateTime(value, field); + if (dateTime === null) { + return null; + } + + return dateTime.split('T')[0]; +} + +function toRawDateTime(value: DocValue, field: Field): string | null { + if (value === null) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + if (value instanceof Date) { + return (value as Date).toISOString(); + } + + if (value instanceof DateTime) { + return (value as DateTime).toISO(); + } + + throwError(value, field, 'raw'); +} + +function toRawCheck(value: DocValue, field: Field): number { + if (typeof value === 'number') { + value = Boolean(value); + } + + if (typeof value === 'boolean') { + return Number(value); + } + + throwError(value, field, 'raw'); +} + +function toRawString(value: DocValue, field: Field): string | null { + if (value === null) { + return null; + } + + if (value === undefined) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + throwError(value, field, 'raw'); +} + +function toRawLink(value: DocValue, field: Field): string | null { + if (value === null || !(value as string)?.length) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + throwError(value, field, 'raw'); +} + +function throwError(value: T, field: Field, type: 'raw' | 'doc'): never { + throw new ValueError( + `invalid ${type} conversion '${value}' of type ${typeof value} found, field: ${JSON.stringify( + field + )}` + ); +} diff --git a/fyo/core/dbHandler.ts b/fyo/core/dbHandler.ts new file mode 100644 index 00000000..3eab4495 --- /dev/null +++ b/fyo/core/dbHandler.ts @@ -0,0 +1,296 @@ +import { SingleValue } from 'backend/database/types'; +import { Fyo } from 'fyo'; +import { DatabaseDemux } from 'fyo/demux/db'; +import { ValueError } from 'fyo/utils/errors'; +import Observable from 'fyo/utils/observable'; +import { translateSchema } from 'fyo/utils/translation'; +import { Field, RawValue, SchemaMap } from 'schemas/types'; +import { getMapFromList } from 'utils'; +import { DatabaseBase, DatabaseDemuxBase, GetAllOptions } from 'utils/db/types'; +import { schemaTranslateables } from 'utils/translationHelpers'; +import { LanguageMap } from 'utils/types'; +import { Converter } from './converter'; +import { + DatabaseDemuxConstructor, + DocValue, + DocValueMap, + RawValueMap, +} from './types'; + +// Return types of Bespoke Queries +type TopExpenses = { account: string; total: number }[]; +type TotalOutstanding = { total: number; outstanding: number }; +type Cashflow = { inflow: number; outflow: number; yearmonth: string }[]; +type Balance = { balance: number; yearmonth: string }[]; +type IncomeExpense = { income: Balance; expense: Balance }; + +export class DatabaseHandler extends DatabaseBase { + #fyo: Fyo; + converter: Converter; + #demux: DatabaseDemuxBase; + dbPath?: string; + #schemaMap: SchemaMap = {}; + observer: Observable = new Observable(); + fieldValueMap: Record> = {}; + + constructor(fyo: Fyo, Demux?: DatabaseDemuxConstructor) { + super(); + this.#fyo = fyo; + this.converter = new Converter(this, this.#fyo); + + if (Demux !== undefined) { + this.#demux = new Demux(fyo.isElectron); + } else { + this.#demux = new DatabaseDemux(fyo.isElectron); + } + } + + get schemaMap(): Readonly { + return this.#schemaMap; + } + + get isConnected() { + return !!this.dbPath; + } + + async createNewDatabase(dbPath: string, countryCode: string) { + countryCode = await this.#demux.createNewDatabase(dbPath, countryCode); + await this.init(); + this.dbPath = dbPath; + return countryCode; + } + + async connectToDatabase(dbPath: string, countryCode?: string) { + countryCode = await this.#demux.connectToDatabase(dbPath, countryCode); + await this.init(); + this.dbPath = dbPath; + return countryCode; + } + + async init() { + this.#schemaMap = (await this.#demux.getSchemaMap()) as SchemaMap; + + for (const schemaName in this.schemaMap) { + const fields = this.schemaMap[schemaName]!.fields!; + this.fieldValueMap[schemaName] = getMapFromList(fields, 'fieldname'); + } + this.observer = new Observable(); + } + + async translateSchemaMap(languageMap?: LanguageMap) { + if (languageMap) { + translateSchema(this.#schemaMap, languageMap, schemaTranslateables); + } else { + this.#schemaMap = (await this.#demux.getSchemaMap()) as SchemaMap; + } + } + + purgeCache() { + this.dbPath = undefined; + this.#schemaMap = {}; + this.fieldValueMap = {}; + } + + async insert( + schemaName: string, + docValueMap: DocValueMap + ): Promise { + let rawValueMap = this.converter.toRawValueMap( + schemaName, + docValueMap + ) as RawValueMap; + rawValueMap = (await this.#demux.call( + 'insert', + schemaName, + rawValueMap + )) as RawValueMap; + this.observer.trigger(`insert:${schemaName}`, docValueMap); + return this.converter.toDocValueMap(schemaName, rawValueMap) as DocValueMap; + } + + // Read + async get( + schemaName: string, + name: string, + fields?: string | string[] + ): Promise { + const rawValueMap = (await this.#demux.call( + 'get', + schemaName, + name, + fields + )) as RawValueMap; + this.observer.trigger(`get:${schemaName}`, { name, fields }); + return this.converter.toDocValueMap(schemaName, rawValueMap) as DocValueMap; + } + + async getAll( + schemaName: string, + options: GetAllOptions = {} + ): Promise { + const rawValueMap = await this.#getAll(schemaName, options); + this.observer.trigger(`getAll:${schemaName}`, options); + return this.converter.toDocValueMap( + schemaName, + rawValueMap + ) as DocValueMap[]; + } + + async getAllRaw( + schemaName: string, + options: GetAllOptions = {} + ): Promise { + const all = await this.#getAll(schemaName, options); + this.observer.trigger(`getAllRaw:${schemaName}`, options); + return all; + } + + async getSingleValues( + ...fieldnames: ({ fieldname: string; parent?: string } | string)[] + ): Promise> { + const rawSingleValue = (await this.#demux.call( + 'getSingleValues', + ...fieldnames + )) as SingleValue; + + const docSingleValue: SingleValue = []; + for (const sv of rawSingleValue) { + const field = this.fieldValueMap[sv.parent][sv.fieldname]; + const value = Converter.toDocValue(sv.value, field, this.#fyo); + + docSingleValue.push({ + value, + parent: sv.parent, + fieldname: sv.fieldname, + }); + } + + this.observer.trigger(`getSingleValues`, fieldnames); + return docSingleValue; + } + + async count( + schemaName: string, + options: GetAllOptions = {} + ): Promise { + const rawValueMap = await this.#getAll(schemaName, options); + const count = rawValueMap.length; + this.observer.trigger(`count:${schemaName}`, options); + return count; + } + + // Update + async rename( + schemaName: string, + oldName: string, + newName: string + ): Promise { + await this.#demux.call('rename', schemaName, oldName, newName); + this.observer.trigger(`rename:${schemaName}`, { oldName, newName }); + } + + async update(schemaName: string, docValueMap: DocValueMap): Promise { + const rawValueMap = this.converter.toRawValueMap(schemaName, docValueMap); + await this.#demux.call('update', schemaName, rawValueMap); + this.observer.trigger(`update:${schemaName}`, docValueMap); + } + + // Delete + async delete(schemaName: string, name: string): Promise { + await this.#demux.call('delete', schemaName, name); + this.observer.trigger(`delete:${schemaName}`, name); + } + + // Other + async exists(schemaName: string, name?: string): Promise { + const doesExist = (await this.#demux.call( + 'exists', + schemaName, + name + )) as boolean; + this.observer.trigger(`exists:${schemaName}`, name); + return doesExist; + } + + async close(): Promise { + await this.#demux.call('close'); + this.purgeCache(); + } + + /** + * Bespoke function + * + * These are functions to run custom queries that are too complex for + * DatabaseCore and require use of knex or raw queries. The output + * of these is not converted to DocValue and is used as is (RawValue). + * + * The query logic for these is in backend/database/bespoke.ts + */ + + async getLastInserted(schemaName: string): Promise { + if (this.schemaMap[schemaName]?.naming !== 'autoincrement') { + throw new ValueError( + `invalid schema, ${schemaName} does not have autoincrement naming` + ); + } + + return (await this.#demux.callBespoke( + 'getLastInserted', + schemaName + )) as number; + } + + async getTopExpenses(fromDate: string, toDate: string): Promise { + return (await this.#demux.callBespoke( + 'getTopExpenses', + fromDate, + toDate + )) as TopExpenses; + } + + async getTotalOutstanding( + schemaName: string, + fromDate: string, + toDate: string + ): Promise { + return (await this.#demux.callBespoke( + 'getTotalOutstanding', + schemaName, + fromDate, + toDate + )) as TotalOutstanding; + } + + async getCashflow(fromDate: string, toDate: string): Promise { + return (await this.#demux.callBespoke( + 'getCashflow', + fromDate, + toDate + )) as Cashflow; + } + + async getIncomeAndExpenses( + fromDate: string, + toDate: string + ): Promise { + return (await this.#demux.callBespoke( + 'getIncomeAndExpenses', + fromDate, + toDate + )) as IncomeExpense; + } + + /** + * Internal methods + */ + async #getAll( + schemaName: string, + options: GetAllOptions = {} + ): Promise { + return (await this.#demux.call( + 'getAll', + schemaName, + options + )) as RawValueMap[]; + } +} diff --git a/fyo/core/docHandler.ts b/fyo/core/docHandler.ts new file mode 100644 index 00000000..f40343a2 --- /dev/null +++ b/fyo/core/docHandler.ts @@ -0,0 +1,174 @@ +import { Doc } from 'fyo/model/doc'; +import { DocMap, ModelMap, SinglesMap } from 'fyo/model/types'; +import { coreModels } from 'fyo/models'; +import { NotFoundError, ValueError } from 'fyo/utils/errors'; +import Observable from 'fyo/utils/observable'; +import { Schema } from 'schemas/types'; +import { getRandomString } from 'utils'; +import { Fyo } from '..'; +import { DocValueMap } from './types'; + +export class DocHandler { + fyo: Fyo; + models: ModelMap = {}; + singles: SinglesMap = {}; + docs: Observable = new Observable(); + observer: Observable = new Observable(); + + constructor(fyo: Fyo) { + this.fyo = fyo; + } + + init() { + this.models = {}; + this.singles = {}; + this.docs = new Observable(); + this.observer = new Observable(); + } + + purgeCache() { + this.init(); + } + + registerModels(models: ModelMap, regionalModels: ModelMap = {}) { + for (const schemaName in this.fyo.db.schemaMap) { + if (coreModels[schemaName] !== undefined) { + this.models[schemaName] = coreModels[schemaName]; + } else if (regionalModels[schemaName] !== undefined) { + this.models[schemaName] = regionalModels[schemaName]; + } else if (models[schemaName] !== undefined) { + this.models[schemaName] = models[schemaName]; + } else { + this.models[schemaName] = Doc; + } + } + } + + /** + * Doc Operations + */ + + async getDoc( + schemaName: string, + name?: string, + options = { skipDocumentCache: false } + ) { + if (name === undefined) { + name = schemaName; + } + + if (name === schemaName && !this.fyo.schemaMap[schemaName]?.isSingle) { + throw new ValueError(`${schemaName} is not a Single Schema`); + } + + let doc: Doc | undefined; + if (!options?.skipDocumentCache) { + doc = this.#getFromCache(schemaName, name); + } + + if (doc) { + return doc; + } + + doc = this.getNewDoc(schemaName, { name }); + await doc.load(); + this.#addToCache(doc); + + return doc; + } + + getNewDoc( + schemaName: string, + data: DocValueMap = {}, + cacheDoc: boolean = true, + schema?: Schema, + Model?: typeof Doc + ): Doc { + if (!this.models[schemaName] && Model) { + this.models[schemaName] = Model; + } + + Model ??= this.models[schemaName]; + schema ??= this.fyo.schemaMap[schemaName]; + + if (schema === undefined) { + throw new NotFoundError(`Schema not found for ${schemaName}`); + } + + const doc = new Model!(schema, data, this.fyo); + doc.name ??= getRandomString(); + if (cacheDoc) { + this.#addToCache(doc); + } + + return doc; + } + + /** + * Cache operations + */ + + #addToCache(doc: Doc) { + if (!doc.name) { + return; + } + + const name = doc.name; + const schemaName = doc.schemaName; + + if (!this.docs[schemaName]) { + this.docs.set(schemaName, {}); + this.#setCacheUpdationListeners(schemaName); + } + + this.docs.get(schemaName)![name] = doc; + + // singles available as first level objects too + if (schemaName === doc.name) { + this.singles[name] = doc; + } + + // propagate change to `docs` + doc.on('change', (params: unknown) => { + this.docs!.trigger('change', params); + }); + + doc.on('afterSync', () => { + if (doc.name === name) { + return; + } + + this.#removeFromCache(doc.schemaName, name); + this.#addToCache(doc); + }); + } + + #setCacheUpdationListeners(schemaName: string) { + this.fyo.db.observer.on(`delete:${schemaName}`, (name: string) => { + this.#removeFromCache(schemaName, name); + }); + + this.fyo.db.observer.on( + `rename:${schemaName}`, + (names: { oldName: string; newName: string }) => { + const doc = this.#getFromCache(schemaName, names.oldName); + if (doc === undefined) { + return; + } + + this.#removeFromCache(schemaName, names.oldName); + this.#addToCache(doc); + } + ); + } + + #removeFromCache(schemaName: string, name: string) { + const docMap = this.docs.get(schemaName); + delete docMap?.[name]; + } + + #getFromCache(schemaName: string, name: string): Doc | undefined { + const docMap = this.docs.get(schemaName); + return docMap?.[name]; + } +} diff --git a/fyo/core/types.ts b/fyo/core/types.ts new file mode 100644 index 00000000..29e1bdfc --- /dev/null +++ b/fyo/core/types.ts @@ -0,0 +1,51 @@ +import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; +import { RawValue } from 'schemas/types'; +import { AuthDemuxBase } from 'utils/auth/types'; +import { DatabaseDemuxBase } from 'utils/db/types'; + +export type DocValue = + | string + | number + | boolean + | Date + | Money + | null + | undefined; +export type DocValueMap = Record; +export type RawValueMap = Record; + +/** + * DatabaseDemuxConstructor: type for a constructor that returns a DatabaseDemuxBase + * it's typed this way because `typeof AbstractClass` is invalid as abstract classes + * can't be initialized using `new`. + * + * AuthDemuxConstructor: same as the above but for AuthDemuxBase + */ + +export type DatabaseDemuxConstructor = new ( + isElectron?: boolean +) => DatabaseDemuxBase; + +export type AuthDemuxConstructor = new (isElectron?: boolean) => AuthDemuxBase; + +export enum ConfigKeys { + Files = 'files', + LastSelectedFilePath = 'lastSelectedFilePath', + Language = 'language', + DeviceId = 'deviceId', +} + +export interface ConfigFile { + id: string; + companyName: string; + dbPath: string; + openCount: number; +} + +export interface FyoConfig { + DatabaseDemux?: DatabaseDemuxConstructor; + AuthDemux?: AuthDemuxConstructor; + isElectron?: boolean; + isTest?: boolean; +} diff --git a/fyo/demux/auth.ts b/fyo/demux/auth.ts new file mode 100644 index 00000000..e9ea5136 --- /dev/null +++ b/fyo/demux/auth.ts @@ -0,0 +1,22 @@ +import { ipcRenderer } from 'electron'; +import { AuthDemuxBase, TelemetryCreds } from 'utils/auth/types'; +import { IPC_ACTIONS } from 'utils/messages'; + +export class AuthDemux extends AuthDemuxBase { + #isElectron: boolean = false; + constructor(isElectron: boolean) { + super(); + this.#isElectron = isElectron; + } + + async getTelemetryCreds(): Promise { + if (this.#isElectron) { + const creds = await ipcRenderer.invoke(IPC_ACTIONS.GET_CREDS); + const url: string = creds?.telemetryUrl ?? ''; + const token: string = creds?.tokenString ?? ''; + return { url, token }; + } else { + return { url: '', token: '' }; + } + } +} diff --git a/fyo/demux/config.ts b/fyo/demux/config.ts new file mode 100644 index 00000000..252810c1 --- /dev/null +++ b/fyo/demux/config.ts @@ -0,0 +1,55 @@ +import config from 'utils/config'; + +export class Config { + #useElectronConfig: boolean; + fallback: Map = new Map(); + constructor(isElectron: boolean) { + this.#useElectronConfig = isElectron; + } + + get store(): Record { + if (this.#useElectronConfig) { + return config.store; + } else { + const store: Record = {}; + + for (const key of this.fallback.keys()) { + store[key] = this.fallback.get(key); + } + + return store; + } + } + + get(key: string, defaultValue?: unknown): unknown { + if (this.#useElectronConfig) { + return config.get(key, defaultValue); + } else { + return this.fallback.get(key) ?? defaultValue; + } + } + + set(key: string, value: unknown) { + if (this.#useElectronConfig) { + config.set(key, value); + } else { + this.fallback.set(key, value); + } + } + + delete(key: string) { + if (this.#useElectronConfig) { + config.delete(key); + } else { + this.fallback.delete(key); + } + } + + clear() { + if (this.#useElectronConfig) { + config.clear(); + } else { + this.fallback.clear(); + } + } +} diff --git a/fyo/demux/db.ts b/fyo/demux/db.ts new file mode 100644 index 00000000..c9d050fe --- /dev/null +++ b/fyo/demux/db.ts @@ -0,0 +1,96 @@ +import { ipcRenderer } from 'electron'; +import { DatabaseError, NotImplemented } from 'fyo/utils/errors'; +import { SchemaMap } from 'schemas/types'; +import { DatabaseDemuxBase, DatabaseMethod } from 'utils/db/types'; +import { DatabaseResponse } from 'utils/ipc/types'; +import { IPC_ACTIONS } from 'utils/messages'; + +export class DatabaseDemux extends DatabaseDemuxBase { + #isElectron: boolean = false; + constructor(isElectron: boolean) { + super(); + this.#isElectron = isElectron; + } + + async #handleDBCall(func: () => Promise): Promise { + const response = await func(); + + if (response.error?.name) { + const { name, message, stack } = response.error; + const dberror = new DatabaseError(`${name}\n${message}`); + dberror.stack = stack; + + throw dberror; + } + + return response.data; + } + + async getSchemaMap(): Promise { + if (this.#isElectron) { + return (await this.#handleDBCall(async function dbFunc() { + return await ipcRenderer.invoke(IPC_ACTIONS.DB_SCHEMA); + })) as SchemaMap; + } + + throw new NotImplemented(); + } + + async createNewDatabase( + dbPath: string, + countryCode?: string + ): Promise { + if (this.#isElectron) { + return (await this.#handleDBCall(async function dbFunc() { + return await ipcRenderer.invoke( + IPC_ACTIONS.DB_CREATE, + dbPath, + countryCode + ); + })) as string; + } + + throw new NotImplemented(); + } + + async connectToDatabase( + dbPath: string, + countryCode?: string + ): Promise { + if (this.#isElectron) { + return (await this.#handleDBCall(async function dbFunc() { + return await ipcRenderer.invoke( + IPC_ACTIONS.DB_CONNECT, + dbPath, + countryCode + ); + })) as string; + } + + throw new NotImplemented(); + } + + async call(method: DatabaseMethod, ...args: unknown[]): Promise { + if (this.#isElectron) { + return (await this.#handleDBCall(async function dbFunc() { + return await ipcRenderer.invoke(IPC_ACTIONS.DB_CALL, method, ...args); + })) as unknown; + } + + throw new NotImplemented(); + } + + async callBespoke(method: string, ...args: unknown[]): Promise { + if (this.#isElectron) { + return (await this.#handleDBCall(async function dbFunc() { + return await ipcRenderer.invoke( + IPC_ACTIONS.DB_BESPOKE, + method, + ...args + ); + })) as unknown; + } + + throw new NotImplemented(); + } +} diff --git a/fyo/index.ts b/fyo/index.ts new file mode 100644 index 00000000..edeefd25 --- /dev/null +++ b/fyo/index.ts @@ -0,0 +1,230 @@ +import { getMoneyMaker, MoneyMaker } from 'pesa'; +import { Field } from 'schemas/types'; +import { getIsNullOrUndef } from 'utils'; +import { markRaw } from 'vue'; +import { AuthHandler } from './core/authHandler'; +import { DatabaseHandler } from './core/dbHandler'; +import { DocHandler } from './core/docHandler'; +import { DocValue, FyoConfig } from './core/types'; +import { Config } from './demux/config'; +import { Doc } from './model/doc'; +import { ModelMap } from './model/types'; +import { TelemetryManager } from './telemetry/telemetry'; +import { + DEFAULT_CURRENCY, + DEFAULT_DISPLAY_PRECISION, + DEFAULT_INTERNAL_PRECISION, +} from './utils/consts'; +import * as errors from './utils/errors'; +import { format } from './utils/format'; +import { t, T } from './utils/translation'; +import { ErrorLog } from './utils/types'; + +export class Fyo { + t = t; + T = T; + + errors = errors; + isElectron: boolean; + + pesa: MoneyMaker; + + auth: AuthHandler; + doc: DocHandler; + db: DatabaseHandler; + + _initialized: boolean = false; + + errorLog: ErrorLog[] = []; + temp?: Record; + + currencyFormatter?: Intl.NumberFormat; + currencySymbols: Record = {}; + + isTest: boolean; + telemetry: TelemetryManager; + config: Config; + + constructor(conf: FyoConfig = {}) { + this.isTest = conf.isTest ?? false; + this.isElectron = conf.isElectron ?? true; + + this.auth = new AuthHandler(this, conf.AuthDemux); + this.db = new DatabaseHandler(this, conf.DatabaseDemux); + this.doc = new DocHandler(this); + + this.pesa = getMoneyMaker({ + currency: DEFAULT_CURRENCY, + precision: DEFAULT_INTERNAL_PRECISION, + display: DEFAULT_DISPLAY_PRECISION, + wrapper: markRaw, + }); + + this.telemetry = new TelemetryManager(this); + this.config = new Config(this.isElectron && !this.isTest); + } + + get initialized() { + return this._initialized; + } + + get docs() { + return this.doc.docs; + } + + get models() { + return this.doc.models; + } + + get singles() { + return this.doc.singles; + } + + get schemaMap() { + return this.db.schemaMap; + } + + format(value: DocValue, field: string | Field, doc?: Doc) { + return format(value, field, doc ?? null, this); + } + + async setIsElectron() { + try { + const { ipcRenderer } = await import('electron'); + this.isElectron = Boolean(ipcRenderer); + } catch { + this.isElectron = false; + } + } + + async initializeAndRegister( + models: ModelMap = {}, + regionalModels: ModelMap = {}, + force: boolean = false + ) { + if (this._initialized && !force) return; + + await this.#initializeModules(); + await this.#initializeMoneyMaker(); + + this.doc.registerModels(models, regionalModels); + await this.doc.getDoc('SystemSettings'); + this._initialized = true; + } + + async #initializeModules() { + // temp params while calling routes + this.temp = {}; + + await this.doc.init(); + await this.auth.init(); + await this.db.init(); + } + + async #initializeMoneyMaker() { + const values = + (await this.db?.getSingleValues( + { + fieldname: 'internalPrecision', + parent: 'SystemSettings', + }, + { + fieldname: 'displayPrecision', + parent: 'SystemSettings', + }, + { + fieldname: 'currency', + parent: 'SystemSettings', + } + )) ?? []; + + const acc = values.reduce((acc, sv) => { + acc[sv.fieldname] = sv.value as string | number | undefined; + return acc; + }, {} as Record); + + const precision: number = + (acc.internalPrecision as number) ?? DEFAULT_INTERNAL_PRECISION; + const display: number = + (acc.displayPrecision as number) ?? DEFAULT_DISPLAY_PRECISION; + const currency: string = (acc.currency as string) ?? DEFAULT_CURRENCY; + + this.pesa = getMoneyMaker({ + currency, + precision, + display, + wrapper: markRaw, + }); + } + + async close() { + await this.db.close(); + await this.auth.logout(); + } + + getField(schemaName: string, fieldname: string) { + const schema = this.schemaMap[schemaName]; + return schema?.fields.find((f) => f.fieldname === fieldname); + } + + async getValue( + schemaName: string, + name: string, + fieldname?: string + ): Promise { + if (fieldname === undefined && this.schemaMap[schemaName]?.isSingle) { + fieldname = name; + name = schemaName; + } + + if (getIsNullOrUndef(name) || getIsNullOrUndef(fieldname)) { + return undefined; + } + + let doc: Doc; + let value: DocValue | Doc[]; + try { + doc = await this.doc.getDoc(schemaName, name); + value = doc.get(fieldname!); + } catch (err) { + value = undefined; + } + + if (value === undefined && schemaName === name) { + const sv = await this.db.getSingleValues({ + fieldname: fieldname!, + parent: schemaName, + }); + + return sv?.[0]?.value; + } + + return value; + } + + purgeCache() { + this.pesa = getMoneyMaker({ + currency: DEFAULT_CURRENCY, + precision: DEFAULT_INTERNAL_PRECISION, + display: DEFAULT_DISPLAY_PRECISION, + wrapper: markRaw, + }); + + this._initialized = false; + this.temp = {}; + this.currencyFormatter = undefined; + this.currencySymbols = {}; + this.errorLog = []; + this.temp = {}; + this.db.purgeCache(); + this.auth.purgeCache(); + this.doc.purgeCache(); + } + + store = { + isDevelopment: false, + appVersion: '', + }; +} + +export { T, t }; diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts new file mode 100644 index 00000000..069f2303 --- /dev/null +++ b/fyo/model/doc.ts @@ -0,0 +1,817 @@ +import { Fyo } from 'fyo'; +import { Converter } from 'fyo/core/converter'; +import { DocValue, DocValueMap } from 'fyo/core/types'; +import { Verb } from 'fyo/telemetry/types'; +import { DEFAULT_USER } from 'fyo/utils/consts'; +import { ConflictError, MandatoryError, NotFoundError } from 'fyo/utils/errors'; +import Observable from 'fyo/utils/observable'; +import { Money } from 'pesa'; +import { + Field, + FieldTypeEnum, + OptionField, + RawValue, + Schema, + TargetField, +} from 'schemas/types'; +import { getIsNullOrUndef, getMapFromList, getRandomString } from 'utils'; +import { markRaw } from 'vue'; +import { isPesa } from '../utils/index'; +import { + areDocValuesEqual, + getMissingMandatoryMessage, + getPreDefaultValues, + setChildDocIdx, + shouldApplyFormula, +} from './helpers'; +import { setName } from './naming'; +import { + Action, + ChangeArg, + CurrenciesMap, + DefaultMap, + EmptyMessageMap, + FiltersMap, + FormulaMap, + FormulaReturn, + HiddenMap, + ListsMap, + ListViewSettings, + ReadOnlyMap, + RequiredMap, + TreeViewSettings, + ValidationMap, +} from './types'; +import { validateOptions, validateRequired } from './validationFunction'; + +export class Doc extends Observable { + name?: string; + schema: Readonly; + fyo: Fyo; + fieldMap: Record; + + /** + * Fields below are used by child docs to maintain + * reference w.r.t their parent doc. + */ + idx?: number; + parentdoc?: Doc; + parentFieldname?: string; + parentSchemaName?: string; + + _links?: Record; + _dirty: boolean = true; + _notInserted: boolean = true; + + _syncing = false; + constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { + super(); + this.fyo = markRaw(fyo); + this.schema = schema; + this.fieldMap = getMapFromList(schema.fields, 'fieldname'); + + if (this.schema.isSingle) { + this.name = this.schemaName; + } + + this._setDefaults(); + this._setValuesWithoutChecks(data); + } + + get schemaName(): string { + return this.schema.name; + } + + get notInserted(): boolean { + return this._notInserted; + } + + get inserted(): boolean { + return !this._notInserted; + } + + get tableFields(): TargetField[] { + return this.schema.fields.filter( + (f) => f.fieldtype === FieldTypeEnum.Table + ) as TargetField[]; + } + + get dirty() { + return this._dirty; + } + + get quickEditFields() { + let fieldnames = this.schema.quickEditFields; + + if (fieldnames === undefined) { + fieldnames = []; + } + + if (fieldnames.length === 0 && this.fieldMap['name']) { + fieldnames = ['name']; + } + + return fieldnames.map((f) => this.fieldMap[f]); + } + + get isSubmitted() { + return !!this.submitted && !this.cancelled; + } + + get isCancelled() { + return !!this.submitted && !!this.cancelled; + } + + get syncing() { + return this._syncing; + } + + _setValuesWithoutChecks(data: DocValueMap) { + for (const field of this.schema.fields) { + const fieldname = field.fieldname; + const value = data[field.fieldname]; + + if (Array.isArray(value)) { + for (const row of value) { + this.push(fieldname, row); + } + } else if (value !== undefined) { + this[fieldname] = Converter.toDocValue( + value as RawValue, + field, + this.fyo + ); + } else { + this[fieldname] = this[fieldname] ?? null; + } + + if (field.fieldtype === FieldTypeEnum.Table && !this[fieldname]) { + this[fieldname] = []; + } + } + } + + _setDirty(value: boolean) { + this._dirty = value; + if (this.schema.isChild && this.parentdoc) { + this.parentdoc._dirty = value; + } + } + + // set value and trigger change + async set( + fieldname: string | DocValueMap, + value?: DocValue | Doc[] | DocValueMap[] + ): Promise { + if (typeof fieldname === 'object') { + return await this.setMultiple(fieldname as DocValueMap); + } + + if (!this._canSet(fieldname, value)) { + return false; + } + + this._setDirty(true); + if (typeof value === 'string') { + value = value.trim(); + } + + if (Array.isArray(value)) { + for (const row of value) { + this.push(fieldname, row); + } + } else { + const field = this.fieldMap[fieldname]; + await this._validateField(field, value); + this[fieldname] = value; + } + + // always run applyChange from the parentdoc + if (this.schema.isChild && this.parentdoc) { + await this._applyChange(fieldname); + await this.parentdoc._applyChange(this.parentFieldname as string); + } else { + await this._applyChange(fieldname); + } + + return true; + } + + async setMultiple(docValueMap: DocValueMap): Promise { + let hasSet = false; + for (const fieldname in docValueMap) { + const isSet = await this.set( + fieldname, + docValueMap[fieldname] as DocValue | Doc[] + ); + hasSet ||= isSet; + } + + return hasSet; + } + + _canSet( + fieldname: string, + value?: DocValue | Doc[] | DocValueMap[] + ): boolean { + if (fieldname === 'numberSeries' && !this.notInserted) { + return false; + } + + if (value === undefined) { + return false; + } + + if (this.fieldMap[fieldname] === undefined) { + return false; + } + + const currentValue = this.get(fieldname); + if (currentValue === undefined) { + return true; + } + + return !areDocValuesEqual(currentValue as DocValue, value as DocValue); + } + + async _applyChange(fieldname: string): Promise { + await this._applyFormula(fieldname); + await this.trigger('change', { + doc: this, + changed: fieldname, + }); + + return true; + } + + _setDefaults() { + for (const field of this.schema.fields) { + let defaultValue: DocValue | Doc[] = getPreDefaultValues( + field.fieldtype, + this.fyo + ); + + const defaultFunction = + this.fyo.models[this.schemaName]?.defaults?.[field.fieldname]; + if (defaultFunction !== undefined) { + defaultValue = defaultFunction(); + } else if (field.default !== undefined) { + defaultValue = field.default; + } + + if (field.fieldtype === FieldTypeEnum.Currency && !isPesa(defaultValue)) { + defaultValue = this.fyo.pesa!(defaultValue as string | number); + } + + this[field.fieldname] = defaultValue; + } + } + async remove(fieldname: string, idx: number) { + const childDocs = ((this[fieldname] ?? []) as Doc[]).filter( + (row, i) => row.idx !== idx || i !== idx + ); + + setChildDocIdx(childDocs); + this[fieldname] = childDocs; + this._setDirty(true); + return await this._applyChange(fieldname); + } + + async append(fieldname: string, docValueMap: DocValueMap = {}) { + this.push(fieldname, docValueMap); + this._setDirty(true); + return await this._applyChange(fieldname); + } + + push(fieldname: string, docValueMap: Doc | DocValueMap = {}) { + const childDocs = [ + (this[fieldname] ?? []) as Doc[], + this._getChildDoc(docValueMap, fieldname), + ].flat(); + + setChildDocIdx(childDocs); + this[fieldname] = childDocs; + } + + _getChildDoc(docValueMap: Doc | DocValueMap, fieldname: string): Doc { + if (!this.name) { + this.name = getRandomString(); + } + + docValueMap.name ??= getRandomString(); + + // Child Meta Fields + docValueMap.parent ??= this.name; + docValueMap.parentSchemaName ??= this.schemaName; + docValueMap.parentFieldname ??= fieldname; + + if (docValueMap instanceof Doc) { + docValueMap.parentdoc ??= this; + return docValueMap; + } + + const childSchemaName = (this.fieldMap[fieldname] as TargetField).target; + const childDoc = this.fyo.doc.getNewDoc( + childSchemaName, + docValueMap, + false + ); + childDoc.parentdoc = this; + return childDoc; + } + + async _validateSync() { + this._validateMandatory(); + await this._validateFields(); + } + + _validateMandatory() { + const checkForMandatory: Doc[] = [this]; + const tableFields = this.schema.fields.filter( + (f) => f.fieldtype === FieldTypeEnum.Table + ) as TargetField[]; + + for (const field of tableFields) { + const childDocs = this.get(field.fieldname) as Doc[]; + if (!childDocs) { + continue; + } + + checkForMandatory.push(...childDocs); + } + + const missingMandatoryMessage = checkForMandatory + .map((doc) => getMissingMandatoryMessage(doc)) + .filter(Boolean); + + if (missingMandatoryMessage.length > 0) { + const fields = missingMandatoryMessage.join('\n'); + const message = this.fyo.t`Value missing for ${fields}`; + throw new MandatoryError(message); + } + } + + async _validateFields() { + const fields = this.schema.fields; + for (const field of fields) { + if (field.fieldtype === FieldTypeEnum.Table) { + continue; + } + + const value = this.get(field.fieldname) as DocValue; + await this._validateField(field, value); + } + } + + async _validateField(field: Field, value: DocValue) { + if ( + field.fieldtype === FieldTypeEnum.Select || + field.fieldtype === FieldTypeEnum.AutoComplete + ) { + validateOptions(field as OptionField, value as string, this); + } + + validateRequired(field, value, this); + if (getIsNullOrUndef(value)) { + return; + } + + const validator = this.validations[field.fieldname]; + if (validator === undefined) { + return; + } + + await validator(value); + } + + getValidDict(filterMeta: boolean = false): DocValueMap { + let fields = this.schema.fields; + if (filterMeta) { + fields = this.schema.fields.filter((f) => !f.meta); + } + + const data: DocValueMap = {}; + for (const field of fields) { + let value = this[field.fieldname] as DocValue | DocValueMap[]; + + if (Array.isArray(value)) { + value = value.map((doc) => (doc as Doc).getValidDict(filterMeta)); + } + + if (isPesa(value)) { + value = (value as Money).copy(); + } + + if (value === null && this.schema.isSingle) { + continue; + } + + data[field.fieldname] = value; + } + return data; + } + + _setBaseMetaValues() { + if (this.schema.isSubmittable) { + this.submitted = false; + this.cancelled = false; + } + + if (!this.createdBy) { + this.createdBy = this.fyo.auth.session.user || DEFAULT_USER; + } + + if (!this.created) { + this.created = new Date(); + } + + this._updateModifiedMetaValues(); + } + + _updateModifiedMetaValues() { + this.modifiedBy = this.fyo.auth.session.user || DEFAULT_USER; + this.modified = new Date(); + } + + async load() { + if (this.name === undefined) { + return; + } + + const data = await this.fyo.db.get(this.schemaName, this.name); + if (this.schema.isSingle && !data?.name) { + data.name = this.name!; + } + + if (data && data.name) { + this._syncValues(data); + await this.loadLinks(); + } else { + throw new NotFoundError(`Not Found: ${this.schemaName} ${this.name}`); + } + + this._setDirty(false); + this._notInserted = false; + this.fyo.doc.observer.trigger(`load:${this.schemaName}`, this.name); + } + + async loadLinks() { + this._links = {}; + const inlineLinks = this.schema.fields.filter((f) => f.inline); + for (const f of inlineLinks) { + await this.loadLink(f.fieldname); + } + } + + async loadLink(fieldname: string) { + this._links ??= {}; + const field = this.fieldMap[fieldname] as TargetField; + if (field === undefined) { + return; + } + + const value = this.get(fieldname); + if (getIsNullOrUndef(value) || field.target === undefined) { + return; + } + + this._links[fieldname] = await this.fyo.doc.getDoc( + field.target, + value as string + ); + } + + getLink(fieldname: string): Doc | null { + const link = this._links?.[fieldname]; + if (link === undefined) { + return null; + } + + return link; + } + + _syncValues(data: DocValueMap) { + this._clearValues(); + this._setValuesWithoutChecks(data); + this._dirty = false; + this.trigger('change', { + doc: this, + }); + } + + _clearValues() { + for (const { fieldname } of this.schema.fields) { + this[fieldname] = null; + } + + this._dirty = true; + this._notInserted = true; + } + + _setChildDocsIdx() { + const childFields = this.schema.fields.filter( + (f) => f.fieldtype === FieldTypeEnum.Table + ) as TargetField[]; + + for (const field of childFields) { + const childDocs = (this.get(field.fieldname) as Doc[]) ?? []; + setChildDocIdx(childDocs); + } + } + + async _validateDbNotModified() { + if (this.notInserted || !this.name || this.schema.isSingle) { + return; + } + + const dbValues = await this.fyo.db.get(this.schemaName, this.name); + const docModified = (this.modified as Date)?.toISOString(); + const dbModified = (dbValues.modified as Date)?.toISOString(); + + if (dbValues && docModified !== dbModified) { + throw new ConflictError( + this.fyo + .t`${this.schema.label} ${this.name} has been modified after loading` + + ` ${dbModified}, ${docModified}` + ); + } + } + + async _applyFormula(fieldname?: string): Promise { + const doc = this; + let changed = false; + + const childDocs = this.tableFields + .map((f) => (this.get(f.fieldname) as Doc[]) ?? []) + .flat(); + + // children + for (const row of childDocs) { + changed ||= (await row?._applyFormula()) ?? false; + } + + // parent or child row + const formulaFields = Object.keys(this.formulas).map( + (fn) => this.fieldMap[fn] + ); + changed ||= await this._applyFormulaForFields( + formulaFields, + doc, + fieldname + ); + return changed; + } + + async _applyFormulaForFields( + formulaFields: Field[], + doc: Doc, + fieldname?: string + ) { + let changed = false; + for (const field of formulaFields) { + const shouldApply = shouldApplyFormula(field, doc, fieldname); + if (!shouldApply) { + continue; + } + + const newVal = await this._getValueFromFormula(field, doc); + const previousVal = doc.get(field.fieldname); + const isSame = areDocValuesEqual(newVal as DocValue, previousVal); + if (newVal === undefined || isSame) { + continue; + } + + doc[field.fieldname] = newVal; + changed = true; + } + + return changed; + } + + async _getValueFromFormula(field: Field, doc: Doc) { + const { formula } = doc.formulas[field.fieldname] ?? {}; + if (formula === undefined) { + return; + } + + let value: FormulaReturn; + try { + value = await formula(); + } catch { + return; + } + + if (Array.isArray(value) && field.fieldtype === FieldTypeEnum.Table) { + value = value.map((row) => this._getChildDoc(row, field.fieldname)); + } + + return value; + } + + async _preSync() { + this._setChildDocsIdx(); + await this._applyFormula(); + await this._validateSync(); + await this.trigger('validate'); + } + + async _insert() { + await setName(this, this.fyo); + this._setBaseMetaValues(); + await this._preSync(); + + const validDict = this.getValidDict(); + const data = await this.fyo.db.insert(this.schemaName, validDict); + this._syncValues(data); + + this.fyo.telemetry.log(Verb.Created, this.schemaName); + return this; + } + + async _update() { + await this._validateDbNotModified(); + this._updateModifiedMetaValues(); + await this._preSync(); + + const data = this.getValidDict(); + await this.fyo.db.update(this.schemaName, data); + this._syncValues(data); + + return this; + } + + async sync(): Promise { + this._syncing = true; + await this.trigger('beforeSync'); + let doc; + if (this.notInserted) { + doc = await this._insert(); + } else { + doc = await this._update(); + } + this._notInserted = false; + await this.trigger('afterSync'); + this.fyo.doc.observer.trigger(`sync:${this.schemaName}`, this.name); + this._syncing = false; + return doc; + } + + async delete() { + if (this.schema.isSubmittable && !this.isCancelled) { + return; + } + + await this.trigger('beforeDelete'); + await this.fyo.db.delete(this.schemaName, this.name!); + await this.trigger('afterDelete'); + + this.fyo.telemetry.log(Verb.Deleted, this.schemaName); + this.fyo.doc.observer.trigger(`delete:${this.schemaName}`, this.name); + } + + async submit() { + if (!this.schema.isSubmittable || this.submitted || this.cancelled) { + return; + } + + await this.trigger('beforeSubmit'); + await this.setAndSync('submitted', true); + await this.trigger('afterSubmit'); + + this.fyo.telemetry.log(Verb.Submitted, this.schemaName); + this.fyo.doc.observer.trigger(`submit:${this.schemaName}`, this.name); + } + + async cancel() { + if (!this.schema.isSubmittable || !this.submitted || this.cancelled) { + return; + } + + await this.trigger('beforeCancel'); + await this.setAndSync('cancelled', true); + await this.trigger('afterCancel'); + + this.fyo.telemetry.log(Verb.Cancelled, this.schemaName); + this.fyo.doc.observer.trigger(`cancel:${this.schemaName}`, this.name); + } + + async rename(newName: string) { + if (this.submitted) { + return; + } + + const oldName = this.name; + await this.trigger('beforeRename', { oldName, newName }); + await this.fyo.db.rename(this.schemaName, this.name!, newName); + this.name = newName; + await this.trigger('afterRename', { oldName, newName }); + this.fyo.doc.observer.trigger(`rename:${this.schemaName}`, this.name); + } + + async trigger(event: string, params?: unknown) { + if (this[event]) { + await (this[event] as Function)(params); + } + + await super.trigger(event, params); + } + + getSum(tablefield: string, childfield: string, convertToFloat = true) { + const childDocs = (this.get(tablefield) as Doc[]) ?? []; + const sum = childDocs + .map((d) => { + const value = d.get(childfield) ?? 0; + if (!isPesa(value)) { + try { + return this.fyo.pesa(value as string | number); + } catch (err) { + ( + err as Error + ).message += ` value: '${value}' of type: ${typeof value}, fieldname: '${tablefield}', childfield: '${childfield}'`; + throw err; + } + } + return value as Money; + }) + .reduce((a, b) => a.add(b), this.fyo.pesa(0)); + + if (convertToFloat) { + return sum.float; + } + return sum; + } + + async setAndSync(fieldname: string | DocValueMap, value?: DocValue | Doc[]) { + await this.set(fieldname, value); + return await this.sync(); + } + + duplicate(): Doc { + const updateMap = this.getValidDict(true); + for (const field in updateMap) { + const value = updateMap[field]; + if (!Array.isArray(value)) { + continue; + } + + for (const row of value) { + delete row.name; + } + } + + if (this.numberSeries) { + delete updateMap.name; + } else { + updateMap.name = updateMap.name + ' CPY'; + } + + return this.fyo.doc.getNewDoc(this.schemaName, updateMap); + } + + /** + * Lifecycle Methods + * + * Abstractish methods that are called using `this.trigger`. + * These are to be overridden if required when subclassing. + * + * Refrain from running methods that call `this.sync` + * in the `beforeLifecycle` methods. + * + * This may cause the lifecycle function to execute incorrectly. + */ + async change(ch: ChangeArg) {} + async validate() {} + async beforeSync() {} + async afterSync() {} + async beforeSubmit() {} + async afterSubmit() {} + async beforeRename() {} + async afterRename() {} + async beforeCancel() {} + async afterCancel() {} + async beforeDelete() {} + async afterDelete() {} + + formulas: FormulaMap = {}; + validations: ValidationMap = {}; + required: RequiredMap = {}; + hidden: HiddenMap = {}; + readOnly: ReadOnlyMap = {}; + getCurrencies: CurrenciesMap = {}; + + static lists: ListsMap = {}; + static filters: FiltersMap = {}; + static createFilters: FiltersMap = {}; // Used by the *Create* dropdown option + static defaults: DefaultMap = {}; + static emptyMessages: EmptyMessageMap = {}; + + static getListViewSettings(fyo: Fyo): ListViewSettings { + return {}; + } + + static getTreeSettings(fyo: Fyo): TreeViewSettings | void {} + + static getActions(fyo: Fyo): Action[] { + return []; + } +} diff --git a/fyo/model/helpers.ts b/fyo/model/helpers.ts new file mode 100644 index 00000000..927a8c93 --- /dev/null +++ b/fyo/model/helpers.ts @@ -0,0 +1,116 @@ +import { Fyo } from 'fyo'; +import { DocValue } from 'fyo/core/types'; +import { isPesa } from 'fyo/utils'; +import { isEqual } from 'lodash'; +import { Money } from 'pesa'; +import { Field, FieldType, FieldTypeEnum } from 'schemas/types'; +import { getIsNullOrUndef } from 'utils'; +import { Doc } from './doc'; + +export function areDocValuesEqual( + dvOne: DocValue | Doc[], + dvTwo: DocValue | Doc[] +): boolean { + if (['string', 'number'].includes(typeof dvOne) || dvOne instanceof Date) { + return dvOne === dvTwo; + } + + if (isPesa(dvOne)) { + try { + return (dvOne as Money).eq(dvTwo as string | number); + } catch { + return false; + } + } + + return isEqual(dvOne, dvTwo); +} + +export function getPreDefaultValues( + fieldtype: FieldType, + fyo: Fyo +): DocValue | Doc[] { + switch (fieldtype) { + case FieldTypeEnum.Table: + return [] as Doc[]; + case FieldTypeEnum.Currency: + return fyo.pesa!(0.0); + case FieldTypeEnum.Int: + case FieldTypeEnum.Float: + return 0; + default: + return null; + } +} + +export function getMissingMandatoryMessage(doc: Doc) { + const mandatoryFields = getMandatory(doc); + const message = mandatoryFields + .filter((f) => { + const value = doc.get(f.fieldname); + const isNullOrUndef = getIsNullOrUndef(value); + + if (f.fieldtype === FieldTypeEnum.Table) { + return isNullOrUndef || (value as Doc[])?.length === 0; + } + + return isNullOrUndef || value === ''; + }) + .map((f) => f.label ?? f.fieldname) + .join(', '); + + if (message && doc.schema.isChild && doc.parentdoc && doc.parentFieldname) { + const parentfield = doc.parentdoc.fieldMap[doc.parentFieldname]; + return `${parentfield.label} Row ${(doc.idx ?? 0) + 1}: ${message}`; + } + + return message; +} + +function getMandatory(doc: Doc): Field[] { + const mandatoryFields: Field[] = []; + for (const field of doc.schema.fields) { + if (field.required) { + mandatoryFields.push(field); + } + + const requiredFunction = doc.required[field.fieldname]; + if (requiredFunction?.()) { + mandatoryFields.push(field); + } + } + + return mandatoryFields; +} + +export function shouldApplyFormula(field: Field, doc: Doc, fieldname?: string) { + if (!doc.formulas[field.fieldname]) { + return false; + } + + if (field.readOnly) { + return true; + } + + const { dependsOn } = doc.formulas[field.fieldname] ?? {}; + if (dependsOn === undefined) { + return true; + } + + if (dependsOn.length === 0) { + return false; + } + + if (fieldname && dependsOn.includes(fieldname)) { + return true; + } + + const value = doc.get(field.fieldname); + return getIsNullOrUndef(value); +} + +export function setChildDocIdx(childDocs: Doc[]) { + for (const idx in childDocs) { + childDocs[idx].idx = +idx; + } +} diff --git a/fyo/model/naming.ts b/fyo/model/naming.ts new file mode 100644 index 00000000..714657d3 --- /dev/null +++ b/fyo/model/naming.ts @@ -0,0 +1,105 @@ +import { Fyo } from 'fyo'; +import NumberSeries from 'fyo/models/NumberSeries'; +import { DEFAULT_SERIES_START } from 'fyo/utils/consts'; +import { BaseError } from 'fyo/utils/errors'; +import { getRandomString } from 'utils'; +import { Doc } from './doc'; + +export function isNameAutoSet(schemaName: string, fyo: Fyo): boolean { + const schema = fyo.schemaMap[schemaName]!; + if (schema.naming === 'manual') { + return false; + } + + if (schema.naming === 'autoincrement') { + return true; + } + + if (schema.naming === 'random') { + return true; + } + + const numberSeries = fyo.getField(schema.name, 'numberSeries'); + if (numberSeries) { + return true; + } + + return false; +} + +export async function setName(doc: Doc, fyo: Fyo) { + if (doc.schema.naming === 'manual') { + return; + } + + if (doc.schema.naming === 'autoincrement') { + return (doc.name = await getNextId(doc.schemaName, fyo)); + } + + if (doc.numberSeries !== undefined) { + return (doc.name = await getSeriesNext( + doc.numberSeries as string, + doc.schemaName, + fyo + )); + } + + // name === schemaName for Single + if (doc.schema.isSingle) { + return (doc.name = doc.schemaName); + } + + // Assign a random name by default + if (!doc.name) { + doc.name = getRandomString(); + } + + return doc.name; +} + +export async function getNextId(schemaName: string, fyo: Fyo): Promise { + const lastInserted = await fyo.db.getLastInserted(schemaName); + return String(lastInserted + 1).padStart(9, '0'); +} + +export async function getSeriesNext( + prefix: string, + schemaName: string, + fyo: Fyo +) { + let series: NumberSeries; + + try { + series = (await fyo.doc.getDoc('NumberSeries', prefix)) as NumberSeries; + } catch (e) { + const { statusCode } = e as BaseError; + if (!statusCode || statusCode !== 404) { + throw e; + } + + await createNumberSeries(prefix, schemaName, DEFAULT_SERIES_START, fyo); + series = (await fyo.doc.getDoc('NumberSeries', prefix)) as NumberSeries; + } + + return await series.next(schemaName); +} + +export async function createNumberSeries( + prefix: string, + referenceType: string, + start: number, + fyo: Fyo +) { + const exists = await fyo.db.exists('NumberSeries', prefix); + if (exists) { + return; + } + + const series = fyo.doc.getNewDoc('NumberSeries', { + name: prefix, + start, + referenceType, + }); + + await series.sync(); +} diff --git a/fyo/model/types.ts b/fyo/model/types.ts new file mode 100644 index 00000000..35184f18 --- /dev/null +++ b/fyo/model/types.ts @@ -0,0 +1,90 @@ +import { DocValue, DocValueMap } from 'fyo/core/types'; +import SystemSettings from 'fyo/models/SystemSettings'; +import { FieldType, SelectOption } from 'schemas/types'; +import { QueryFilter } from 'utils/db/types'; +import { Router } from 'vue-router'; +import { Doc } from './doc'; + +/** + * The functions below are used for dynamic evaluation + * and setting of field types. + * + * Since they are set directly on the doc, they can + * access the doc by using `this`. + * + * - `Formula`: Async function used for obtaining a computed value such as amount (rate * qty). + * - `Default`: Regular function used to dynamically set the default value, example new Date(). + * - `Validation`: Async function that throw an error if the value is invalid. + * - `Required`: Regular function used to decide if a value is mandatory (there are !notnul in the db). + */ +export type FormulaReturn = DocValue | DocValueMap[] | undefined | Doc[]; +export type Formula = () => Promise | FormulaReturn; +export type FormulaConfig = { dependsOn?: string[]; formula: Formula }; +export type Default = () => DocValue; +export type Validation = (value: DocValue) => Promise | void; +export type Required = () => boolean; +export type Hidden = () => boolean; +export type ReadOnly = () => boolean; +export type GetCurrency = () => string; + +export type FormulaMap = Record; +export type DefaultMap = Record; +export type ValidationMap = Record; +export type RequiredMap = Record; +export type CurrenciesMap = Record; +export type HiddenMap = Record