diff --git a/backend/database/bespoke.ts b/backend/database/bespoke.ts index 8970e1ee..ee830635 100644 --- a/backend/database/bespoke.ts +++ b/backend/database/bespoke.ts @@ -1,3 +1,4 @@ +import { ModelNameEnum } from '../../models/types'; import DatabaseCore from './core'; import { BespokeFunction } from './types'; @@ -130,4 +131,35 @@ export class BespokeQueries { group by account `); } + + static async getStockQuantity( + db: DatabaseCore, + item: string, + location?: string, + fromDate?: string, + toDate?: string + ): Promise { + const query = db.knex!(ModelNameEnum.StockLedgerEntry) + .sum('quantity') + .where('item', item); + + if (location) { + query.andWhere('location', location); + } + + if (fromDate) { + query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]); + } + + if (toDate) { + query.andWhereRaw('datetime(date) < datetime(?)', [toDate]); + } + + const value = (await query) as Record[]; + if (!value.length) { + return null; + } + + return value[0][Object.keys(value[0])[0]]; + } } diff --git a/backend/database/tests/helpers.ts b/backend/database/tests/helpers.ts index 9a58e3f1..9bfa8de3 100644 --- a/backend/database/tests/helpers.ts +++ b/backend/database/tests/helpers.ts @@ -1,12 +1,12 @@ 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'; +import { SchemaMap, SchemaStub, SchemaStubMap } from '../../../schemas/types'; const Customer = { name: 'Customer', @@ -188,7 +188,7 @@ export async function assertThrows( } finally { if (!threw) { throw new assert.AssertionError({ - message: `Missing expected exception: ${message}`, + message: `Missing expected exception${message ? `: ${message}` : ''}`, }); } } @@ -202,9 +202,9 @@ export async function assertDoesNotThrow( await func(); } catch (err) { throw new assert.AssertionError({ - message: `Got unwanted exception: ${message}\nError: ${ - (err as Error).message - }\n${(err as Error).stack}`, + message: `Got unwanted exception${ + message ? `: ${message}` : '' + }\nError: ${(err as Error).message}\n${(err as Error).stack}`, }); } } diff --git a/fyo/core/dbHandler.ts b/fyo/core/dbHandler.ts index 3863244a..d4675000 100644 --- a/fyo/core/dbHandler.ts +++ b/fyo/core/dbHandler.ts @@ -19,7 +19,7 @@ import { DatabaseDemuxConstructor, DocValue, DocValueMap, - RawValueMap + RawValueMap, } from './types'; // Return types of Bespoke Queries @@ -306,6 +306,21 @@ export class DatabaseHandler extends DatabaseBase { )) as TotalCreditAndDebit[]; } + async getStockQuantity( + item: string, + location?: string, + fromDate?: string, + toDate?: string + ): Promise { + return (await this.#demux.callBespoke( + 'getStockQuantity', + item, + location, + fromDate, + toDate + )) as number | null; + } + /** * Internal methods */ diff --git a/models/inventory/StockManager.ts b/models/inventory/StockManager.ts index 1fb217e6..accc8914 100644 --- a/models/inventory/StockManager.ts +++ b/models/inventory/StockManager.ts @@ -26,8 +26,13 @@ export class StockManager { } async createTransfers(transferDetails: SMTransferDetails[]) { - for (const detail of transferDetails) { - await this.#createTransfer(detail); + const detailsList = transferDetails.map((d) => this.#getSMIDetails(d)); + for (const details of detailsList) { + await this.#validate(details); + } + + for (const details of detailsList) { + await this.#createTransfer(details); } await this.#sync(); @@ -47,16 +52,111 @@ export class StockManager { } } - async #createTransfer(transferDetails: SMTransferDetails) { - const details = this.#getSMIDetails(transferDetails); + async #createTransfer(details: SMIDetails) { const item = new StockManagerItem(details, this.fyo); - await item.transferStock(); + item.transferStock(); this.items.push(item); } #getSMIDetails(transferDetails: SMTransferDetails): SMIDetails { return Object.assign({}, this.details, transferDetails); } + + async #validate(details: SMIDetails) { + this.#validateRate(details); + this.#validateQuantity(details); + this.#validateLocation(details); + await this.#validateStockAvailability(details); + } + + #validateQuantity(details: SMIDetails) { + if (!details.quantity) { + throw new ValidationError(t`Quantity needs to be set`); + } + + if (details.quantity <= 0) { + throw new ValidationError( + t`Quantity (${details.quantity}) has to be greater than zero` + ); + } + } + + #validateRate(details: SMIDetails) { + if (!details.rate) { + throw new ValidationError(t`Rate needs to be set`); + } + + if (details.rate.lte(0)) { + throw new ValidationError( + t`Rate (${details.rate.float}) has to be greater than zero` + ); + } + } + + #validateLocation(details: SMIDetails) { + if (details.fromLocation) { + return; + } + + if (details.toLocation) { + return; + } + + throw new ValidationError(t`Both From and To Location cannot be undefined`); + } + + async #validateStockAvailability(details: SMIDetails) { + if (!details.fromLocation) { + return; + } + + const quantityBefore = + (await this.fyo.db.getStockQuantity( + details.item, + details.fromLocation, + undefined, + details.date.toISOString() + )) ?? 0; + const formattedDate = this.fyo.format(details.date, 'Datetime'); + + if (quantityBefore < details.quantity) { + throw new ValidationError( + [ + t`Insufficient Quantity.`, + t`Additional quantity (${ + details.quantity - quantityBefore + }) required to make outward transfer of item ${details.item} from ${ + details.fromLocation + } on ${formattedDate}`, + ].join('\n') + ); + } + + const quantityAfter = await this.fyo.db.getStockQuantity( + details.item, + details.fromLocation, + details.date.toISOString() + ); + if (quantityAfter === null) { + // No future transactions + return; + } + + const quantityRemaining = quantityBefore - details.quantity; + if (quantityAfter < quantityRemaining) { + throw new ValidationError( + [ + t`Insufficient Quantity.`, + t`Transfer will cause future entries to have negative stock.`, + t`Additional quantity (${ + quantityAfter - quantityRemaining + }) required to make outward transfer of item ${details.item} from ${ + details.fromLocation + } on ${formattedDate}`, + ].join('\n') + ); + } + } } class StockManagerItem { @@ -91,7 +191,6 @@ class StockManagerItem { this.toLocation = details.toLocation; this.referenceName = details.referenceName; this.referenceType = details.referenceType; - this.#validate(); this.fyo = fyo; } @@ -102,8 +201,15 @@ class StockManagerItem { } async sync() { - for (const sle of this.stockLedgerEntries ?? []) { - await sle.sync(); + const sles = [ + this.stockLedgerEntries?.filter((s) => s.quantity! <= 0), + this.stockLedgerEntries?.filter((s) => s.quantity! > 0), + ] + .flat() + .filter(Boolean); + + for (const sle of sles) { + await sle!.sync(); } } @@ -147,48 +253,4 @@ class StockManagerItem { #clear() { this.stockLedgerEntries = []; } - - #validate() { - this.#validateRate(); - this.#validateQuantity(); - this.#validateLocation(); - } - - #validateQuantity() { - if (!this.quantity) { - throw new ValidationError(t`Stock Manager: quantity needs to be set`); - } - - if (this.quantity <= 0) { - throw new ValidationError( - t`Stock Manager: quantity (${this.quantity}) has to be greater than zero` - ); - } - } - - #validateRate() { - if (!this.rate) { - throw new ValidationError(t`Stock Manager: rate needs to be set`); - } - - if (this.rate.lte(0)) { - throw new ValidationError( - t`Stock Manager: rate (${this.rate.float}) has to be greater than zero` - ); - } - } - - #validateLocation() { - if (this.fromLocation) { - return; - } - - if (this.toLocation) { - return; - } - - throw new ValidationError( - t`Stock Manager: both From and To Location cannot be undefined` - ); - } } diff --git a/models/inventory/tests/helpers.ts b/models/inventory/tests/helpers.ts index 20f476cb..e6011aa8 100644 --- a/models/inventory/tests/helpers.ts +++ b/models/inventory/tests/helpers.ts @@ -26,11 +26,13 @@ export function getItem(name: string, rate: number) { export async function getStockMovement( movementType: MovementType, + date: Date, transfers: Transfer[], fyo: Fyo ): Promise { const doc = fyo.doc.getNewDoc(ModelNameEnum.StockMovement, { movementType, + date, }) as StockMovement; for (const { diff --git a/models/inventory/tests/testInventory.spec.ts b/models/inventory/tests/testInventory.spec.ts index 919803ae..e8a8cb1e 100644 --- a/models/inventory/tests/testInventory.spec.ts +++ b/models/inventory/tests/testInventory.spec.ts @@ -1,5 +1,9 @@ +import { + assertDoesNotThrow, + assertThrows +} from 'backend/database/tests/helpers'; import { ModelNameEnum } from 'models/types'; -import test from 'tape'; +import { default as tape, default as test } from 'tape'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { StockMovement } from '../StockMovement'; import { MovementType } from '../types'; @@ -54,6 +58,7 @@ test('create stock movement, material receipt', async (t) => { const amount = rate * quantity; const stockMovement = await getStockMovement( MovementType.MaterialReceipt, + new Date('2022-11-03T09:57:04.528'), [ { item: itemMap.Ink.name, @@ -82,6 +87,7 @@ test('create stock movement, material receipt', async (t) => { t.equal(parseFloat(sle.rate), rate); t.equal(sle.quantity, quantity); t.equal(sle.location, locationMap.LocationOne); + t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity); }); test('create stock movement, material transfer', async (t) => { @@ -90,6 +96,7 @@ test('create stock movement, material transfer', async (t) => { const stockMovement = await getStockMovement( MovementType.MaterialTransfer, + new Date('2022-11-03T09:58:04.528'), [ { item: itemMap.Ink.name, @@ -121,6 +128,12 @@ test('create stock movement, material transfer', async (t) => { t.ok(false, 'no-op'); } } + + t.equal( + await fyo.db.getStockQuantity(itemMap.Ink.name, locationMap.LocationOne), + 0 + ); + t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity); }); test('create stock movement, material issue', async (t) => { @@ -129,6 +142,7 @@ test('create stock movement, material issue', async (t) => { const stockMovement = await getStockMovement( MovementType.MaterialIssue, + new Date('2022-11-03T09:59:04.528'), [ { item: itemMap.Ink.name, @@ -152,6 +166,7 @@ test('create stock movement, material issue', async (t) => { t.equal(parseFloat(sle.rate), rate); t.equal(sle.quantity, -quantity); t.equal(sle.location, locationMap.LocationTwo); + t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), 0); }); /** @@ -180,10 +195,180 @@ test('cancel stock movement', async (t) => { const slesAfter = await getSLEs(name, ModelNameEnum.StockMovement, fyo); t.equal(slesAfter.length, 0); } + + t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), null); }); /** * Section 4: Test Invalid entries */ +async function runEntries( + item: string, + entries: { + type: MovementType; + date: Date; + valid: boolean; + postQuantity: number; + items: { + item: string; + to?: string; + from?: string; + quantity: number; + rate: number; + }[]; + }[], + t: tape.Test +) { + for (const { type, date, items, valid, postQuantity } of entries) { + const stockMovement = await getStockMovement(type, date, items, fyo); + await stockMovement.sync(); + + if (valid) { + await assertDoesNotThrow(async () => await stockMovement.submit()); + } else { + await assertThrows(async () => await stockMovement.submit()); + } + + t.equal(await fyo.db.getStockQuantity(item), postQuantity); + } +} + +test('create stock movements, invalid entries, in sequence', async (t) => { + const { name: item, rate } = itemMap.Ink; + const quantity = 10; + await runEntries( + item, + [ + { + type: MovementType.MaterialReceipt, + date: new Date('2022-11-03T09:58:04.528'), + valid: true, + postQuantity: quantity, + items: [ + { + item, + to: locationMap.LocationOne, + quantity, + rate, + }, + ], + }, + { + type: MovementType.MaterialTransfer, + date: new Date('2022-11-03T09:58:05.528'), + valid: false, + postQuantity: quantity, + items: [ + { + item, + from: locationMap.LocationOne, + to: locationMap.LocationTwo, + quantity: quantity + 1, + rate, + }, + ], + }, + { + type: MovementType.MaterialIssue, + date: new Date('2022-11-03T09:58:06.528'), + valid: false, + postQuantity: quantity, + items: [ + { + item, + from: locationMap.LocationOne, + quantity: quantity + 1, + rate, + }, + ], + }, + { + type: MovementType.MaterialTransfer, + date: new Date('2022-11-03T09:58:07.528'), + valid: true, + postQuantity: quantity, + items: [ + { + item, + from: locationMap.LocationOne, + to: locationMap.LocationTwo, + quantity, + rate, + }, + ], + }, + { + type: MovementType.MaterialIssue, + date: new Date('2022-11-03T09:58:08.528'), + valid: true, + postQuantity: 0, + items: [ + { + item, + from: locationMap.LocationTwo, + quantity, + rate, + }, + ], + }, + ], + t + ); +}); + +test('create stock movements, invalid entries, out of sequence', async (t) => { + const { name: item, rate } = itemMap.Ink; + const quantity = 10; + await runEntries( + item, + [ + { + type: MovementType.MaterialReceipt, + date: new Date('2022-11-15'), + valid: true, + postQuantity: quantity, + items: [ + { + item, + to: locationMap.LocationOne, + quantity, + rate, + }, + ], + }, + { + type: MovementType.MaterialIssue, + date: new Date('2022-11-17'), + valid: true, + postQuantity: quantity - 5, + items: [ + { + item, + from: locationMap.LocationOne, + quantity: quantity - 5, + rate, + }, + ], + }, + { + type: MovementType.MaterialTransfer, + date: new Date('2022-11-16'), + valid: false, + postQuantity: quantity - 5, + items: [ + { + item, + from: locationMap.LocationOne, + to: locationMap.LocationTwo, + quantity, + rate, + }, + ], + }, + ], + t + ); +}); + closeTestFyo(fyo, __filename); diff --git a/schemas/app/inventory/StockMovement.json b/schemas/app/inventory/StockMovement.json index 941aba5a..b4d41684 100644 --- a/schemas/app/inventory/StockMovement.json +++ b/schemas/app/inventory/StockMovement.json @@ -50,7 +50,7 @@ }, { "fieldname": "amount", - "label": "Amount", + "label": "Total Amount", "fieldtype": "Currency", "readOnly": true },