refactor: dynamic list to nestjs

This commit is contained in:
Ahmed Bouhuolia
2025-01-12 18:22:48 +02:00
parent ddaea20d16
commit 270b421a6c
117 changed files with 4232 additions and 1493 deletions

View File

@@ -0,0 +1,366 @@
import { pick } from 'lodash';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import {
IInventoryLotCost,
IInventoryTransaction,
TInventoryTransactionDirection,
IItemEntry,
IItemEntryTransactionType,
IInventoryTransactionsCreatedPayload,
IInventoryTransactionsDeletedPayload,
IInventoryItemCostScheduledPayload,
} from '@/interfaces';
import { InventoryAverageCostMethod } from './InventoryAverageCost';
import { InventoryCostLotTracker } from './InventoryCostLotTracker';
import { ItemsEntriesService } from '../Items/ItemsEntries.service';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { Item } from '../Items/models/Item';
import { SETTINGS_PROVIDER } from '../Settings/Settings.types';
import { SettingsStore } from '../Settings/SettingsStore';
import { events } from '@/common/events/events';
import { InventoryTransaction } from './models/InventoryTransaction';
import InventoryCostMethod from './InventoryCostMethod';
@Injectable()
export class InventoryService {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly itemsEntriesService: ItemsEntriesService,
private readonly uow: UnitOfWork,
@Inject(Item.name)
private readonly itemModel: typeof Item,
@Inject(InventoryTransaction.name)
private readonly inventoryTransactionModel: typeof InventoryTransaction,
@Inject(InventoryCostLotTracker.name)
private readonly inventoryCostLotTracker: typeof InventoryCostLotTracker,
@Inject(SETTINGS_PROVIDER)
private readonly settings: SettingsStore,
) {}
/**
* Transforms the items entries to inventory transactions.
*/
transformItemEntriesToInventory(transaction: {
transactionId: number;
transactionType: IItemEntryTransactionType;
transactionNumber?: string;
exchangeRate?: number;
warehouseId: number | null;
date: Date | string;
direction: TInventoryTransactionDirection;
entries: IItemEntry[];
createdAt: Date;
}): IInventoryTransaction[] {
const exchangeRate = transaction.exchangeRate || 1;
return transaction.entries.map((entry: IItemEntry) => ({
...pick(entry, ['itemId', 'quantity']),
rate: entry.rate * exchangeRate,
transactionType: transaction.transactionType,
transactionId: transaction.transactionId,
direction: transaction.direction,
date: transaction.date,
entryId: entry.id,
createdAt: transaction.createdAt,
costAccountId: entry.costAccountId,
warehouseId: entry.warehouseId || transaction.warehouseId,
meta: {
transactionNumber: transaction.transactionNumber,
description: entry.description,
},
}));
}
async computeItemCost(fromDate: Date, itemId: number) {
return this.uow.withTransaction((trx: Knex.Transaction) => {
return this.computeInventoryItemCost(fromDate, itemId);
});
}
/**
* 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.
* @param {Date} fromDate - From date.
* @param {number} itemId - Item id.
*/
async computeInventoryItemCost(
fromDate: Date,
itemId: number,
trx?: Knex.Transaction,
) {
// Fetches the item with associated 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.');
}
let costMethodComputer: InventoryCostMethod;
// Switch between methods based on the item cost method.
switch ('AVG') {
case 'FIFO':
case 'LIFO':
costMethodComputer = new InventoryCostLotTracker(
tenantId,
fromDate,
itemId,
);
break;
case 'AVG':
costMethodComputer = new InventoryAverageCostMethod(
fromDate,
itemId,
trx,
);
break;
}
return costMethodComputer.computeItemCost();
}
/**
* Schedule item cost compute job.
* @param {number} tenantId
* @param {number} itemId
* @param {Date} startingDate
*/
async scheduleComputeItemCost(
tenantId: number,
itemId: number,
startingDate: Date | string,
) {
const agenda = Container.get('agenda');
const commonJobsQuery = {
name: 'compute-item-cost',
lastRunAt: { $exists: false },
'data.tenantId': tenantId,
'data.itemId': itemId,
};
// Cancel any `compute-item-cost` in the queue has upper starting date
// with the same given item.
await agenda.cancel({
...commonJobsQuery,
'data.startingDate': { $lte: startingDate },
});
// Retrieve any `compute-item-cost` in the queue has lower starting date
// with the same given item.
const dependsJobs = await agenda.jobs({
...commonJobsQuery,
'data.startingDate': { $gte: startingDate },
});
// If the depends jobs cleared.
if (dependsJobs.length === 0) {
await agenda.schedule(
config.scheduleComputeItemCost,
'compute-item-cost',
{
startingDate,
itemId,
tenantId,
},
);
// Triggers `onComputeItemCostJobScheduled` event.
await this.eventPublisher.emitAsync(
events.inventory.onComputeItemCostJobScheduled,
{
startingDate,
itemId,
tenantId,
} as IInventoryItemCostScheduledPayload,
);
} else {
// Re-schedule the jobs that have higher date from current moment.
await Promise.all(
dependsJobs.map((job) =>
job.schedule(config.scheduleComputeItemCost).save(),
),
);
}
}
/**
* Records the inventory transactions.
* @param {number} tenantId - Tenant id.
* @param {Bill} bill - Bill model object.
* @param {number} billId - Bill id.
* @return {Promise<void>}
*/
async recordInventoryTransactions(
transactions: IInventoryTransaction[],
override: boolean = false,
trx?: Knex.Transaction,
): Promise<void> {
const bulkInsertOpers = [];
transactions.forEach((transaction: IInventoryTransaction) => {
const oper = this.recordInventoryTransaction(transaction, override, trx);
bulkInsertOpers.push(oper);
});
const inventoryTransactions = await Promise.all(bulkInsertOpers);
// Triggers `onInventoryTransactionsCreated` event.
await this.eventEmitter.emitAsync(
events.inventory.onInventoryTransactionsCreated,
{
inventoryTransactions,
trx,
} as IInventoryTransactionsCreatedPayload,
);
}
/**
* Writes the inventory transactiosn on the storage from the given
* inventory transactions entries.
*
* @param {number} tenantId -
* @param {IInventoryTransaction} inventoryEntry -
* @param {boolean} deleteOld -
*/
async recordInventoryTransaction(
inventoryEntry: IInventoryTransaction,
deleteOld: boolean = false,
trx: Knex.Transaction,
): Promise<IInventoryTransaction> {
if (deleteOld) {
await this.deleteInventoryTransactions(
inventoryEntry.transactionId,
inventoryEntry.transactionType,
trx,
);
}
return this.inventoryTransactionModel.query(trx).insertGraph({
...inventoryEntry,
});
}
/**
* Records the inventory transactions from items entries that have (inventory) type.
*
* @param {number} tenantId
* @param {number} transactionId
* @param {string} transactionType
* @param {Date|string} transactionDate
* @param {boolean} override
*/
async recordInventoryTransactionsFromItemsEntries(
transaction: {
transactionId: number;
transactionType: IItemEntryTransactionType;
exchangeRate: number;
date: Date | string;
direction: TInventoryTransactionDirection;
entries: IItemEntry[];
createdAt: Date | string;
warehouseId: number;
},
override: boolean = false,
trx?: Knex.Transaction,
): Promise<void> {
// Can't continue if there is no entries has inventory items in the invoice.
if (transaction.entries.length <= 0) {
return;
}
// Inventory transactions.
const inventoryTranscations =
this.transformItemEntriesToInventory(transaction);
// Records the inventory transactions of the given sale invoice.
await this.recordInventoryTransactions(
inventoryTranscations,
override,
trx,
);
}
/**
* Deletes the given inventory transactions.
* @param {number} tenantId - Tenant id.
* @param {string} transactionType
* @param {number} transactionId
* @return {Promise<{
* oldInventoryTransactions: IInventoryTransaction[]
* }>}
*/
async deleteInventoryTransactions(
transactionId: number,
transactionType: string,
trx?: Knex.Transaction,
): Promise<{ oldInventoryTransactions: InventoryTransaction[] }> {
// Retrieve the inventory transactions of the given sale invoice.
const oldInventoryTransactions = await this.inventoryTransactionModel
.query(trx)
.where({ transactionId, transactionType });
// Deletes the inventory transactions by the given transaction type and id.
await this.inventoryTransactionModel
.query(trx)
.where({ transactionType, transactionId })
.delete();
// Triggers `onInventoryTransactionsDeleted` event.
await this.eventEmitter.emitAsync(
events.inventory.onInventoryTransactionsDeleted,
{
oldInventoryTransactions,
transactionId,
transactionType,
trx,
} as IInventoryTransactionsDeletedPayload,
);
return { oldInventoryTransactions };
}
/**
* Records the inventory cost lot transaction.
* @param {number} tenantId
* @param {IInventoryLotCost} inventoryLotEntry
* @return {Promise<IInventoryLotCost>}
*/
async recordInventoryCostLotTransaction(
tenantId: number,
inventoryLotEntry: IInventoryLotCost,
): Promise<void> {
return this.inventoryCostLotTracker.query().insert({
...inventoryLotEntry,
});
}
/**
* Mark item cost computing is running.
* @param {boolean} isRunning -
*/
async markItemsCostComputeRunning(isRunning: boolean = true) {
this.settings.set({
key: 'cost_compute_running',
group: 'inventory',
value: isRunning,
});
await this.settings.save();
}
/**
* Checks if the items cost compute is running.
* @returns {boolean}
*/
isItemsCostComputeRunning() {
return (
this.settings.get({
key: 'cost_compute_running',
group: 'inventory',
}) ?? false
);
}
}

