diff --git a/models/inventory/StockLedgerEntry.ts b/models/inventory/StockLedgerEntry.ts index 42a9d5aa..708011a0 100644 --- a/models/inventory/StockLedgerEntry.ts +++ b/models/inventory/StockLedgerEntry.ts @@ -1,3 +1,15 @@ import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; -export class StockLedgerEntry extends Doc {} + +export class StockLedgerEntry extends Doc { + date?: Date; + item?: string; + rate?: Money; + quantity?: number; + location?: string; + referenceName?: string; + referenceType?: string; + stockValueBefore?: Money; + stockValueAfter?: Money; +} diff --git a/models/inventory/StockManager.ts b/models/inventory/StockManager.ts new file mode 100644 index 00000000..605d5a45 --- /dev/null +++ b/models/inventory/StockManager.ts @@ -0,0 +1,156 @@ +import { Fyo, t } from 'fyo'; +import { ValidationError } from 'fyo/utils/errors'; +import { ModelNameEnum } from 'models/types'; +import { Money } from 'pesa'; +import { getStockQueue } from './helpers'; +import { StockLedgerEntry } from './StockLedgerEntry'; +import { SMDetails } from './types'; + +export class StockManager { + /** + * The Stock Manager is used to move stock to and from a location. It + * updates the Stock Queue and creates Stock Ledger Entries. + * + * 1. Get existing stock Queue + * 2. Get Stock Value Before from Stock Queue + * 3. Update Stock Queue + * 4. Get Stock Value After from Stock Queue + * 5. Create Stock Ledger Entry + * 6. Save Stock Queue + * 7. Insert Stock Ledger Entry + */ + + date?: Date; + item?: string; + rate?: Money; + quantity?: number; + fromLocation?: string; + toLocation?: string; + referenceName?: string; + referenceType?: string; + stockValue?: string; + stockValueDifference?: string; + + fyo: Fyo; + + constructor(fyo: Fyo) { + this.fyo = fyo; + } + + moveStock(details: SMDetails) { + this.date = details.date; + this.item = details.item; + this.quantity = details.quantity; + this.fromLocation = details.fromLocation; + this.toLocation = details.toLocation; + this.referenceName = details.referenceName; + this.referenceType = details.referenceType; + + this.#validate(); + this.#moveStockForBothLocations(); + } + + async #moveStockForBothLocations() { + if (this.fromLocation) { + await this.#moveStockForSingleLocation(this.fromLocation, true); + } + + if (this.toLocation) { + await this.#moveStockForSingleLocation(this.toLocation, false); + } + } + + async #moveStockForSingleLocation(location: string, isOutward: boolean) { + let quantity = this.quantity!; + if (isOutward) { + quantity = -quantity; + } + + const { stockQueue, stockValueBefore, stockValueAfter } = + await this.#makeStockQueueChange(location, isOutward); + const stockLedgerEntry = this.#getStockLedgerEntry( + location, + quantity, + stockValueBefore, + stockValueAfter + ); + + await stockQueue.sync(); + await stockLedgerEntry.sync(); + } + + #getStockLedgerEntry( + location: string, + quantity: number, + stockValueBefore: Money, + stockValueAfter: Money + ) { + return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, { + date: this.date, + item: this.item, + rate: this.rate, + quantity, + location, + stockValueBefore, + stockValueAfter, + referenceName: this.referenceName, + referenceType: this.referenceType, + }) as StockLedgerEntry; + } + + async #makeStockQueueChange(location: string, isOutward: boolean) { + const stockQueue = await getStockQueue(this.item!, location, this.fyo); + const stockValueBefore = stockQueue.stockValue!; + let isSuccess; + + if (isOutward) { + isSuccess = stockQueue.outward(-this.quantity!); + } else { + isSuccess = stockQueue.inward(this.rate!, this.quantity!); + } + + if (!isSuccess && isOutward) { + throw new ValidationError( + t`Stock Manager: Insufficient quantity ${ + stockQueue.quantity + } at ${location} of ${this + .item!} for outward transaction. Quantity required ${this.quantity!}.` + ); + } + + const stockValueAfter = stockQueue.stockValue!; + + return { stockQueue, stockValueBefore, stockValueAfter }; + } + + #validate() { + this.#validateRate(); + this.#validateLocation(); + } + + #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/StockQueue.ts b/models/inventory/StockQueue.ts index b0ade7bb..7d0a6487 100644 --- a/models/inventory/StockQueue.ts +++ b/models/inventory/StockQueue.ts @@ -1,3 +1,119 @@ import { Doc } from 'fyo/model/doc'; +import { Money } from 'pesa'; -export class StockQueue extends Doc {} \ No newline at end of file +type StockQueueItem = { rate: Money; quantity: number }; + +export class StockQueue extends Doc { + item?: string; + location?: string; + queue?: string; + stockValue?: Money; + + /** + * Stock Queue + * + * Used to keep track of inward rates for + * stock valuation purposes. + */ + + get quantity(): number { + return this.stockQueue.reduce((qty, sqi) => { + return qty + sqi.quantity; + }, 0); + } + + get stockQueue(): StockQueueItem[] { + const stringifiedRatesQueue = JSON.parse(this.queue ?? '[]') as { + rate: string; + quantity: number; + }[]; + + return stringifiedRatesQueue.map(({ rate, quantity }) => ({ + rate: this.fyo.pesa(rate), + quantity, + })); + } + + set stockQueue(stockQueue: StockQueueItem[]) { + const stringifiedRatesQueue = stockQueue.map(({ rate, quantity }) => ({ + rate: rate.store, + quantity, + })); + this.queue = JSON.stringify(stringifiedRatesQueue); + } + + inward(rate: Money, quantity: number): boolean { + const stockQueue = this.stockQueue; + stockQueue.push({ rate, quantity }); + this.stockQueue = stockQueue; + + this._updateStockValue(stockQueue); + return true; + } + + outward(quantity: number): boolean { + const stockQueue = this.stockQueue; + const outwardQueues = getQueuesPostOutwards(stockQueue, quantity); + if (!outwardQueues.isPossible) { + return false; + } + + this.stockQueue = outwardQueues.balanceQueue; + this._updateStockValue(outwardQueues.balanceQueue); + return true; + } + + _updateStockValue(stockQueue: StockQueueItem[]) { + this.stockValue = stockQueue.reduce((acc, { rate, quantity }) => { + return acc.add(rate.mul(quantity)); + }, this.fyo.pesa(0)); + } +} + +function getQueuesPostOutwards( + stockQueue: StockQueueItem[], + outwardQuantity: number +) { + const totalQuantity = stockQueue.reduce( + (acc, { quantity }) => acc + quantity, + 0 + ); + + const isPossible = outwardQuantity <= totalQuantity; + if (!isPossible) { + return { isPossible }; + } + + let outwardRemaining = outwardQuantity; + const balanceQueue: StockQueueItem[] = []; + const outwardQueue: StockQueueItem[] = []; + + for (let i = stockQueue.length - 1; i >= 0; i--) { + const { quantity, rate } = stockQueue[i]; + if (outwardRemaining === 0) { + balanceQueue.unshift({ quantity, rate }); + } + + const balanceRemaining = quantity - outwardRemaining; + if (balanceRemaining === 0) { + outwardQueue.push({ quantity, rate }); + outwardRemaining = 0; + continue; + } + + if (balanceRemaining > 0) { + outwardQueue.push({ quantity: outwardRemaining, rate }); + balanceQueue.unshift({ quantity: balanceRemaining, rate }); + outwardRemaining = 0; + continue; + } + + if (balanceRemaining < 0) { + outwardQueue.push({ quantity, rate }); + outwardRemaining = +balanceRemaining; + continue; + } + } + + return { isPossible, outwardQueue, balanceQueue }; +} diff --git a/models/inventory/helpers.ts b/models/inventory/helpers.ts new file mode 100644 index 00000000..d2017577 --- /dev/null +++ b/models/inventory/helpers.ts @@ -0,0 +1,29 @@ +import { Fyo } from 'fyo'; +import { ModelNameEnum } from 'models/types'; +import { StockQueue } from './StockQueue'; + +export async function getStockQueue( + item: string, + location: string, + fyo: Fyo +): Promise { + /** + * Create a new StockQueue if it doesn't exist. + */ + + const names = (await fyo.db.getAllRaw(ModelNameEnum.StockQueue, { + filters: { item, location }, + fields: ['name'], + limit: 1, + })) as { name: string }[]; + const name = names?.[0]?.name; + + if (!name) { + return fyo.doc.getNewDoc(ModelNameEnum.StockQueue, { + item, + location, + }) as StockQueue; + } + + return (await fyo.doc.getDoc(ModelNameEnum.StockQueue, name)) as StockQueue; +} diff --git a/models/inventory/types.ts b/models/inventory/types.ts index bb125e90..aa96b6fb 100644 --- a/models/inventory/types.ts +++ b/models/inventory/types.ts @@ -1,4 +1,17 @@ +import { Money } from "pesa"; + export type MovementType = | 'MaterialIssue' | 'MaterialReceipt' | 'MaterialTransfer'; + +export interface SMDetails { + date: Date; + item: string; + rate: Money; + quantity: number; + referenceName: string; + referenceType: string; + fromLocation?: string; + toLocation?: string; +} \ No newline at end of file diff --git a/schemas/app/inventory/StockLedgerEntry.json b/schemas/app/inventory/StockLedgerEntry.json index 5b1a6045..8874d2fc 100644 --- a/schemas/app/inventory/StockLedgerEntry.json +++ b/schemas/app/inventory/StockLedgerEntry.json @@ -31,32 +31,12 @@ "readOnly": true }, { - "fieldname": "fromLocation", - "label": "From Location", + "fieldname": "location", + "label": "Location", "fieldtype": "Link", "target": "Location", "readOnly": true }, - { - "fieldname": "toLocation", - "label": "To Location", - "fieldtype": "Link", - "target": "Location", - "readOnly": true - }, - { - "fieldname": "referenceDetailName", - "label": "Ref. Detail Name", - "fieldtype": "DynamicLink", - "references": "referenceDetailType", - "readOnly": true - }, - { - "fieldname": "referenceDetailType", - "label": "Ref. Detail Type", - "fieldtype": "Data", - "readOnly": true - }, { "fieldname": "referenceName", "label": "Ref. Name", @@ -71,14 +51,14 @@ "readOnly": true }, { - "fieldname": "stockValue", - "label": "Stock Value", + "fieldname": "stockValueBefore", + "label": "Stock Value Before", "fieldtype": "Currency", "readOnly": true }, { - "fieldname": "stockValueDifference", - "label": "Stock Value Difference", + "fieldname": "stockValueAfter", + "label": "Stock Value After", "fieldtype": "Currency", "readOnly": true } diff --git a/schemas/app/inventory/StockQueue.json b/schemas/app/inventory/StockQueue.json index 87962718..2888782c 100644 --- a/schemas/app/inventory/StockQueue.json +++ b/schemas/app/inventory/StockQueue.json @@ -3,13 +3,14 @@ "label": "Stock Queue", "isSingle": false, "isChild": false, - "naming": "random", + "naming": "autoincrement", "fields": [ { "fieldname": "item", "label": "Item", "fieldtype": "Link", "target": "Item", + "required": true, "readOnly": true }, { @@ -17,12 +18,14 @@ "label": "Location", "fieldtype": "Link", "target": "Location", + "required": true, "readOnly": true }, { "fieldname": "queue", "label": "Queue", "fieldtype": "Data", + "required": true, "readOnly": true }, { @@ -30,12 +33,6 @@ "label": "Stock Value", "fieldtype": "Currency", "readOnly": true - }, - { - "fieldname": "valuationRate", - "label": "Valuation Rate", - "fieldtype": "Currency", - "readOnly": true } ] }