feat: average rate cost method.

This commit is contained in:
a.bouhuolia
2020-12-22 22:27:54 +02:00
parent c327c79612
commit 061b50c671
21 changed files with 787 additions and 409 deletions

View File

@@ -1,8 +1,14 @@
import { Container, Service, Inject } from 'typedi';
import { IInventoryTransaction, IItem } from 'interfaces'
import { pick } from 'lodash';
import {
EventDispatcher,
EventDispatcherInterface,
} from 'decorators/eventDispatcher';
import { IInventoryTransaction, IItem, IItemEntry } from 'interfaces'
import InventoryAverageCost from 'services/Inventory/InventoryAverageCost';
import InventoryCostLotTracker from 'services/Inventory/InventoryCostLotTracker';
import TenancyService from 'services/Tenancy/TenancyService';
import events from 'subscribers/events';
type TCostMethod = 'FIFO' | 'LIFO' | 'AVG';
@@ -11,6 +17,31 @@ export default class InventoryService {
@Inject()
tenancy: TenancyService;
@EventDispatcher()
eventDispatcher: EventDispatcherInterface;
/**
* Transforms the items entries to inventory transactions.
*/
transformItemEntriesToInventory(
itemEntries: IItemEntry[],
transactionType: string,
transactionId: number,
direction: 'IN'|'OUT',
date: Date|string,
lotNumber: number,
) {
return itemEntries.map((entry: IItemEntry) => ({
...pick(entry, ['itemId', 'quantity', 'rate']),
lotNumber,
transactionType,
transactionId,
direction,
date,
entryId: entry.id,
}));
}
/**
* Computes the given item cost and records the inventory lots transactions
* and journal entries based on the cost method FIFO, LIFO or average cost rate.
@@ -21,25 +52,23 @@ export default class InventoryService {
async computeItemCost(tenantId: number, fromDate: Date, itemId: number) {
const { Item } = this.tenancy.models(tenantId);
const item = await Item.query()
.findById(itemId)
.withGraphFetched('category');
// Fetches the item with assocaited item category.
const item = await Item.query().findById(itemId);
// Cannot continue if the given item was not inventory item.
if (item.type !== 'inventory') {
throw new Error('You could not compute item cost has no inventory type.');
}
const costMethod: TCostMethod = item.category.costMethod;
let costMethodComputer: IInventoryCostMethod;
// Switch between methods based on the item cost method.
switch(costMethod) {
switch('AVG') {
case 'FIFO':
case 'LIFO':
costMethodComputer = new InventoryCostLotTracker(fromDate, itemId);
costMethodComputer = new InventoryCostLotTracker(tenantId, fromDate, itemId);
break;
case 'AVG':
costMethodComputer = new InventoryAverageCost(fromDate, itemId);
costMethodComputer = new InventoryAverageCost(tenantId, fromDate, itemId);
break;
}
return costMethodComputer.computeItemCost();
@@ -54,9 +83,36 @@ export default class InventoryService {
async scheduleComputeItemCost(tenantId: number, itemId: number, startingDate: Date|string) {
const agenda = Container.get('agenda');
return agenda.schedule('in 3 seconds', 'compute-item-cost', {
startingDate, itemId, tenantId,
// Cancel any `compute-item-cost` in the queue has upper starting date
// with the same given item.
await agenda.cancel({
name: 'compute-item-cost',
nextRunAt: { $ne: null },
'data.tenantId': tenantId,
'data.itemId': itemId,
'data.startingDate': { "$gt": startingDate }
});
// Retrieve any `compute-item-cost` in the queue has lower starting date
// with the same given item.
const dependsJobs = await agenda.jobs({
name: 'compute-item-cost',
nextRunAt: { $ne: null },
'data.tenantId': tenantId,
'data.itemId': itemId,
'data.startingDate': { "$lte": startingDate }
});
if (dependsJobs.length === 0) {
await agenda.schedule('in 30 seconds', 'compute-item-cost', {
startingDate, itemId, tenantId,
});
// Triggers `onComputeItemCostJobScheduled` event.
await this.eventDispatcher.dispatch(
events.inventory.onComputeItemCostJobScheduled,
{ startingDate, itemId, tenantId },
);
}
}
/**
@@ -68,24 +124,11 @@ export default class InventoryService {
*/
async recordInventoryTransactions(
tenantId: number,
entries: IInventoryTransaction[],
inventoryEntries: IInventoryTransaction[],
deleteOld: boolean,
): Promise<void> {
const { InventoryTransaction, Item } = this.tenancy.models(tenantId);
// Mapping the inventory entries items ids.
const entriesItemsIds = entries.map((e: any) => e.itemId);
const inventoryItems = await Item.query()
.whereIn('id', entriesItemsIds)
.where('type', 'inventory');
// Mapping the inventory items ids.
const inventoryItemsIds = inventoryItems.map((i: IItem) => i.id);
// Filter the bill entries that have inventory items.
const inventoryEntries = entries.filter(
(entry: IInventoryTransaction) => inventoryItemsIds.indexOf(entry.itemId) !== -1
);
inventoryEntries.forEach(async (entry: any) => {
if (deleteOld) {
await this.deleteInventoryTransactions(
@@ -108,14 +151,14 @@ export default class InventoryService {
* @param {number} transactionId
* @return {Promise}
*/
deleteInventoryTransactions(
async deleteInventoryTransactions(
tenantId: number,
transactionId: number,
transactionType: string,
) {
): Promise<void> {
const { InventoryTransaction } = this.tenancy.models(tenantId);
return InventoryTransaction.query()
await InventoryTransaction.query()
.where('transaction_type', transactionType)
.where('transaction_id', transactionId)
.delete();
@@ -125,22 +168,37 @@ export default class InventoryService {
* Retrieve the lot number after the increment.
* @param {number} tenantId - Tenant id.
*/
async nextLotNumber(tenantId: number) {
const { Option } = this.tenancy.models(tenantId);
getNextLotNumber(tenantId: number) {
const settings = this.tenancy.settings(tenantId);
const LOT_NUMBER_KEY = 'lot_number_increment';
const effectRows = await Option.query()
.where('key', LOT_NUMBER_KEY)
.increment('value', 1);
const storedLotNumber = settings.find({ key: LOT_NUMBER_KEY });
if (effectRows === 0) {
await Option.query()
.insert({
key: LOT_NUMBER_KEY,
value: 1,
});
return (storedLotNumber && storedLotNumber.value) ?
parseInt(storedLotNumber.value, 10) : 1;
}
/**
* Increment the next inventory LOT number.
* @param {number} tenantId
* @return {Promise<number>}
*/
async incrementNextLotNumber(tenantId: number) {
const settings = this.tenancy.settings(tenantId);
const LOT_NUMBER_KEY = 'lot_number_increment';
const storedLotNumber = settings.find({ key: LOT_NUMBER_KEY });
let lotNumber = 1;
if (storedLotNumber && storedLotNumber.value) {
lotNumber = parseInt(storedLotNumber.value, 10);
lotNumber += 1;
}
const options = await Option.query();
return options.getMeta(LOT_NUMBER_KEY, 1);
settings.set({ key: LOT_NUMBER_KEY }, lotNumber);
await settings.save();
return lotNumber;
}
}

View File

@@ -1,8 +1,11 @@
import { pick } from 'lodash';
import { raw } from 'objection';
import { IInventoryTransaction } from 'interfaces';
import InventoryCostMethod from 'services/Inventory/InventoryCostMethod';
export default class InventoryAverageCostMethod extends InventoryCostMethod implements IInventoryCostMethod {
export default class InventoryAverageCostMethod
extends InventoryCostMethod
implements IInventoryCostMethod {
startingDate: Date;
itemId: number;
costTransactions: any[];
@@ -13,13 +16,9 @@ export default class InventoryAverageCostMethod extends InventoryCostMethod impl
* @param {Date} startingDate -
* @param {number} itemId - The given inventory item id.
*/
constructor(
tenantId: number,
startingDate: Date,
itemId: number,
) {
super();
constructor(tenantId: number, startingDate: Date, itemId: number) {
super(tenantId, startingDate, itemId);
this.startingDate = startingDate;
this.itemId = itemId;
this.costTransactions = [];
@@ -27,154 +26,216 @@ export default class InventoryAverageCostMethod extends InventoryCostMethod impl
/**
* Computes items costs from the given date using average cost method.
*
* ----------
* - Calculate the items average cost in the given date.
* - Remove the journal entries that associated to the inventory transacions
* - Remove the journal entries that associated to the inventory transacions
* after the given date.
* - Re-compute the inventory transactions and re-write the journal entries
* - Re-compute the inventory transactions and re-write the journal entries
* after the given date.
* ----------
* @asycn
* @param {Date} startingDate
* @param {number} referenceId
* @param {string} referenceType
* @async
* @param {Date} startingDate
* @param {number} referenceId
* @param {string} referenceType
*/
public async computeItemCost() {
const { InventoryTransaction } = this.tenantModels;
const openingAvgCost = await this.getOpeningAvaregeCost(this.startingDate, this.itemId);
const {
averageCost,
openingQuantity,
openingCost,
} = await this.getOpeningAvaregeCost(this.startingDate, this.itemId);
const afterInvTransactions: IInventoryTransaction[] = await InventoryTransaction.query()
.modify('filterDateRange', this.startingDate)
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'ASC')
.orderByRaw("FIELD(direction, 'IN', 'OUT')")
.orderBy('lot_number', 'ASC')
.where('item_id', this.itemId)
.withGraphFetched('item');
// Tracking inventroy transactions and retrieve cost transactions
// based on average rate cost method.
// Tracking inventroy transactions and retrieve cost transactions based on
// average rate cost method.
const costTransactions = this.trackingCostTransactions(
afterInvTransactions,
openingAvgCost,
openingQuantity,
openingCost
);
// Revert the inveout out lots transactions
await this.revertTheInventoryOutLotTrans();
// Store inventory lots cost transactions.
await this.storeInventoryLotsCost(costTransactions);
}
/**
* Get items Avarege cost from specific date from inventory transactions.
* @static
* @param {Date} startingDate
* @async
* @param {Date} closingDate
* @return {number}
*/
public async getOpeningAvaregeCost(startingDate: Date, itemId: number) {
const { InventoryTransaction } = this.tenantModels;
public async getOpeningAvaregeCost(closingDate: Date, itemId: number) {
const { InventoryCostLotTracker } = this.tenantModels;
const commonBuilder = (builder: any) => {
if (startingDate) {
builder.where('date', '<', startingDate);
if (closingDate) {
builder.where('date', '<', closingDate);
}
builder.where('item_id', itemId);
builder.groupBy('rate');
builder.groupBy('quantity');
builder.groupBy('item_id');
builder.groupBy('direction');
builder.sum('rate as rate');
builder.sum('quantity as quantity');
builder.sum('cost as cost');
builder.first();
};
// Calculates the total inventory total quantity and rate `IN` transactions.
// @todo total `IN` transactions.
const inInvSumationOper: Promise<any> = InventoryTransaction.query()
const inInvSumationOper: Promise<any> = InventoryCostLotTracker.query()
.onBuild(commonBuilder)
.where('direction', 'IN')
.first();
.where('direction', 'IN');
// Calculates the total inventory total quantity and rate `OUT` transactions.
// @todo total `OUT` transactions.
const outInvSumationOper: Promise<any> = InventoryTransaction.query()
const outInvSumationOper: Promise<any> = InventoryCostLotTracker.query()
.onBuild(commonBuilder)
.where('direction', 'OUT')
.first();
.where('direction', 'OUT');
const [inInvSumation, outInvSumation] = await Promise.all([
inInvSumationOper,
outInvSumationOper,
]);
return this.computeItemAverageCost(
inInvSumation?.quantity || 0,
inInvSumation?.rate || 0,
outInvSumation?.quantity || 0,
outInvSumation?.rate || 0
inInvSumation?.cost || 0,
inInvSumation?.quantity || 0,
outInvSumation?.cost || 0,
outInvSumation?.quantity || 0
);
}
/**
* Computes the item average cost.
* @static
* @param {number} quantityIn
* @param {number} rateIn
* @param {number} quantityOut
* @param {number} rateOut
* @static
* @param {number} quantityIn
* @param {number} rateIn
* @param {number} quantityOut
* @param {number} rateOut
*/
public computeItemAverageCost(
quantityIn: number,
rateIn: number,
totalCostIn: number,
totalQuantityIn: number,
quantityOut: number,
rateOut: number,
totalCostOut: number,
totalQuantityOut: number
) {
const totalQuantity = (quantityIn - quantityOut);
const totalRate = (rateIn - rateOut);
const averageCost = (totalRate) ? (totalQuantity / totalRate) : totalQuantity;
const openingCost = totalCostIn - totalCostOut;
const openingQuantity = totalQuantityIn - totalQuantityOut;
return averageCost;
const averageCost = openingQuantity ? openingCost / openingQuantity : 0;
return { averageCost, openingCost, openingQuantity };
}
/**
* Records the journal entries from specific item inventory transactions.
* @param {IInventoryTransaction[]} invTransactions
* @param {number} openingAverageCost
* @param {string} referenceType
* @param {number} referenceId
* @param {JournalCommand} journalCommands
* @param {IInventoryTransaction[]} invTransactions
* @param {number} openingAverageCost
* @param {string} referenceType
* @param {number} referenceId
* @param {JournalCommand} journalCommands
*/
public trackingCostTransactions(
invTransactions: IInventoryTransaction[],
openingAverageCost: number,
openingQuantity: number = 0,
openingCost: number = 0
) {
const costTransactions: any[] = [];
let accQuantity: number = 0;
let accCost: number = 0;
// Cumulative item quantity and cost. This will decrement after
// each out transactions depends on its quantity and cost.
let accQuantity: number = openingQuantity;
let accCost: number = openingCost;
invTransactions.forEach((invTransaction: IInventoryTransaction) => {
const commonEntry = {
invTransId: invTransaction.id,
...pick(invTransaction, ['date', 'direction', 'itemId', 'quantity', 'rate', 'entryId',
'transactionId', 'transactionType']),
...pick(invTransaction, [
'date',
'direction',
'itemId',
'quantity',
'rate',
'entryId',
'transactionId',
'transactionType',
'lotNumber',
]),
};
switch(invTransaction.direction) {
switch (invTransaction.direction) {
case 'IN':
const inCost = invTransaction.rate * invTransaction.quantity;
// Increases the quantity and cost in `IN` inventory transactions.
accQuantity += invTransaction.quantity;
accCost += invTransaction.rate * invTransaction.quantity;
accCost += inCost;
costTransactions.push({
...commonEntry,
cost: inCost,
});
break;
case 'OUT':
const transactionAvgCost = accCost ? (accCost / accQuantity) : 0;
const averageCost = transactionAvgCost;
const cost = (invTransaction.quantity * averageCost);
const income = (invTransaction.quantity * invTransaction.rate);
// Average cost = Total cost / Total quantity
const averageCost = accQuantity ? accCost / accQuantity : 0;
accQuantity -= invTransaction.quantity;
accCost -= income;
const quantity =
accQuantity > 0
? Math.min(invTransaction.quantity, accQuantity)
: invTransaction.quantity;
// Cost = the transaction quantity * Average cost.
const cost = quantity * averageCost;
// Revenue = transaction quanity * rate.
// const revenue = quantity * invTransaction.rate;
costTransactions.push({
...commonEntry,
quantity,
cost,
});
accQuantity = Math.max(accQuantity - quantity, 0);
accCost = Math.max(accCost - cost, 0);
if (invTransaction.quantity > quantity) {
const remainingQuantity = Math.max(
invTransaction.quantity - quantity,
0
);
const remainingIncome = remainingQuantity * invTransaction.rate;
costTransactions.push({
...commonEntry,
quantity: remainingQuantity,
cost: 0,
});
accQuantity = Math.max(accQuantity - remainingQuantity, 0);
accCost = Math.max(accCost - remainingIncome, 0);
}
break;
}
});
return costTransactions;
}
}
/**
* Reverts the inventory lots `OUT` transactions.
* @param {Date} openingDate - Opening date.
* @param {number} itemId - Item id.
* @returns {Promise<void>}
*/
async revertTheInventoryOutLotTrans(): Promise<void> {
const { InventoryCostLotTracker } = this.tenantModels;
await InventoryCostLotTracker.query()
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'DESC')
.where('item_id', this.itemId)
.delete();
}
}

View File

@@ -29,7 +29,7 @@ export default class InventoryCostLotTracker extends InventoryCostMethod impleme
itemId: number,
costMethod: TCostMethod = 'FIFO'
) {
super();
super(tenantId, startingDate, itemId);
this.startingDate = startingDate;
this.itemId = itemId;
@@ -129,7 +129,7 @@ export default class InventoryCostLotTracker extends InventoryCostMethod impleme
.withGraphFetched('item');
this.outTransactions = [ ...afterOUTTransactions ];
}
}
private async fetchItemsMapped() {
const itemsIds = chain(this.inTransactions).map((e) => e.itemId).uniq().value();

View File

@@ -1,10 +1,9 @@
import { omit } from 'lodash';
import { Inject } from 'typedi';
import { Container } from 'typedi';
import TenancyService from 'services/Tenancy/TenancyService';
import { IInventoryLotCost } from 'interfaces';
export default class InventoryCostMethod {
@Inject()
tenancy: TenancyService;
tenantModels: any;
@@ -13,27 +12,29 @@ export default class InventoryCostMethod {
* @param {number} tenantId - The given tenant id.
*/
constructor(tenantId: number, startingDate: Date, itemId: number) {
this.tenantModels = this.tenantModels.models(tenantId);
const tenancyService = Container.get(TenancyService);
this.tenantModels = tenancyService.models(tenantId);
}
/**
* Stores the inventory lots costs transactions in bulk.
* @param {IInventoryLotCost[]} costLotsTransactions
* @param {IInventoryLotCost[]} costLotsTransactions
* @return {Promise[]}
*/
public storeInventoryLotsCost(costLotsTransactions: IInventoryLotCost[]): Promise<object> {
const { InventoryLotCostTracker } = this.tenantModels;
const { InventoryCostLotTracker } = this.tenantModels;
const opers: any = [];
costLotsTransactions.forEach((transaction: IInventoryLotCost) => {
costLotsTransactions.forEach((transaction: any) => {
if (transaction.lotTransId && transaction.decrement) {
const decrementOper = InventoryLotCostTracker.query()
const decrementOper = InventoryCostLotTracker.query()
.where('id', transaction.lotTransId)
.decrement('remaining', transaction.decrement);
opers.push(decrementOper);
} else if(!transaction.lotTransId) {
const operation = InventoryLotCostTracker.query()
const operation = InventoryCostLotTracker.query()
.insert({
...omit(transaction, ['decrement', 'invTransId', 'lotTransId']),
});