View File

@@ -0,0 +1,254 @@
import { pick } from 'lodash';
import { Knex } from 'knex';
import InventoryCostMethod from './InventoryCostMethod';
import { InventoryTransaction } from './models/InventoryTransaction';
export class InventoryAverageCostMethod extends InventoryCostMethod {
startingDate: Date;
itemId: number;
costTransactions: any[];
trx: Knex.Transaction;
/**
* Constructor method.
* @param {number} tenantId - The given tenant id.
* @param {Date} startingDate -
* @param {number} itemId - The given inventory item id.
*/
constructor(
tenantId: number,
startingDate: Date,
itemId: number,
trx?: Knex.Transaction,
) {
super(tenantId, startingDate, itemId);
this.trx = trx;
this.startingDate = startingDate;
this.itemId = itemId;
this.costTransactions = [];
}
/**
* 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
* after the given date.
* - Re-compute the inventory transactions and re-write the journal entries
* after the given date.
* ----------
* @async
* @param {Date} startingDate
* @param {number} referenceId
* @param {string} referenceType
*/
public async computeItemCost() {
const { InventoryTransaction } = this.tenantModels;
const { averageCost, openingQuantity, openingCost } =
await this.getOpeningAverageCost(this.startingDate, this.itemId);
const afterInvTransactions =
await InventoryTransaction.query()
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'ASC')
.orderByRaw("FIELD(direction, 'IN', 'OUT')")
.orderBy('createdAt', 'ASC')
.where('item_id', this.itemId)
.withGraphFetched('item');
// Tracking inventroy transactions and retrieve cost transactions based on
// average rate cost method.
const costTransactions = this.trackingCostTransactions(
afterInvTransactions,
openingQuantity,
openingCost,
);
// Revert the inveout out lots transactions
await this.revertTheInventoryOutLotTrans();
// Store inventory lots cost transactions.
await this.storeInventoryLotsCost(costTransactions);
}
/**
* Get items Average cost from specific date from inventory transactions.
* @async
* @param {Date} closingDate
* @return {number}
*/
public async getOpeningAverageCost(closingDate: Date, itemId: number) {
const { InventoryCostLotTracker } = this.tenantModels;
const commonBuilder = (builder: any) => {
if (closingDate) {
builder.where('date', '<', closingDate);
}
builder.where('item_id', itemId);
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.
const inInvSumationOper: Promise<any> = InventoryCostLotTracker.query()
.onBuild(commonBuilder)
.where('direction', 'IN');
// Calculates the total inventory total quantity and rate `OUT` transactions.
const outInvSumationOper: Promise<any> = InventoryCostLotTracker.query()
.onBuild(commonBuilder)
.where('direction', 'OUT');
const [inInvSumation, outInvSumation] = await Promise.all([
inInvSumationOper,
outInvSumationOper,
]);
return this.computeItemAverageCost(
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
*/
public computeItemAverageCost(
totalCostIn: number,
totalQuantityIn: number,
totalCostOut: number,
totalQuantityOut: number,
) {
const openingCost = totalCostIn - totalCostOut;
const openingQuantity = totalQuantityIn - totalQuantityOut;
const averageCost = openingQuantity ? openingCost / openingQuantity : 0;
return { averageCost, openingCost, openingQuantity };
}
private getCost(rate: number, quantity: number) {
return quantity ? rate * quantity : rate;
}
/**
* Records the journal entries from specific item inventory transactions.
* @param {IInventoryTransaction[]} invTransactions
* @param {number} openingAverageCost
* @param {string} referenceType
* @param {number} referenceId
* @param {JournalCommand} journalCommands
*/
public trackingCostTransactions(
invTransactions: InventoryTransaction[],
openingQuantity: number = 0,
openingCost: number = 0,
) {
const costTransactions: any[] = [];
// 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: InventoryTransaction) => {
const commonEntry = {
invTransId: invTransaction.id,
...pick(invTransaction, [
'date',
'direction',
'itemId',
'quantity',
'rate',
'entryId',
'transactionId',
'transactionType',
'createdAt',
'costAccountId',
'branchId',
'warehouseId',
]),
inventoryTransactionId: invTransaction.id,
};
switch (invTransaction.direction) {
case 'IN':
const inCost = this.getCost(
invTransaction.rate,
invTransaction.quantity,
);
// Increases the quantity and cost in `IN` inventory transactions.
accQuantity += invTransaction.quantity;
accCost += inCost;
costTransactions.push({
...commonEntry,
cost: inCost,
});
break;
case 'OUT':
// Average cost = Total cost / Total quantity
const averageCost = accQuantity ? accCost / accQuantity : 0;
const quantity =
accQuantity > 0
? Math.min(invTransaction.quantity, accQuantity)
: invTransaction.quantity;
// Cost = the transaction quantity * Average cost.
const cost = this.getCost(averageCost, quantity);
// 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(this.trx)
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'DESC')
.where('item_id', this.itemId)
.delete();
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { InventoryCostGLBeforeWriteSubscriber } from './subscribers/InventoryCostGLBeforeWriteSubscriber';
import { InventoryCostGLStorage } from './InventoryCostGLStorage.service';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { InventoryCostLotTracker } from './models/InventoryCostLotTracker';
import { InventoryTransaction } from './models/InventoryTransaction';
const models = [
RegisterTenancyModel(InventoryCostLotTracker),
RegisterTenancyModel(InventoryTransaction),
];
@Module({
providers: [
...models,
InventoryCostGLBeforeWriteSubscriber,
InventoryCostGLStorage,
],
exports: [...models],
})
export class InventoryCostModule {}

View File

@@ -0,0 +1,28 @@
import { IInventoryItemCostMeta } from '@/interfaces';
import { Service, Inject } from 'typedi';
import { InventoryItemCostService } from './InventoryCosts.service';
@Service()
export class InventoryCostApplication {
@Inject()
inventoryCost: InventoryItemCostService;
/**
* Retrieves the items inventory valuation list.
* @param {number} tenantId
* @param {number[]} itemsId
* @param {Date} date
* @returns {Promise<IInventoryItemCostMeta[]>}
*/
public getItemsInventoryValuationList = async (
itemsId: number[],
date: Date
): Promise<IInventoryItemCostMeta[]> => {
const itemsMap = await this.inventoryCost.getItemsInventoryValuation(
tenantId,
itemsId,
date
);
return [...itemsMap.values()];
};
}

View File

@@ -0,0 +1,38 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { LedgerStorageService } from '../Ledger/LedgerStorage.service';
import { Ledger } from '../Ledger/Ledger';
import { AccountTransaction } from '../Accounts/models/AccountTransaction.model';
@Injectable()
export class InventoryCostGLStorage {
constructor(
private readonly ledgerStorage: LedgerStorageService,
@Inject(AccountTransaction.name)
private readonly accountTransactionModel: typeof AccountTransaction,
) {}
/**
* Reverts the inventory cost GL entries from the given starting date.
* @param {Date} startingDate - Starting date.
* @param {Knex.Transaction} trx - Transaction.
*/
public async revertInventoryCostGLEntries(
startingDate: Date,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve transactions from specific date range and costable transactions only.
const transactions = await this.accountTransactionModel
.query()
.where('costable', true)
.modify('filterDateRange', startingDate)
.withGraphFetched('account');
// Transform transaction to ledger entries and reverse them.
const reversedLedger = Ledger.fromTransactions(transactions).reverse();
// Deletes and reverts balances of the given ledger.
await this.ledgerStorage.delete(reversedLedger, trx);
}
}

View File

@@ -0,0 +1,302 @@
import { pick, chain } from 'lodash';
import moment from 'moment';
import { IInventoryLotCost, IInventoryTransaction } from "interfaces";
import InventoryCostMethod from '@/services/Inventory/InventoryCostMethod';
type TCostMethod = 'FIFO' | 'LIFO';
export class InventoryCostLotTracker extends InventoryCostMethod {
startingDate: Date;
itemId: number;
costMethod: TCostMethod;
itemsById: Map<number, any>;
inventoryINTrans: any;
inventoryByItem: any;
costLotsTransactions: IInventoryLotCost[];
inTransactions: any[];
outTransactions: IInventoryTransaction[];
revertJEntriesTransactions: IInventoryTransaction[];
/**
* Constructor method.
* @param {Date} startingDate -
* @param {number} itemId -
* @param {string} costMethod -
*/
constructor(
tenantId: number,
startingDate: Date,
itemId: number,
costMethod: TCostMethod = 'FIFO'
) {
super(tenantId, startingDate, itemId);
this.startingDate = startingDate;
this.itemId = itemId;
this.costMethod = costMethod;
// Collect cost lots transactions to insert them to the storage in bulk.
this.costLotsTransactions= [];
// Collect inventory transactions by item id.
this.inventoryByItem = {};
// Collection `IN` inventory tranaction by transaction id.
this.inventoryINTrans = {};
// Collects `IN` transactions.
this.inTransactions = [];
// Collects `OUT` transactions.
this.outTransactions = [];
}
/**
* Computes items costs from the given date using FIFO or LIFO cost method.
* --------
* - Revert the inventory lots after the given date.
* - Remove all the journal entries from the inventory transactions
* after the given date.
* - Re-tracking the inventory lots from inventory transactions.
* - Re-write the journal entries from the given inventory transactions.
* @async
* @return {void}
*/
public async computeItemCost(): Promise<any> {
await this.revertInventoryLots(this.startingDate);
await this.fetchInvINTransactions();
await this.fetchInvOUTTransactions();
await this.fetchRevertInvJReferenceIds();
await this.fetchItemsMapped();
this.trackingInventoryINLots(this.inTransactions);
this.trackingInventoryOUTLots(this.outTransactions);
// Re-tracking the inventory `IN` and `OUT` lots costs.
const storedTrackedInvLotsOper = this.storeInventoryLotsCost(
this.costLotsTransactions,
);
return Promise.all([
storedTrackedInvLotsOper,
]);
}
/**
* Fetched inventory transactions that has date from the starting date and
* fetches available IN LOTs transactions that has remaining bigger than zero.
* @private
*/
private async fetchInvINTransactions() {
const { InventoryTransaction, InventoryLotCostTracker } = this.tenantModels;
const commonBuilder = (builder: any) => {
builder.orderBy('date', (this.costMethod === 'LIFO') ? 'DESC': 'ASC');
builder.where('item_id', this.itemId);
};
const afterInvTransactions: IInventoryTransaction[] =
await InventoryTransaction.query()
.modify('filterDateRange', this.startingDate)
.orderByRaw("FIELD(direction, 'IN', 'OUT')")
.onBuild(commonBuilder)
.orderBy('lot_number', (this.costMethod === 'LIFO') ? 'DESC' : 'ASC')
.withGraphFetched('item');
const availableINLots: IInventoryLotCost[] =
await InventoryLotCostTracker.query()
.modify('filterDateRange', null, this.startingDate)
.orderBy('date', 'ASC')
.where('direction', 'IN')
.orderBy('lot_number', 'ASC')
.onBuild(commonBuilder)
.whereNot('remaining', 0);
this.inTransactions = [
...availableINLots.map((trans) => ({ lotTransId: trans.id, ...trans })),
...afterInvTransactions.map((trans) => ({ invTransId: trans.id, ...trans })),
];
}
/**
* Fetches inventory OUT transactions that has date from the starting date.
* @private
*/
private async fetchInvOUTTransactions() {
const { InventoryTransaction } = this.tenantModels;
const afterOUTTransactions: IInventoryTransaction[] =
await InventoryTransaction.query()
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'ASC')
.orderBy('lot_number', 'ASC')
.where('item_id', this.itemId)
.where('direction', 'OUT')
.withGraphFetched('item');
this.outTransactions = [ ...afterOUTTransactions ];
}
private async fetchItemsMapped() {
const itemsIds = chain(this.inTransactions).map((e) => e.itemId).uniq().value();
const { Item } = this.tenantModels;
const storedItems = await Item.query()
.where('type', 'inventory')
.whereIn('id', itemsIds);
this.itemsById = new Map(storedItems.map((item: any) => [item.id, item]));
}
/**
* Fetch the inventory transactions that should revert its journal entries.
* @private
*/
private async fetchRevertInvJReferenceIds() {
const { InventoryTransaction } = this.tenantModels;
const revertJEntriesTransactions: IInventoryTransaction[] =
await InventoryTransaction.query()
.select(['transactionId', 'transactionType'])
.modify('filterDateRange', this.startingDate)
.where('direction', 'OUT')
.where('item_id', this.itemId);
this.revertJEntriesTransactions = revertJEntriesTransactions;
}
/**
* Revert the inventory lots to the given date by removing the inventory lots
* transactions after the given date and increment the remaining that
* associate to lot number.
* @async
* @return {Promise}
*/
public async revertInventoryLots(startingDate: Date) {
const { InventoryLotCostTracker } = this.tenantModels;
const asyncOpers: any[] = [];
const inventoryLotsTrans = await InventoryLotCostTracker.query()
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'DESC')
.where('item_id', this.itemId)
.where('direction', 'OUT');
const deleteInvLotsTrans = InventoryLotCostTracker.query()
.modify('filterDateRange', this.startingDate)
.where('item_id', this.itemId)
.delete();
inventoryLotsTrans.forEach((inventoryLot: IInventoryLotCost) => {
if (!inventoryLot.lotNumber) { return; }
const incrementOper = InventoryLotCostTracker.query()
.where('lot_number', inventoryLot.lotNumber)
.where('direction', 'IN')
.increment('remaining', inventoryLot.quantity);
asyncOpers.push(incrementOper);
});
return Promise.all([deleteInvLotsTrans, ...asyncOpers]);
}
/**
* Tracking inventory `IN` lots transactions.
* @public
* @param {IInventoryTransaction[]} inventoryTransactions -
* @return {void}
*/
public trackingInventoryINLots(
inventoryTransactions: IInventoryTransaction[],
) {
inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
const { itemId, id } = transaction;
(this.inventoryByItem[itemId] || (this.inventoryByItem[itemId] = []));
const commonLotTransaction: IInventoryLotCost = {
...pick(transaction, [
'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId',
'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
]),
};
this.inventoryByItem[itemId].push(id);
this.inventoryINTrans[id] = {
...commonLotTransaction,
decrement: 0,
remaining: commonLotTransaction.remaining || commonLotTransaction.quantity,
};
this.costLotsTransactions.push(this.inventoryINTrans[id]);
});
}
/**
* Tracking inventory `OUT` lots transactions.
* @public
* @param {IInventoryTransaction[]} inventoryTransactions -
* @return {void}
*/
public trackingInventoryOUTLots(
inventoryTransactions: IInventoryTransaction[],
) {
inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
const { itemId, id } = transaction;
(this.inventoryByItem[itemId] || (this.inventoryByItem[itemId] = []));
const commonLotTransaction: IInventoryLotCost = {
...pick(transaction, [
'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId', 'entryId',
'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
]),
};
let invRemaining = transaction.quantity;
const idsShouldDel: number[] = [];
this.inventoryByItem?.[itemId]?.some((_invTransactionId: number) => {
const _invINTransaction = this.inventoryINTrans[_invTransactionId];
// Can't continue if the IN transaction remaining equals zero.
if (invRemaining <= 0) { return true; }
// Can't continue if the IN transaction date is after the current transaction date.
if (moment(_invINTransaction.date).isAfter(transaction.date)) {
return true;
}
// Detarmines the 'OUT' lot tranasctions whether bigger than 'IN' remaining transaction.
const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0;
const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining;
const maxDecrement = Math.min(decrement, invRemaining);
const cost = maxDecrement * _invINTransaction.rate;
_invINTransaction.decrement += maxDecrement;
_invINTransaction.remaining = Math.max(
_invINTransaction.remaining - maxDecrement,
0,
);
invRemaining = Math.max(invRemaining - maxDecrement, 0);
this.costLotsTransactions.push({
...commonLotTransaction,
cost,
quantity: maxDecrement,
lotNumber: _invINTransaction.lotNumber,
});
// Pop the 'IN' lots that has zero remaining.
if (_invINTransaction.remaining === 0) {
idsShouldDel.push(_invTransactionId);
}
return false;
});
if (invRemaining > 0) {
this.costLotsTransactions.push({
...commonLotTransaction,
quantity: invRemaining,
});
}
this.removeInventoryItems(itemId, idsShouldDel);
});
}
/**
* Remove inventory transactions for specific item id.
* @private
* @param {number} itemId
* @param {number[]} idsShouldDel
* @return {void}
*/
private removeInventoryItems(itemId: number, idsShouldDel: number[]) {
// Remove the IN transactions that has zero remaining amount.
this.inventoryByItem[itemId] = this.inventoryByItem?.[itemId]
?.filter((transId: number) => idsShouldDel.indexOf(transId) === -1);
}
}

