From 43784984c3f5f0b1f0f95c0bac05e9ee7c0f9141 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Wed, 2 Nov 2022 20:26:37 +0530 Subject: [PATCH] fix: stock movement - test: stock movement create and cancel --- backend/helpers.ts | 1 + fyo/model/doc.ts | 26 ++- fyo/model/helpers.ts | 35 ++++ models/inventory/StockManager.ts | 60 +++--- models/inventory/StockMovement.ts | 29 ++- models/inventory/tests/helpers.ts | 75 ++++++-- models/inventory/tests/testInventory.spec.ts | 183 ++++++++++++++++++- 7 files changed, 327 insertions(+), 82 deletions(-) diff --git a/backend/helpers.ts b/backend/helpers.ts index 29e769c4..95389164 100644 --- a/backend/helpers.ts +++ b/backend/helpers.ts @@ -46,6 +46,7 @@ export const databaseMethodSet: Set = new Set([ 'rename', 'update', 'delete', + 'deleteAll', 'close', 'exists', ]); diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 8f5dcd6f..31fa0d07 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -129,7 +129,7 @@ export class Doc extends Observable { return !!this.submitted && !!this.cancelled; } - get syncing() { + get isSyncing() { return this._syncing; } @@ -159,13 +159,18 @@ export class Doc extends Observable { _setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) { for (const field of this.schema.fields) { - const fieldname = field.fieldname; + const { fieldname, fieldtype } = field; const value = data[field.fieldname]; if (Array.isArray(value)) { for (const row of value) { this.push(fieldname, row, convertToDocValue); } + } else if ( + fieldtype === FieldTypeEnum.Currency && + typeof value === 'number' + ) { + this[fieldname] = this.fyo.pesa(value); } else if (value !== undefined && !convertToDocValue) { this[fieldname] = value; } else if (value !== undefined) { @@ -269,13 +274,13 @@ export class Doc extends Observable { } async _applyChange( - fieldname: string, + changedFieldname: string, retriggerChildDocApplyChange?: boolean ): Promise { - await this._applyFormula(fieldname, retriggerChildDocApplyChange); + await this._applyFormula(changedFieldname, retriggerChildDocApplyChange); await this.trigger('change', { doc: this, - changed: fieldname, + changed: changedFieldname, }); return true; @@ -666,16 +671,17 @@ export class Doc extends Observable { } async _applyFormula( - fieldname?: string, + changedFieldname?: string, retriggerChildDocApplyChange?: boolean ): Promise { const doc = this; - let changed = await this._callAllTableFieldsApplyFormula(fieldname); - changed = (await this._applyFormulaForFields(doc, fieldname)) || changed; + let changed = await this._callAllTableFieldsApplyFormula(changedFieldname); + changed = + (await this._applyFormulaForFields(doc, changedFieldname)) || changed; if (changed && retriggerChildDocApplyChange) { - await this._callAllTableFieldsApplyFormula(fieldname); - await this._applyFormulaForFields(doc, fieldname); + await this._callAllTableFieldsApplyFormula(changedFieldname); + await this._applyFormulaForFields(doc, changedFieldname); } return changed; diff --git a/fyo/model/helpers.ts b/fyo/model/helpers.ts index 927a8c93..5670683e 100644 --- a/fyo/model/helpers.ts +++ b/fyo/model/helpers.ts @@ -105,10 +105,45 @@ export function shouldApplyFormula(field: Field, doc: Doc, fieldname?: string) { return true; } + if (doc.isSyncing && dependsOn.length > 0) { + return shouldApplyFormulaPreSync(field.fieldname, dependsOn, doc); + } + const value = doc.get(field.fieldname); return getIsNullOrUndef(value); } +function shouldApplyFormulaPreSync( + fieldname: string, + dependsOn: string[], + doc: Doc +): boolean { + if (isDocValueTruthy(doc.get(fieldname))) { + return false; + } + + for (const d of dependsOn) { + const isSet = isDocValueTruthy(doc.get(d)); + if (isSet) { + return true; + } + } + + return false; +} + +export function isDocValueTruthy(docValue: DocValue | Doc[]) { + if (isPesa(docValue)) { + return !(docValue as Money).isZero(); + } + + if (Array.isArray(docValue)) { + return docValue.length > 0; + } + + return !!docValue; +} + export function setChildDocIdx(childDocs: Doc[]) { for (const idx in childDocs) { childDocs[idx].idx = +idx; diff --git a/models/inventory/StockManager.ts b/models/inventory/StockManager.ts index 406b7fb7..1fb217e6 100644 --- a/models/inventory/StockManager.ts +++ b/models/inventory/StockManager.ts @@ -25,25 +25,41 @@ export class StockManager { this.fyo = fyo; } - async transferStock(transferDetails: SMTransferDetails) { - const details = this.#getSMIDetails(transferDetails); - const item = new StockManagerItem(details, this.fyo); - await item.transferStock(this.isCancelled); - this.items.push(item); + async createTransfers(transferDetails: SMTransferDetails[]) { + for (const detail of transferDetails) { + await this.#createTransfer(detail); + } + + await this.#sync(); } - async sync() { + async cancelTransfers() { + const { referenceName, referenceType } = this.details; + await this.fyo.db.deleteAll(ModelNameEnum.StockLedgerEntry, { + referenceType, + referenceName, + }); + } + + async #sync() { for (const item of this.items) { await item.sync(); } } + async #createTransfer(transferDetails: SMTransferDetails) { + const details = this.#getSMIDetails(transferDetails); + const item = new StockManagerItem(details, this.fyo); + await item.transferStock(); + this.items.push(item); + } + #getSMIDetails(transferDetails: SMTransferDetails): SMIDetails { return Object.assign({}, this.details, transferDetails); } } -export class StockManagerItem { +class StockManagerItem { /** * The Stock Manager Item is used to move stock to and from a location. It * updates the Stock Queue and creates Stock Ledger Entries. @@ -80,9 +96,9 @@ export class StockManagerItem { this.fyo = fyo; } - async transferStock(isCancelled: boolean) { + transferStock() { this.#clear(); - await this.#moveStockForBothLocations(isCancelled); + this.#moveStockForBothLocations(); } async sync() { @@ -91,29 +107,17 @@ export class StockManagerItem { } } - async #moveStockForBothLocations(isCancelled: boolean) { + #moveStockForBothLocations() { if (this.fromLocation) { - await this.#moveStockForSingleLocation( - this.fromLocation, - isCancelled ? false : true, - isCancelled - ); + this.#moveStockForSingleLocation(this.fromLocation, true); } if (this.toLocation) { - await this.#moveStockForSingleLocation( - this.toLocation, - isCancelled ? true : false, - isCancelled - ); + this.#moveStockForSingleLocation(this.toLocation, false); } } - async #moveStockForSingleLocation( - location: string, - isOutward: boolean, - isCancelled: boolean - ) { + #moveStockForSingleLocation(location: string, isOutward: boolean) { let quantity = this.quantity!; if (quantity === 0) { return; @@ -124,10 +128,8 @@ export class StockManagerItem { } // Stock Ledger Entry - if (!isCancelled) { - const stockLedgerEntry = this.#getStockLedgerEntry(location, quantity); - this.stockLedgerEntries?.push(stockLedgerEntry); - } + const stockLedgerEntry = this.#getStockLedgerEntry(location, quantity); + this.stockLedgerEntries?.push(stockLedgerEntry); } #getStockLedgerEntry(location: string, quantity: number) { diff --git a/models/inventory/StockMovement.ts b/models/inventory/StockMovement.ts index fb08c25f..a1367034 100644 --- a/models/inventory/StockMovement.ts +++ b/models/inventory/StockMovement.ts @@ -44,29 +44,22 @@ export class StockMovement extends Doc { } async afterSubmit(): Promise { - await this._transferStock(); + const transferDetails = this._getTransferDetails(); + await this._getStockManager().createTransfers(transferDetails); } async afterCancel(): Promise { - await this._transferStock(); + await this._getStockManager().cancelTransfers(); } - async _transferStock() { - const stockManager = this._getStockManager(); - this._makeTransfers(stockManager); - await stockManager.sync(); - } - - _makeTransfers(stockManager: StockManager) { - for (const row of this.items ?? []) { - stockManager.transferStock({ - item: row.item!, - rate: row.rate!, - quantity: row.quantity!, - fromLocation: row.fromLocation, - toLocation: row.toLocation, - }); - } + _getTransferDetails() { + return (this.items ?? []).map((row) => ({ + item: row.item!, + rate: row.rate!, + quantity: row.quantity!, + fromLocation: row.fromLocation, + toLocation: row.toLocation, + })); } _getStockManager(): StockManager { diff --git a/models/inventory/tests/helpers.ts b/models/inventory/tests/helpers.ts index 39d09489..20f476cb 100644 --- a/models/inventory/tests/helpers.ts +++ b/models/inventory/tests/helpers.ts @@ -1,21 +1,64 @@ import { Fyo } from 'fyo'; import { ModelNameEnum } from 'models/types'; -import test from 'tape'; +import { StockMovement } from '../StockMovement'; +import { MovementType } from '../types'; -export const dummyItems = [ - { - name: 'Ball Pen', - rate: 50, - for: 'Both', - trackItem: true, - }, - { - name: 'Ink Pen', - rate: 700, - for: 'Both', - trackItem: true, - }, -]; +type SLE = { + date: string; + name: string; + item: string; + location: string; + rate: string; + quantity: string; +}; -export function createDummyItems(fyo: Fyo) { +type Transfer = { + item: string; + from?: string; + to?: string; + quantity: number; + rate: number; +}; + +export function getItem(name: string, rate: number) { + return { name, rate, trackItem: true }; +} + +export async function getStockMovement( + movementType: MovementType, + transfers: Transfer[], + fyo: Fyo +): Promise { + const doc = fyo.doc.getNewDoc(ModelNameEnum.StockMovement, { + movementType, + }) as StockMovement; + + for (const { + item, + from: fromLocation, + to: toLocation, + quantity, + rate, + } of transfers) { + await doc.append('items', { + item, + fromLocation, + toLocation, + rate, + quantity, + }); + } + + return doc; +} + +export async function getSLEs( + referenceName: string, + referenceType: string, + fyo: Fyo +) { + return (await fyo.db.getAllRaw(ModelNameEnum.StockLedgerEntry, { + filters: { referenceName, referenceType }, + fields: ['date', 'name', 'item', 'location', 'rate', 'quantity'], + })) as SLE[]; } diff --git a/models/inventory/tests/testInventory.spec.ts b/models/inventory/tests/testInventory.spec.ts index cd7f1f2b..919803ae 100644 --- a/models/inventory/tests/testInventory.spec.ts +++ b/models/inventory/tests/testInventory.spec.ts @@ -1,24 +1,189 @@ import { ModelNameEnum } from 'models/types'; import test from 'tape'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; -import { dummyItems } from './helpers'; +import { StockMovement } from '../StockMovement'; +import { MovementType } from '../types'; +import { getItem, getSLEs, getStockMovement } from './helpers'; const fyo = getTestFyo(); setupTestFyo(fyo, __filename); -test('create dummy items', async (t) => { - for (const item of dummyItems) { - const doc = fyo.doc.getNewDoc(ModelNameEnum.Item, item); - t.ok(await doc.sync(), `${item.name} created`); +const itemMap = { + Pen: { + name: 'Pen', + rate: 700, + }, + Ink: { + name: 'Ink', + rate: 50, + }, +}; + +const locationMap = { + LocationOne: 'LocationOne', + LocationTwo: 'LocationTwo', +}; + +/** + * Section 1: Test Creation of Items and Locations + */ + +test('create dummy items & locations', async (t) => { + // Create Items + for (const { name, rate } of Object.values(itemMap)) { + const item = getItem(name, rate); + await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync(); + t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `${name} exists`); + } + + // Create Locations + for (const name of Object.values(locationMap)) { + await fyo.doc.getNewDoc(ModelNameEnum.Location, { name }).sync(); + t.ok(await fyo.db.exists(ModelNameEnum.Location, name), `${name} exists`); } }); -test('check dummy items', async (t) => { - for (const { name } of dummyItems) { - const exists = await fyo.db.exists(ModelNameEnum.Item, name); - t.ok(exists, `${name} exists`); +/** + * Section 2: Test Creation of Stock Movements + */ + +test('create stock movement, material receipt', async (t) => { + const { rate } = itemMap.Ink; + const quantity = 2; + const amount = rate * quantity; + const stockMovement = await getStockMovement( + MovementType.MaterialReceipt, + [ + { + item: itemMap.Ink.name, + to: locationMap.LocationOne, + quantity, + rate, + }, + ], + fyo + ); + + await (await stockMovement.sync()).submit(); + t.ok(stockMovement.name?.startsWith('SMOV-')); + t.equal(stockMovement.amount?.float, amount); + t.equal(stockMovement.items?.[0].amount?.float, amount); + + const name = stockMovement.name!; + + const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo); + t.equal(sles.length, 1); + + const sle = sles[0]; + t.notEqual(new Date(sle.date).toString(), 'Invalid Date'); + t.equal(parseInt(sle.name), 1); + t.equal(sle.item, itemMap.Ink.name); + t.equal(parseFloat(sle.rate), rate); + t.equal(sle.quantity, quantity); + t.equal(sle.location, locationMap.LocationOne); +}); + +test('create stock movement, material transfer', async (t) => { + const { rate } = itemMap.Ink; + const quantity = 2; + + const stockMovement = await getStockMovement( + MovementType.MaterialTransfer, + [ + { + item: itemMap.Ink.name, + from: locationMap.LocationOne, + to: locationMap.LocationTwo, + quantity, + rate, + }, + ], + fyo + ); + + await (await stockMovement.sync()).submit(); + const name = stockMovement.name!; + + const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo); + t.equal(sles.length, 2); + + for (const sle of sles) { + t.notEqual(new Date(sle.date).toString(), 'Invalid Date'); + t.equal(sle.item, itemMap.Ink.name); + t.equal(parseFloat(sle.rate), rate); + + if (sle.location === locationMap.LocationOne) { + t.equal(sle.quantity, -quantity); + } else if (sle.location === locationMap.LocationTwo) { + t.equal(sle.quantity, quantity); + } else { + t.ok(false, 'no-op'); + } } }); +test('create stock movement, material issue', async (t) => { + const { rate } = itemMap.Ink; + const quantity = 2; + + const stockMovement = await getStockMovement( + MovementType.MaterialIssue, + [ + { + item: itemMap.Ink.name, + from: locationMap.LocationTwo, + quantity, + rate, + }, + ], + fyo + ); + + await (await stockMovement.sync()).submit(); + const name = stockMovement.name!; + + const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo); + t.equal(sles.length, 1); + + const sle = sles[0]; + t.notEqual(new Date(sle.date).toString(), 'Invalid Date'); + t.equal(sle.item, itemMap.Ink.name); + t.equal(parseFloat(sle.rate), rate); + t.equal(sle.quantity, -quantity); + t.equal(sle.location, locationMap.LocationTwo); +}); + +/** + * Section 3: Test Cancellation of Stock Movements + */ + +test('cancel stock movement', async (t) => { + const names = (await fyo.db.getAllRaw(ModelNameEnum.StockMovement)) as { + name: string; + }[]; + + for (const { name } of names) { + const slesBefore = await getSLEs(name, ModelNameEnum.StockMovement, fyo); + const doc = (await fyo.doc.getDoc( + ModelNameEnum.StockMovement, + name + )) as StockMovement; + + if (doc.movementType === MovementType.MaterialTransfer) { + t.equal(slesBefore.length, (doc.items?.length ?? 0) * 2); + } else { + t.equal(slesBefore.length, doc.items?.length ?? 0); + } + + await doc.cancel(); + const slesAfter = await getSLEs(name, ModelNameEnum.StockMovement, fyo); + t.equal(slesAfter.length, 0); + } +}); + +/** + * Section 4: Test Invalid entries + */ + closeTestFyo(fyo, __filename);