2
0
mirror of https://github.com/frappe/books.git synced 2025-01-03 15:17:30 +00:00

incr: add Stock Manager

This commit is contained in:
18alantom 2022-10-28 13:34:08 +05:30
parent a8b5884929
commit daaf56da48
7 changed files with 338 additions and 35 deletions

View File

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

View File

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

View File

@ -1,3 +1,119 @@
import { Doc } from 'fyo/model/doc';
import { Money } from 'pesa';
export class StockQueue extends Doc {}
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 };
}

View File

@ -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<StockQueue> {
/**
* 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;
}

View File

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

View File

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

View File

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