View File

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

View File

@@ -0,0 +1,149 @@
import { keyBy, get } from 'lodash';
import { Knex } from 'knex';
import * as R from 'ramda';
import { IInventoryItemCostMeta } from './types/InventoryCost.types';
import { Inject, Injectable } from '@nestjs/common';
import { InventoryTransaction } from './models/InventoryTransaction';
import { InventoryCostLotTracker } from './models/InventoryCostLotTracker';
import { Item } from '../Items/models/Item';
@Injectable()
export class InventoryItemCostService {
constructor(
@Inject(InventoryTransaction.name)
private readonly inventoryTransactionModel: typeof InventoryTransaction,
@Inject(InventoryCostLotTracker.name)
private readonly inventoryCostLotTrackerModel: typeof InventoryCostLotTracker,
@Inject(Item.name)
private readonly itemModel: typeof Item,
) {}
/**
* Common query of items inventory valuation.
* @param {number[]} itemsIds -
* @param {Date} date -
* @param {Knex.QueryBuilder} builder -
*/
private itemsInventoryValuationCommonQuery = R.curry(
(itemsIds: number[], date: Date, builder: Knex.QueryBuilder) => {
if (date) {
builder.where('date', '<', date);
}
builder.whereIn('item_id', itemsIds);
builder.sum('rate as rate');
builder.sum('quantity as quantity');
builder.sum('cost as cost');
builder.groupBy('item_id');
builder.select(['item_id']);
}
);
/**
*
* @param {} INValuationMap -
* @param {} OUTValuationMap -
* @param {number} itemId
*/
private getItemInventoryMeta = R.curry(
(
INValuationMap,
OUTValuationMap,
itemId: number
): IInventoryItemCostMeta => {
const INCost = get(INValuationMap, `[${itemId}].cost`, 0);
const INQuantity = get(INValuationMap, `[${itemId}].quantity`, 0);
const OUTCost = get(OUTValuationMap, `[${itemId}].cost`, 0);
const OUTQuantity = get(OUTValuationMap, `[${itemId}].quantity`, 0);
const valuation = INCost - OUTCost;
const quantity = INQuantity - OUTQuantity;
const average = quantity ? valuation / quantity : 0;
return { itemId, valuation, quantity, average };
}
);
/**
*
* @param {number} tenantId
* @param {number} itemsId
* @param {Date} date
* @returns
*/
private getItemsInventoryINAndOutAggregated = (
itemsId: number[],
date: Date
): Promise<any> => {
const commonBuilder = this.itemsInventoryValuationCommonQuery(
itemsId,
date
);
const INValuationOper = this.inventoryCostLotTrackerModel.query()
.onBuild(commonBuilder)
.where('direction', 'IN');
const OUTValuationOper = this.inventoryCostLotTrackerModel.query()
.onBuild(commonBuilder)
.where('direction', 'OUT');
return Promise.all([OUTValuationOper, INValuationOper]);
};
/**
*
* @param {number} tenantId -
* @param {number[]} itemsIds -
* @param {Date} date -
*/
private getItemsInventoryInOutMap = async (
itemsId: number[],
date: Date
) => {
const [OUTValuation, INValuation] =
await this.getItemsInventoryINAndOutAggregated(itemsId, date);
const OUTValuationMap = keyBy(OUTValuation, 'itemId');
const INValuationMap = keyBy(INValuation, 'itemId');
return [OUTValuationMap, INValuationMap];
};
/**
*
* @param {number} tenantId
* @param {number} itemId
* @param {Date} date
* @returns {Promise<Map<number, IInventoryItemCostMeta>>}
*/
public getItemsInventoryValuation = async (
itemsId: number[],
date: Date
): Promise<Map<number, IInventoryItemCostMeta>> => {
// Retrieves the inventory items.
const items = await this.itemModel.query()
.whereIn('id', itemsId)
.where('type', 'inventory');
// Retrieves the inventory items ids.
const inventoryItemsIds: number[] = items.map((item) => item.id);
// Retreives the items inventory IN/OUT map.
const [OUTValuationMap, INValuationMap] =
await this.getItemsInventoryInOutMap(itemsId, date);
const getItemValuation = this.getItemInventoryMeta(
INValuationMap,
OUTValuationMap
);
const itemsValuations = inventoryItemsIds.map(getItemValuation);
const itemsValuationsMap = new Map(
itemsValuations.map((i) => [i.itemId, i])
);
return itemsValuationsMap;
};
}

View File

@@ -0,0 +1,94 @@
import { toSafeInteger } from 'lodash';
import { IInventoryTransaction, IItemsQuantityChanges } from '@/interfaces';
import { Knex } from 'knex';
import { Inject } from '@nestjs/common';
import { Item } from '../Items/models/Item';
import { Injectable } from '@nestjs/common';
/**
* Syncs the inventory transactions with inventory items quantity.
*/
@Injectable()
export class InventoryItemsQuantitySyncService {
constructor(
@Inject(Item.name)
private readonly itemModel: typeof Item,
) {}
/**
* Reverse the given inventory transactions.
* @param {IInventoryTransaction[]} inventroyTransactions
* @return {IInventoryTransaction[]}
*/
public reverseInventoryTransactions(
inventroyTransactions: IInventoryTransaction[],
): IInventoryTransaction[] {
return inventroyTransactions.map((transaction) => ({
...transaction,
direction: transaction.direction === 'OUT' ? 'IN' : 'OUT',
}));
}
/**
* Reverses the inventory transactions.
* @param {IInventoryTransaction[]} inventroyTransactions -
* @return {IItemsQuantityChanges[]}
*/
public getReverseItemsQuantityChanges(
inventroyTransactions: IInventoryTransaction[],
): IItemsQuantityChanges[] {
const reversedTransactions = this.reverseInventoryTransactions(
inventroyTransactions,
);
return this.getItemsQuantityChanges(reversedTransactions);
}
/**
* Retrieve the items quantity changes from the given inventory transactions.
* @param {IInventoryTransaction[]} inventroyTransactions - Inventory transactions.
* @return {IItemsQuantityChanges[]}
*/
public getItemsQuantityChanges(
inventroyTransactions: IInventoryTransaction[],
): IItemsQuantityChanges[] {
const balanceMap: { [itemId: number]: number } = {};
inventroyTransactions.forEach(
(inventoryTransaction: IInventoryTransaction) => {
const { itemId, direction, quantity } = inventoryTransaction;
if (!balanceMap[itemId]) {
balanceMap[itemId] = 0;
}
balanceMap[itemId] += direction === 'IN' ? quantity : 0;
balanceMap[itemId] -= direction === 'OUT' ? quantity : 0;
},
);
return Object.entries(balanceMap).map(([itemId, balanceChange]) => ({
itemId: toSafeInteger(itemId),
balanceChange,
}));
}
/**
* Changes the items quantity changes.
* @param {IItemsQuantityChanges[]} itemsQuantity - Items quantity changes.
* @return {Promise<void>}
*/
public async changeItemsQuantity(
itemsQuantity: IItemsQuantityChanges[],
trx?: Knex.Transaction,
): Promise<void> {
const opers = [];
itemsQuantity.forEach((itemQuantity: IItemsQuantityChanges) => {
const changeQuantityOper = this.itemModel
.query(trx)
.where({ id: itemQuantity.itemId, type: 'inventory' })
.modify('quantityOnHand', itemQuantity.balanceChange);
opers.push(changeQuantityOper);
});
await Promise.all(opers);
}
}

View File

@@ -0,0 +1,135 @@
import { Model } from 'objection';
import { castArray } from 'lodash';
import moment from 'moment';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { SaleReceipt } from '@/modules/SaleReceipts/models/SaleReceipt';
import { Item } from '@/modules/Items/models/Item';
import { BaseModel } from '@/models/Model';
export class InventoryCostLotTracker extends BaseModel {
date: Date;
direction: string;
itemId: number;
quantity: number;
rate: number;
remaining: number;
cost: number;
transactionType: string;
transactionId: number;
costAccountId: number;
entryId: number;
createdAt: Date;
exchangeRate: number;
currencyCode: string;
item?: Item;
invoice?: SaleInvoice;
receipt?: SaleReceipt;
/**
* Table name
*/
static get tableName() {
return 'inventory_cost_lot_tracker';
}
/**
* Model timestamps.
*/
static get timestamps() {
return [];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
groupedEntriesCost(query) {
query.select(['date', 'item_id', 'transaction_id', 'transaction_type']);
query.sum('cost as cost');
query.groupBy('transaction_id');
query.groupBy('transaction_type');
query.groupBy('date');
query.groupBy('item_id');
},
filterDateRange(query, startDate, endDate, type = 'day') {
const dateFormat = 'YYYY-MM-DD';
const fromDate = moment(startDate).startOf(type).format(dateFormat);
const toDate = moment(endDate).endOf(type).format(dateFormat);
if (startDate) {
query.where('date', '>=', fromDate);
}
if (endDate) {
query.where('date', '<=', toDate);
}
},
/**
* Filters transactions by the given branches.
*/
filterByBranches(query, branchesIds) {
const formattedBranchesIds = castArray(branchesIds);
query.whereIn('branchId', formattedBranchesIds);
},
/**
* Filters transactions by the given warehosues.
*/
filterByWarehouses(query, branchesIds) {
const formattedWarehousesIds = castArray(branchesIds);
query.whereIn('warehouseId', formattedWarehousesIds);
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Item = require('models/Item');
const SaleInvoice = require('models/SaleInvoice');
const ItemEntry = require('models/ItemEntry');
const SaleReceipt = require('models/SaleReceipt');
return {
item: {
relation: Model.BelongsToOneRelation,
modelClass: Item.default,
join: {
from: 'inventory_cost_lot_tracker.itemId',
to: 'items.id',
},
},
invoice: {
relation: Model.BelongsToOneRelation,
modelClass: SaleInvoice.default,
join: {
from: 'inventory_cost_lot_tracker.transactionId',
to: 'sales_invoices.id',
},
},
itemEntry: {
relation: Model.BelongsToOneRelation,
modelClass: ItemEntry.default,
join: {
from: 'inventory_cost_lot_tracker.entryId',
to: 'items_entries.id',
},
},
receipt: {
relation: Model.BelongsToOneRelation,
modelClass: SaleReceipt.default,
join: {
from: 'inventory_cost_lot_tracker.transactionId',
to: 'sales_receipts.id',
},
},
};
}
}

View File

@@ -0,0 +1,165 @@
import { Model, raw } from 'objection';
import { castArray } from 'lodash';
import moment from 'moment';
import { BaseModel } from '@/models/Model';
import { getTransactionTypeLabel } from '@/modules/BankingTransactions/utils';
import { TInventoryTransactionDirection } from '../types/InventoryCost.types';
export class InventoryTransaction extends BaseModel {
date: Date | string;
direction: TInventoryTransactionDirection;
itemId: number;
quantity: number | null;
rate: number;
transactionType: string;
transactionId: number;
costAccountId?: number;
entryId: number;
createdAt?: Date;
updatedAt?: Date;
warehouseId?: number;
/**
* Table name
*/
static get tableName() {
return 'inventory_transactions';
}
/**
* Model timestamps.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Retrieve formatted reference type.
* @return {string}
*/
get transcationTypeFormatted() {
return getTransactionTypeLabel(this.transactionType);
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
filterDateRange(query, startDate, endDate, type = 'day') {
const dateFormat = 'YYYY-MM-DD';
const fromDate = moment(startDate).startOf(type).format(dateFormat);
const toDate = moment(endDate).endOf(type).format(dateFormat);
if (startDate) {
query.where('date', '>=', fromDate);
}
if (endDate) {
query.where('date', '<=', toDate);
}
},
itemsTotals(builder) {
builder.select('itemId');
builder.sum('rate as rate');
builder.sum('quantity as quantity');
builder.select(raw('SUM(`QUANTITY` * `RATE`) as COST'));
builder.groupBy('itemId');
},
INDirection(builder) {
builder.where('direction', 'IN');
},
OUTDirection(builder) {
builder.where('direction', 'OUT');
},
/**
* Filters transactions by the given branches.
*/
filterByBranches(query, branchesIds) {
const formattedBranchesIds = castArray(branchesIds);
query.whereIn('branch_id', formattedBranchesIds);
},
/**
* Filters transactions by the given warehosues.
*/
filterByWarehouses(query, warehousesIds) {
const formattedWarehousesIds = castArray(warehousesIds);
query.whereIn('warehouse_id', formattedWarehousesIds);
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Item = require('models/Item');
const ItemEntry = require('models/ItemEntry');
const InventoryTransactionMeta = require('models/InventoryTransactionMeta');
const InventoryCostLots = require('models/InventoryCostLotTracker');
return {
// Transaction meta.
meta: {
relation: Model.HasOneRelation,
modelClass: InventoryTransactionMeta.default,
join: {
from: 'inventory_transactions.id',
to: 'inventory_transaction_meta.inventoryTransactionId',
},
},
// Item cost aggregated.
itemCostAggregated: {
relation: Model.HasOneRelation,
modelClass: InventoryCostLots.default,
join: {
from: 'inventory_transactions.itemId',
to: 'inventory_cost_lot_tracker.itemId',
},
filter(query) {
query.select('itemId');
query.sum('cost as cost');
query.sum('quantity as quantity');
query.groupBy('itemId');
},
},
costLotAggregated: {
relation: Model.HasOneRelation,
modelClass: InventoryCostLots.default,
join: {
from: 'inventory_transactions.id',
to: 'inventory_cost_lot_tracker.inventoryTransactionId',
},
filter(query) {
query.sum('cost as cost');
query.sum('quantity as quantity');
query.groupBy('inventoryTransactionId');
},
},
item: {
relation: Model.BelongsToOneRelation,
modelClass: Item.default,
join: {
from: 'inventory_transactions.itemId',
to: 'items.id',
},
},
itemEntry: {
relation: Model.BelongsToOneRelation,
modelClass: ItemEntry.default,
join: {
from: 'inventory_transactions.entryId',
to: 'items_entries.id',
},
},
};
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { IInventoryCostLotsGLEntriesWriteEvent } from '../types/InventoryCost.types';
import { InventoryCostGLStorage } from '../InventoryCostGLStorage.service';
@Injectable()
export class InventoryCostGLBeforeWriteSubscriber {
constructor(
private readonly inventoryCostGLStorage: InventoryCostGLStorage,
) {}
/**
* Writes the receipts cost GL entries once the inventory cost lots be written.
* @param {IInventoryCostLotsGLEntriesWriteEvent}
*/
@OnEvent(events.inventory.onCostLotsGLEntriesBeforeWrite)
public async revertsInventoryCostGLEntries({
trx,
startingDate,
}: IInventoryCostLotsGLEntriesWriteEvent) {
await this.inventoryCostGLStorage.revertInventoryCostGLEntries(
startingDate,
trx
);
};
}

View File

@@ -0,0 +1,61 @@
import { Knex } from "knex";
import { InventoryTransaction } from "../models/InventoryTransaction";
export interface IInventoryItemCostMeta {
itemId: number;
valuation: number;
quantity: number;
average: number;
}
export interface IInventoryCostLotsGLEntriesWriteEvent {
startingDate: Date,
trx: Knex.Transaction
}
export type TInventoryTransactionDirection = 'IN' | 'OUT';
export type TCostMethod = 'FIFO' | 'LIFO' | 'AVG';
export interface IInventoryTransactionMeta {
id?: number;
transactionNumber: string;
description: string;
}
export interface IInventoryCostLotAggregated {
cost: number;
quantity: number;
}
export interface IItemsQuantityChanges {
itemId: number;
balanceChange: number;
}
export interface IInventoryTransactionsCreatedPayload {
inventoryTransactions: InventoryTransaction[];
trx: Knex.Transaction;
}
export interface IInventoryTransactionsDeletedPayload {
oldInventoryTransactions: InventoryTransaction[];
transactionId: number;
transactionType: string;
trx: Knex.Transaction;
}
export interface IInventoryItemCostScheduledPayload {
startingDate: Date | string;
itemId: number;
}
export interface IComputeItemCostJobStartedPayload {
startingDate: Date | string;
itemId: number;
}
export interface IComputeItemCostJobCompletedPayload {
startingDate: Date | string;
itemId: number;
}

View File

@@ -0,0 +1,15 @@
import { chain } from 'lodash';
/**
* Grpups by transaction type and id the inventory transactions.
* @param {IInventoryTransaction} invTransactions
* @returns
*/
export function groupInventoryTransactionsByTypeId(
transactions: { transactionType: string; transactionId: number }[]
): { transactionType: string; transactionId: number }[][] {
return chain(transactions)
.groupBy((t) => `${t.transactionType}-${t.transactionId}`)
.values()
.value();
}