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

@@ -17,6 +17,7 @@ exports.up = function(knex) {
table.string('inv_lot_number').index(); table.string('inv_lot_number').index();
table.date('delivered_at').index(); table.date('delivered_at').index();
table.integer('user_id').unsigned();
table.timestamps(); table.timestamps();
}); });
}; };

View File

@@ -10,7 +10,7 @@ exports.up = function(knex) {
table.integer('quantity').unsigned(); table.integer('quantity').unsigned();
table.decimal('rate', 13, 3).unsigned(); table.decimal('rate', 13, 3).unsigned();
table.integer('lot_number').index(); table.string('lot_number').index();
table.string('transaction_type').index(); table.string('transaction_type').index();
table.integer('transaction_id').unsigned().index(); table.integer('transaction_id').unsigned().index();

View File

@@ -9,7 +9,7 @@ exports.up = function(knex) {
table.integer('quantity').unsigned().index(); table.integer('quantity').unsigned().index();
table.decimal('rate', 13, 3); table.decimal('rate', 13, 3);
table.integer('remaining'); table.integer('remaining');
table.integer('cost'); table.decimal('cost', 13, 3);
table.integer('lot_number').index(); table.integer('lot_number').index();
table.string('transaction_type').index(); table.string('transaction_type').index();

View File

@@ -10,6 +10,7 @@ export interface IInventoryTransaction {
transactionType: string, transactionType: string,
transactionId: string, transactionId: string,
lotNumber: string, lotNumber: string,
entryId: number
}; };
export interface IInventoryLotCost { export interface IInventoryLotCost {
@@ -19,7 +20,9 @@ export interface IInventoryLotCost {
itemId: number, itemId: number,
rate: number, rate: number,
remaining: number, remaining: number,
cost: number,
lotNumber: string|number, lotNumber: string|number,
transactionType: string, transactionType: string,
transactionId: string, transactionId: string,
entryId: number
} }

View File

@@ -1,20 +1,30 @@
import { Container } from 'typedi'; import { Container } from 'typedi';
import moment from 'moment'; import {EventDispatcher} from "event-dispatch";
// import {
// EventDispatcher,
// } from 'decorators/eventDispatcher';
import events from 'subscribers/events';
import InventoryService from 'services/Inventory/Inventory'; import InventoryService from 'services/Inventory/Inventory';
import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost';
export default class ComputeItemCostJob { export default class ComputeItemCostJob {
depends: number;
agenda: any; agenda: any;
startingDate: Date; eventDispatcher: EventDispatcher;
/**
*
* @param agenda
*/
constructor(agenda) { constructor(agenda) {
this.agenda = agenda; this.agenda = agenda;
this.depends = 0; this.eventDispatcher = new EventDispatcher();
this.startingDate = null;
this.agenda.on('complete:compute-item-cost', this.onJobFinished.bind(this)); agenda.define(
'compute-item-cost',
{ priority: 'high', concurrency: 1 },
this.handler.bind(this),
);
this.agenda.on('start:compute-item-cost', this.onJobStart.bind(this)); this.agenda.on('start:compute-item-cost', this.onJobStart.bind(this));
this.agenda.on('complete:compute-item-cost', this.onJobCompleted.bind(this));
} }
/** /**
@@ -23,12 +33,14 @@ export default class ComputeItemCostJob {
*/ */
public async handler(job, done: Function): Promise<void> { public async handler(job, done: Function): Promise<void> {
const Logger = Container.get('logger'); const Logger = Container.get('logger');
const { startingDate, itemId, costMethod = 'FIFO' } = job.attrs.data; const inventoryService = Container.get(InventoryService);
const { startingDate, itemId, tenantId } = job.attrs.data;
Logger.info(`Compute item cost - started: ${job.attrs.data}`); Logger.info(`Compute item cost - started: ${job.attrs.data}`);
try { try {
await InventoryService.computeItemCost(startingDate, itemId, costMethod); await inventoryService.computeItemCost(tenantId, startingDate, itemId);
Logger.info(`Compute item cost - completed: ${job.attrs.data}`); Logger.info(`Compute item cost - completed: ${job.attrs.data}`);
done(); done();
} catch(e) { } catch(e) {
@@ -42,30 +54,24 @@ export default class ComputeItemCostJob {
* @param {Job} job - . * @param {Job} job - .
*/ */
async onJobStart(job) { async onJobStart(job) {
const { startingDate } = job.attrs.data; const { startingDate, itemId, tenantId } = job.attrs.data;
this.depends += 1;
if (!this.startingDate || moment(this.startingDate).isBefore(startingDate)) { await this.eventDispatcher.dispatch(
this.startingDate = startingDate; events.inventory.onComputeItemCostJobStarted,
} { startingDate, itemId, tenantId }
);
} }
/** /**
* Handle job complete items cost finished. * Handle job complete items cost finished.
* @param {Job} job - * @param {Job} job -
*/ */
async onJobFinished() { async onJobCompleted(job) {
const agenda = Container.get('agenda'); const { startingDate, itemId, tenantId } = job.attrs.data;
const startingDate = this.startingDate;
this.depends = Math.max(this.depends - 1, 0); await this.eventDispatcher.dispatch(
events.inventory.onComputeItemCostJobCompleted,
if (this.depends === 0) { { startingDate, itemId, tenantId },
this.startingDate = null; );
await agenda.now('rewrite-invoices-journal-entries', {
startingDate,
});
}
} }
} }

View File

@@ -3,14 +3,24 @@ import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost';
export default class WriteInvoicesJournalEntries { export default class WriteInvoicesJournalEntries {
constructor(agenda) {
agenda.define(
'rewrite-invoices-journal-entries',
{ priority: 'normal', concurrency: 1, },
this.handler.bind(this),
);
}
public async handler(job, done: Function): Promise<void> { public async handler(job, done: Function): Promise<void> {
const Logger = Container.get('logger'); const Logger = Container.get('logger');
const { startingDate } = job.attrs.data; const { startingDate, tenantId } = job.attrs.data;
const salesInvoicesCost = Container.get(SalesInvoicesCost);
Logger.info(`Write sales invoices journal entries - started: ${job.attrs.data}`); Logger.info(`Write sales invoices journal entries - started: ${job.attrs.data}`);
try { try {
await SalesInvoicesCost.writeJournalEntries(startingDate, true); await salesInvoicesCost.writeJournalEntries(tenantId, startingDate, true);
Logger.info(`Write sales invoices journal entries - completed: ${job.attrs.data}`); Logger.info(`Write sales invoices journal entries - completed: ${job.attrs.data}`);
done(); done();
} catch(e) { } catch(e) {

View File

@@ -12,4 +12,5 @@ import 'subscribers/vendors';
import 'subscribers/paymentMades'; import 'subscribers/paymentMades';
import 'subscribers/paymentReceives'; import 'subscribers/paymentReceives';
import 'subscribers/saleEstimates'; import 'subscribers/saleEstimates';
import 'subscribers/saleReceipts'; import 'subscribers/saleReceipts';
import 'subscribers/inventory';

View File

@@ -19,17 +19,8 @@ export default ({ agenda }: { agenda: Agenda }) => {
new UserInviteMailJob(agenda); new UserInviteMailJob(agenda);
new SendLicenseViaEmailJob(agenda); new SendLicenseViaEmailJob(agenda);
new SendLicenseViaPhoneJob(agenda); new SendLicenseViaPhoneJob(agenda);
new ComputeItemCost(agenda);
agenda.define( new RewriteInvoicesJournalEntries(agenda);
'compute-item-cost',
{ priority: 'high', concurrency: 20 },
new ComputeItemCost(agenda).handler,
);
agenda.define(
'rewrite-invoices-journal-entries',
{ priority: 'normal', concurrency: 1, },
new RewriteInvoicesJournalEntries().handler,
);
agenda.define( agenda.define(
'send-sms-notification-subscribe-end', 'send-sms-notification-subscribe-end',

View File

@@ -10,6 +10,9 @@ import {
IExpense, IExpense,
IExpenseCategory, IExpenseCategory,
IItem, IItem,
ISaleInvoice,
IInventoryLotCost,
IItemEntry,
} from 'interfaces'; } from 'interfaces';
interface IInventoryCostEntity { interface IInventoryCostEntity {
@@ -409,4 +412,119 @@ export default class JournalCommands{
} }
}); });
} }
/**
* Writes journal entries for given sale invoice.
* ----------
* - Receivable accounts -> Debit -> XXXX
* - Income -> Credit -> XXXX
*
* - Cost of goods sold -> Debit -> YYYY
* - Inventory assets -> YYYY
*
* @param {ISaleInvoice} saleInvoice
* @param {JournalPoster} journal
*/
saleInvoice(
saleInvoice: ISaleInvoice & {
costTransactions: IInventoryLotCost[],
entries: IItemEntry & { item: IItem },
},
receivableAccountsId: number,
) {
let inventoryTotal: number = 0;
const commonEntry = {
referenceType: 'SaleInvoice',
referenceId: saleInvoice.id,
date: saleInvoice.invoiceDate,
};
const costTransactions: Map<number, number> = new Map(
saleInvoice.costTransactions.map((trans: IInventoryLotCost) => [
trans.entryId, trans.cost,
]),
);
// XXX Debit - Receivable account.
const receivableEntry = new JournalEntry({
...commonEntry,
debit: saleInvoice.balance,
account: receivableAccountsId,
index: 1,
});
this.journal.debit(receivableEntry);
saleInvoice.entries.forEach((entry: IItemEntry & { item: IItem }, index) => {
const cost: number = costTransactions.get(entry.id);
const income: number = entry.quantity * entry.rate;
if (entry.item.type === 'inventory' && cost) {
// XXX Debit - Cost account.
const costEntry = new JournalEntry({
...commonEntry,
debit: cost,
account: entry.item.costAccountId,
note: entry.description,
index: index + 3,
});
this.journal.debit(costEntry);
inventoryTotal += cost;
}
// XXX Credit - Income account.
const incomeEntry = new JournalEntry({
...commonEntry,
credit: income,
account: entry.item.sellAccountId,
note: entry.description,
index: index + 2,
});
this.journal.credit(incomeEntry);
if (inventoryTotal > 0) {
// XXX Credit - Inventory account.
const inventoryEntry = new JournalEntry({
...commonEntry,
credit: inventoryTotal,
account: entry.item.inventoryAccountId,
index: index + 4,
});
this.journal.credit(inventoryEntry);
}
});
}
saleInvoiceNonInventory(
saleInvoice: ISaleInvoice & {
entries: IItemEntry & { item: IItem },
},
receivableAccountsId: number,
) {
const commonEntry = {
referenceType: 'SaleInvoice',
referenceId: saleInvoice.id,
date: saleInvoice.invoiceDate,
};
// XXX Debit - Receivable account.
const receivableEntry = new JournalEntry({
...commonEntry,
debit: saleInvoice.balance,
account: receivableAccountsId,
index: 1,
});
this.journal.debit(receivableEntry);
saleInvoice.entries.forEach((entry: IItemEntry & { item: IItem }, index: number) => {
const income: number = entry.quantity * entry.rate;
// XXX Credit - Income account.
const incomeEntry = new JournalEntry({
...commonEntry,
credit: income,
account: entry.item.sellAccountId,
note: entry.description,
index: index + 2,
});
this.journal.credit(incomeEntry);
});
}
} }

View File

@@ -1,8 +1,14 @@
import { Container, Service, Inject } from 'typedi'; 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 InventoryAverageCost from 'services/Inventory/InventoryAverageCost';
import InventoryCostLotTracker from 'services/Inventory/InventoryCostLotTracker'; import InventoryCostLotTracker from 'services/Inventory/InventoryCostLotTracker';
import TenancyService from 'services/Tenancy/TenancyService'; import TenancyService from 'services/Tenancy/TenancyService';
import events from 'subscribers/events';
type TCostMethod = 'FIFO' | 'LIFO' | 'AVG'; type TCostMethod = 'FIFO' | 'LIFO' | 'AVG';
@@ -11,6 +17,31 @@ export default class InventoryService {
@Inject() @Inject()
tenancy: TenancyService; 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 * 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. * 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) { async computeItemCost(tenantId: number, fromDate: Date, itemId: number) {
const { Item } = this.tenancy.models(tenantId); const { Item } = this.tenancy.models(tenantId);
const item = await Item.query() // Fetches the item with assocaited item category.
.findById(itemId) const item = await Item.query().findById(itemId);
.withGraphFetched('category');
// Cannot continue if the given item was not inventory item. // Cannot continue if the given item was not inventory item.
if (item.type !== 'inventory') { if (item.type !== 'inventory') {
throw new Error('You could not compute item cost has no inventory type.'); throw new Error('You could not compute item cost has no inventory type.');
} }
const costMethod: TCostMethod = item.category.costMethod;
let costMethodComputer: IInventoryCostMethod; let costMethodComputer: IInventoryCostMethod;
// Switch between methods based on the item cost method. // Switch between methods based on the item cost method.
switch(costMethod) { switch('AVG') {
case 'FIFO': case 'FIFO':
case 'LIFO': case 'LIFO':
costMethodComputer = new InventoryCostLotTracker(fromDate, itemId); costMethodComputer = new InventoryCostLotTracker(tenantId, fromDate, itemId);
break; break;
case 'AVG': case 'AVG':
costMethodComputer = new InventoryAverageCost(fromDate, itemId); costMethodComputer = new InventoryAverageCost(tenantId, fromDate, itemId);
break; break;
} }
return costMethodComputer.computeItemCost(); return costMethodComputer.computeItemCost();
@@ -54,9 +83,36 @@ export default class InventoryService {
async scheduleComputeItemCost(tenantId: number, itemId: number, startingDate: Date|string) { async scheduleComputeItemCost(tenantId: number, itemId: number, startingDate: Date|string) {
const agenda = Container.get('agenda'); const agenda = Container.get('agenda');
return agenda.schedule('in 3 seconds', 'compute-item-cost', { // Cancel any `compute-item-cost` in the queue has upper starting date
startingDate, itemId, tenantId, // 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( async recordInventoryTransactions(
tenantId: number, tenantId: number,
entries: IInventoryTransaction[], inventoryEntries: IInventoryTransaction[],
deleteOld: boolean, deleteOld: boolean,
): Promise<void> { ): Promise<void> {
const { InventoryTransaction, Item } = this.tenancy.models(tenantId); 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) => { inventoryEntries.forEach(async (entry: any) => {
if (deleteOld) { if (deleteOld) {
await this.deleteInventoryTransactions( await this.deleteInventoryTransactions(
@@ -108,14 +151,14 @@ export default class InventoryService {
* @param {number} transactionId * @param {number} transactionId
* @return {Promise} * @return {Promise}
*/ */
deleteInventoryTransactions( async deleteInventoryTransactions(
tenantId: number, tenantId: number,
transactionId: number, transactionId: number,
transactionType: string, transactionType: string,
) { ): Promise<void> {
const { InventoryTransaction } = this.tenancy.models(tenantId); const { InventoryTransaction } = this.tenancy.models(tenantId);
return InventoryTransaction.query() await InventoryTransaction.query()
.where('transaction_type', transactionType) .where('transaction_type', transactionType)
.where('transaction_id', transactionId) .where('transaction_id', transactionId)
.delete(); .delete();
@@ -125,22 +168,37 @@ export default class InventoryService {
* Retrieve the lot number after the increment. * Retrieve the lot number after the increment.
* @param {number} tenantId - Tenant id. * @param {number} tenantId - Tenant id.
*/ */
async nextLotNumber(tenantId: number) { getNextLotNumber(tenantId: number) {
const { Option } = this.tenancy.models(tenantId); const settings = this.tenancy.settings(tenantId);
const LOT_NUMBER_KEY = 'lot_number_increment'; const LOT_NUMBER_KEY = 'lot_number_increment';
const effectRows = await Option.query() const storedLotNumber = settings.find({ key: LOT_NUMBER_KEY });
.where('key', LOT_NUMBER_KEY)
.increment('value', 1);
if (effectRows === 0) { return (storedLotNumber && storedLotNumber.value) ?
await Option.query() parseInt(storedLotNumber.value, 10) : 1;
.insert({ }
key: LOT_NUMBER_KEY,
value: 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(); settings.set({ key: LOT_NUMBER_KEY }, lotNumber);
return options.getMeta(LOT_NUMBER_KEY, 1);
await settings.save();
return lotNumber;
} }
} }

View File

@@ -1,8 +1,11 @@
import { pick } from 'lodash'; import { pick } from 'lodash';
import { raw } from 'objection';
import { IInventoryTransaction } from 'interfaces'; import { IInventoryTransaction } from 'interfaces';
import InventoryCostMethod from 'services/Inventory/InventoryCostMethod'; import InventoryCostMethod from 'services/Inventory/InventoryCostMethod';
export default class InventoryAverageCostMethod extends InventoryCostMethod implements IInventoryCostMethod { export default class InventoryAverageCostMethod
extends InventoryCostMethod
implements IInventoryCostMethod {
startingDate: Date; startingDate: Date;
itemId: number; itemId: number;
costTransactions: any[]; costTransactions: any[];
@@ -13,13 +16,9 @@ export default class InventoryAverageCostMethod extends InventoryCostMethod impl
* @param {Date} startingDate - * @param {Date} startingDate -
* @param {number} itemId - The given inventory item id. * @param {number} itemId - The given inventory item id.
*/ */
constructor( constructor(tenantId: number, startingDate: Date, itemId: number) {
tenantId: number, super(tenantId, startingDate, itemId);
startingDate: Date,
itemId: number,
) {
super();
this.startingDate = startingDate; this.startingDate = startingDate;
this.itemId = itemId; this.itemId = itemId;
this.costTransactions = []; this.costTransactions = [];
@@ -27,154 +26,216 @@ export default class InventoryAverageCostMethod extends InventoryCostMethod impl
/** /**
* Computes items costs from the given date using average cost method. * Computes items costs from the given date using average cost method.
* * ----------
* - Calculate the items average cost in the given date. * - 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. * 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. * after the given date.
* ---------- * ----------
* @asycn * @async
* @param {Date} startingDate * @param {Date} startingDate
* @param {number} referenceId * @param {number} referenceId
* @param {string} referenceType * @param {string} referenceType
*/ */
public async computeItemCost() { public async computeItemCost() {
const { InventoryTransaction } = this.tenantModels; 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() const afterInvTransactions: IInventoryTransaction[] = await InventoryTransaction.query()
.modify('filterDateRange', this.startingDate) .modify('filterDateRange', this.startingDate)
.orderBy('date', 'ASC') .orderBy('date', 'ASC')
.orderByRaw("FIELD(direction, 'IN', 'OUT')") .orderByRaw("FIELD(direction, 'IN', 'OUT')")
.orderBy('lot_number', 'ASC')
.where('item_id', this.itemId) .where('item_id', this.itemId)
.withGraphFetched('item'); .withGraphFetched('item');
// Tracking inventroy transactions and retrieve cost transactions // Tracking inventroy transactions and retrieve cost transactions based on
// based on average rate cost method. // average rate cost method.
const costTransactions = this.trackingCostTransactions( const costTransactions = this.trackingCostTransactions(
afterInvTransactions, afterInvTransactions,
openingAvgCost, openingQuantity,
openingCost
); );
// Revert the inveout out lots transactions
await this.revertTheInventoryOutLotTrans();
// Store inventory lots cost transactions.
await this.storeInventoryLotsCost(costTransactions); await this.storeInventoryLotsCost(costTransactions);
} }
/** /**
* Get items Avarege cost from specific date from inventory transactions. * Get items Avarege cost from specific date from inventory transactions.
* @static * @async
* @param {Date} startingDate * @param {Date} closingDate
* @return {number} * @return {number}
*/ */
public async getOpeningAvaregeCost(startingDate: Date, itemId: number) { public async getOpeningAvaregeCost(closingDate: Date, itemId: number) {
const { InventoryTransaction } = this.tenantModels; const { InventoryCostLotTracker } = this.tenantModels;
const commonBuilder = (builder: any) => { const commonBuilder = (builder: any) => {
if (startingDate) { if (closingDate) {
builder.where('date', '<', startingDate); builder.where('date', '<', closingDate);
} }
builder.where('item_id', itemId); 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('rate as rate');
builder.sum('quantity as quantity'); builder.sum('quantity as quantity');
builder.sum('cost as cost');
builder.first();
}; };
// Calculates the total inventory total quantity and rate `IN` transactions. // Calculates the total inventory total quantity and rate `IN` transactions.
const inInvSumationOper: Promise<any> = InventoryCostLotTracker.query()
// @todo total `IN` transactions.
const inInvSumationOper: Promise<any> = InventoryTransaction.query()
.onBuild(commonBuilder) .onBuild(commonBuilder)
.where('direction', 'IN') .where('direction', 'IN');
.first();
// Calculates the total inventory total quantity and rate `OUT` transactions. // Calculates the total inventory total quantity and rate `OUT` transactions.
// @todo total `OUT` transactions. const outInvSumationOper: Promise<any> = InventoryCostLotTracker.query()
const outInvSumationOper: Promise<any> = InventoryTransaction.query()
.onBuild(commonBuilder) .onBuild(commonBuilder)
.where('direction', 'OUT') .where('direction', 'OUT');
.first();
const [inInvSumation, outInvSumation] = await Promise.all([ const [inInvSumation, outInvSumation] = await Promise.all([
inInvSumationOper, inInvSumationOper,
outInvSumationOper, outInvSumationOper,
]); ]);
return this.computeItemAverageCost( return this.computeItemAverageCost(
inInvSumation?.quantity || 0, inInvSumation?.cost || 0,
inInvSumation?.rate || 0, inInvSumation?.quantity || 0,
outInvSumation?.quantity || 0, outInvSumation?.cost || 0,
outInvSumation?.rate || 0 outInvSumation?.quantity || 0
); );
} }
/** /**
* Computes the item average cost. * Computes the item average cost.
* @static * @static
* @param {number} quantityIn * @param {number} quantityIn
* @param {number} rateIn * @param {number} rateIn
* @param {number} quantityOut * @param {number} quantityOut
* @param {number} rateOut * @param {number} rateOut
*/ */
public computeItemAverageCost( public computeItemAverageCost(
quantityIn: number, totalCostIn: number,
rateIn: number, totalQuantityIn: number,
quantityOut: number, totalCostOut: number,
rateOut: number, totalQuantityOut: number
) { ) {
const totalQuantity = (quantityIn - quantityOut); const openingCost = totalCostIn - totalCostOut;
const totalRate = (rateIn - rateOut); const openingQuantity = totalQuantityIn - totalQuantityOut;
const averageCost = (totalRate) ? (totalQuantity / totalRate) : totalQuantity;
return averageCost; const averageCost = openingQuantity ? openingCost / openingQuantity : 0;
return { averageCost, openingCost, openingQuantity };
} }
/** /**
* Records the journal entries from specific item inventory transactions. * Records the journal entries from specific item inventory transactions.
* @param {IInventoryTransaction[]} invTransactions * @param {IInventoryTransaction[]} invTransactions
* @param {number} openingAverageCost * @param {number} openingAverageCost
* @param {string} referenceType * @param {string} referenceType
* @param {number} referenceId * @param {number} referenceId
* @param {JournalCommand} journalCommands * @param {JournalCommand} journalCommands
*/ */
public trackingCostTransactions( public trackingCostTransactions(
invTransactions: IInventoryTransaction[], invTransactions: IInventoryTransaction[],
openingAverageCost: number, openingQuantity: number = 0,
openingCost: number = 0
) { ) {
const costTransactions: any[] = []; 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) => { invTransactions.forEach((invTransaction: IInventoryTransaction) => {
const commonEntry = { const commonEntry = {
invTransId: invTransaction.id, invTransId: invTransaction.id,
...pick(invTransaction, ['date', 'direction', 'itemId', 'quantity', 'rate', 'entryId', ...pick(invTransaction, [
'transactionId', 'transactionType']), 'date',
'direction',
'itemId',
'quantity',
'rate',
'entryId',
'transactionId',
'transactionType',
'lotNumber',
]),
}; };
switch(invTransaction.direction) { switch (invTransaction.direction) {
case 'IN': case 'IN':
const inCost = invTransaction.rate * invTransaction.quantity;
// Increases the quantity and cost in `IN` inventory transactions.
accQuantity += invTransaction.quantity; accQuantity += invTransaction.quantity;
accCost += invTransaction.rate * invTransaction.quantity; accCost += inCost;
costTransactions.push({ costTransactions.push({
...commonEntry, ...commonEntry,
cost: inCost,
}); });
break; break;
case 'OUT': case 'OUT':
const transactionAvgCost = accCost ? (accCost / accQuantity) : 0; // Average cost = Total cost / Total quantity
const averageCost = transactionAvgCost; const averageCost = accQuantity ? accCost / accQuantity : 0;
const cost = (invTransaction.quantity * averageCost);
const income = (invTransaction.quantity * invTransaction.rate);
accQuantity -= invTransaction.quantity; const quantity =
accCost -= income; 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({ costTransactions.push({
...commonEntry, ...commonEntry,
quantity,
cost, 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; break;
} }
}); });
return costTransactions; 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, itemId: number,
costMethod: TCostMethod = 'FIFO' costMethod: TCostMethod = 'FIFO'
) { ) {
super(); super(tenantId, startingDate, itemId);
this.startingDate = startingDate; this.startingDate = startingDate;
this.itemId = itemId; this.itemId = itemId;
@@ -129,7 +129,7 @@ export default class InventoryCostLotTracker extends InventoryCostMethod impleme
.withGraphFetched('item'); .withGraphFetched('item');
this.outTransactions = [ ...afterOUTTransactions ]; this.outTransactions = [ ...afterOUTTransactions ];
} }
private async fetchItemsMapped() { private async fetchItemsMapped() {
const itemsIds = chain(this.inTransactions).map((e) => e.itemId).uniq().value(); const itemsIds = chain(this.inTransactions).map((e) => e.itemId).uniq().value();

View File

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

View File

@@ -1,4 +1,4 @@
import { difference } from 'lodash'; import { difference, map } from 'lodash';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { import {
IItemEntry, IItemEntry,
@@ -7,6 +7,7 @@ import {
} from 'interfaces'; } from 'interfaces';
import { ServiceError } from 'exceptions'; import { ServiceError } from 'exceptions';
import TenancyService from 'services/Tenancy/TenancyService'; import TenancyService from 'services/Tenancy/TenancyService';
import { ItemEntry } from 'models';
const ERRORS = { const ERRORS = {
ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND', ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND',
@@ -20,6 +21,39 @@ export default class ItemsEntriesService {
@Inject() @Inject()
tenancy: TenancyService; tenancy: TenancyService;
/**
* Retrieve the inventory items entries of the reference id and type.
* @param {number} tenantId
* @param {string} referenceType
* @param {string} referenceId
* @return {Promise<IItemEntry[]>}
*/
public async getInventoryEntries(
tenantId: number,
referenceType: string,
referenceId: number,
): Promise<IItemEntry[]> {
const { Item, ItemEntry } = this.tenancy.models(tenantId);
const itemsEntries = await ItemEntry.query()
.where('reference_type', referenceType)
.where('reference_id', referenceId);
// Inventory items.
const inventoryItems = await Item.query()
.whereIn('id', map(itemsEntries, 'itemId'))
.where('type', 'inventory');
// Inventory items ids.
const inventoryItemsIds = map(inventoryItems, 'id');
// Filtering the inventory items entries.
const inventoryItemsEntries = itemsEntries.filter(
(itemEntry) => inventoryItemsIds.indexOf(itemEntry.itemId) !== -1
);
return inventoryItemsEntries;
}
/** /**
* Validates the entries items ids. * Validates the entries items ids.
* @async * @async

View File

@@ -1,4 +1,4 @@
import { omit, sumBy, pick, difference, assignWith } from 'lodash'; import { omit, sumBy, pick, map } from 'lodash';
import moment from 'moment'; import moment from 'moment';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { import {
@@ -7,7 +7,6 @@ import {
} from 'decorators/eventDispatcher'; } from 'decorators/eventDispatcher';
import events from 'subscribers/events'; import events from 'subscribers/events';
import JournalPoster from 'services/Accounting/JournalPoster'; import JournalPoster from 'services/Accounting/JournalPoster';
import JournalEntry from 'services/Accounting/JournalEntry';
import AccountsService from 'services/Accounts/AccountsService'; import AccountsService from 'services/Accounts/AccountsService';
import InventoryService from 'services/Inventory/Inventory'; import InventoryService from 'services/Inventory/Inventory';
import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost'; import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost';
@@ -24,6 +23,7 @@ import {
IFilterMeta, IFilterMeta,
IBillsFilter, IBillsFilter,
IItemEntry, IItemEntry,
IInventoryTransaction,
} from 'interfaces'; } from 'interfaces';
import { ServiceError } from 'exceptions'; import { ServiceError } from 'exceptions';
import ItemsService from 'services/Items/ItemsService'; import ItemsService from 'services/Items/ItemsService';
@@ -159,11 +159,7 @@ export default class BillsService extends SalesInvoicesCost {
oldBill?: IBill oldBill?: IBill
) { ) {
const { ItemEntry } = this.tenancy.models(tenantId); const { ItemEntry } = this.tenancy.models(tenantId);
let invLotNumber = oldBill?.invLotNumber;
// if (!invLotNumber) {
// invLotNumber = await this.inventoryService.nextLotNumber(tenantId);
// }
const entries = billDTO.entries.map((entry) => ({ const entries = billDTO.entries.map((entry) => ({
...entry, ...entry,
amount: ItemEntry.calcAmount(entry), amount: ItemEntry.calcAmount(entry),
@@ -176,7 +172,6 @@ export default class BillsService extends SalesInvoicesCost {
'dueDate', 'dueDate',
]), ]),
amount, amount,
invLotNumber,
entries: entries.map((entry) => ({ entries: entries.map((entry) => ({
reference_type: 'Bill', reference_type: 'Bill',
...omit(entry, ['amount', 'id']), ...omit(entry, ['amount', 'id']),
@@ -369,29 +364,51 @@ export default class BillsService extends SalesInvoicesCost {
/** /**
* Records the inventory transactions from the given bill input. * Records the inventory transactions from the given bill input.
* @param {Bill} bill * @param {Bill} bill - Bill model object.
* @param {number} billId * @param {number} billId - Bill id.
* @return {Promise<void>}
*/ */
public async recordInventoryTransactions( public async recordInventoryTransactions(
tenantId: number, tenantId: number,
bill: IBill, billId: number,
billDate: Date,
override?: boolean override?: boolean
): Promise<void> { ): Promise<void> {
const invTransactions = bill.entries.map((entry: IItemEntry) => ({ // Retrieve the next inventory lot number.
...pick(entry, ['itemId', 'quantity', 'rate']), const lotNumber = this.inventoryService.getNextLotNumber(tenantId);
lotNumber: bill.invLotNumber,
transactionType: 'Bill',
transactionId: bill.id,
direction: 'IN',
date: bill.billDate,
entryId: entry.id,
}));
const inventoryEntries = await this.itemsEntriesService.getInventoryEntries(
tenantId,
'Bill',
billId
);
// Can't continue if there is no entries has inventory items in the bill.
if (inventoryEntries.length <= 0) return;
// Inventory transactions.
const inventoryTranscations = this.inventoryService.transformItemEntriesToInventory(
inventoryEntries,
'Bill',
billId,
'IN',
billDate,
lotNumber,
);
// Records the inventory transactions.
await this.inventoryService.recordInventoryTransactions( await this.inventoryService.recordInventoryTransactions(
tenantId, tenantId,
invTransactions, inventoryTranscations,
override override
); );
// Save the next lot number settings.
await this.inventoryService.incrementNextLotNumber(tenantId);
// Triggers `onInventoryTransactionsCreated` event.
this.eventDispatcher.dispatch(events.bill.onInventoryTransactionsCreated, {
tenantId,
billId,
billDate,
});
} }
/** /**
@@ -404,7 +421,7 @@ export default class BillsService extends SalesInvoicesCost {
await this.inventoryService.deleteInventoryTransactions( await this.inventoryService.deleteInventoryTransactions(
tenantId, tenantId,
billId, billId,
'Bill', 'Bill'
); );
} }
@@ -519,22 +536,26 @@ export default class BillsService extends SalesInvoicesCost {
* @param {IBill} bill - * @param {IBill} bill -
* @return {Promise} * @return {Promise}
*/ */
public async scheduleComputeBillItemsCost(tenantId: number, bill) { public async scheduleComputeBillItemsCost(tenantId: number, billId: number) {
const { Item } = this.tenancy.models(tenantId); const { Item, Bill } = this.tenancy.models(tenantId);
const billItemsIds = bill.entries.map((entry) => entry.item_id);
// Retrieve the bill with associated entries.
const bill = await Bill.query()
.findById(billId)
.withGraphFetched('entries');
// Retrieves inventory items only. // Retrieves inventory items only.
const inventoryItems = await Item.query() const inventoryItems = await Item.query()
.whereIn('id', billItemsIds) .whereIn('id', map(bill.entries, 'itemId'))
.where('type', 'inventory'); .where('type', 'inventory');
const inventoryItemsIds = inventoryItems.map((i) => i.id); const inventoryItemsIds = map(inventoryItems, 'id');
if (inventoryItemsIds.length > 0) { if (inventoryItemsIds.length > 0) {
await this.scheduleComputeItemsCost( await this.scheduleComputeItemsCost(
tenantId, tenantId,
inventoryItemsIds, inventoryItemsIds,
bill.bill_date bill.billDate
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import { omit, sumBy, pick, chain } from 'lodash'; import { omit, sumBy, pick, map } from 'lodash';
import moment from 'moment'; import moment from 'moment';
import uniqid from 'uniqid';
import { import {
EventDispatcher, EventDispatcher,
EventDispatcherInterface, EventDispatcherInterface,
@@ -16,7 +17,6 @@ import {
IFilterMeta, IFilterMeta,
} from 'interfaces'; } from 'interfaces';
import events from 'subscribers/events'; import events from 'subscribers/events';
import JournalPoster from 'services/Accounting/JournalPoster';
import InventoryService from 'services/Inventory/Inventory'; import InventoryService from 'services/Inventory/Inventory';
import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost'; import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost';
import TenancyService from 'services/Tenancy/TenancyService'; import TenancyService from 'services/Tenancy/TenancyService';
@@ -71,7 +71,6 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
saleEstimatesService: SaleEstimateService; saleEstimatesService: SaleEstimateService;
/** /**
*
* Validate whether sale invoice number unqiue on the storage. * Validate whether sale invoice number unqiue on the storage.
*/ */
async validateInvoiceNumberUnique( async validateInvoiceNumberUnique(
@@ -166,8 +165,6 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
): Promise<ISaleInvoice> { ): Promise<ISaleInvoice> {
const { saleInvoiceRepository } = this.tenancy.repositories(tenantId); const { saleInvoiceRepository } = this.tenancy.repositories(tenantId);
const invLotNumber = 1;
// Transform DTO object to model object. // Transform DTO object to model object.
const saleInvoiceObj = this.transformDTOToModel(tenantId, saleInvoiceDTO); const saleInvoiceObj = this.transformDTOToModel(tenantId, saleInvoiceDTO);
@@ -176,7 +173,6 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
tenantId, tenantId,
saleInvoiceDTO.customerId saleInvoiceDTO.customerId
); );
// Validate sale invoice number uniquiness. // Validate sale invoice number uniquiness.
if (saleInvoiceDTO.invoiceNo) { if (saleInvoiceDTO.invoiceNo) {
await this.validateInvoiceNumberUnique( await this.validateInvoiceNumberUnique(
@@ -258,13 +254,11 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
tenantId, tenantId,
saleInvoiceDTO.entries saleInvoiceDTO.entries
); );
// Validate non-sellable entries items. // Validate non-sellable entries items.
await this.itemsEntriesService.validateNonSellableEntriesItems( await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId, tenantId,
saleInvoiceDTO.entries saleInvoiceDTO.entries
); );
// Validate the items entries existance. // Validate the items entries existance.
await this.itemsEntriesService.validateEntriesIdsExistance( await this.itemsEntriesService.validateEntriesIdsExistance(
tenantId, tenantId,
@@ -337,7 +331,6 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
tenantId, tenantId,
saleInvoiceId saleInvoiceId
); );
// Unlink the converted sale estimates from the given sale invoice. // Unlink the converted sale estimates from the given sale invoice.
await this.saleEstimatesService.unlinkConvertedEstimateFromInvoice( await this.saleEstimatesService.unlinkConvertedEstimateFromInvoice(
tenantId, tenantId,
@@ -360,91 +353,113 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
} }
/** /**
* Records the inventory transactions from the givne sale invoice input. * Records the inventory transactions of the given sale invoice in case
* @parma {number} tenantId - Tenant id. * the invoice has inventory entries only.
*
* @param {number} tenantId - Tenant id.
* @param {SaleInvoice} saleInvoice - Sale invoice DTO. * @param {SaleInvoice} saleInvoice - Sale invoice DTO.
* @param {number} saleInvoiceId - Sale invoice id. * @param {number} saleInvoiceId - Sale invoice id.
* @param {boolean} override - Allow to override old transactions. * @param {boolean} override - Allow to override old transactions.
* @return {Promise<void>}
*/ */
public recordInventoryTranscactions( public async recordInventoryTranscactions(
tenantId: number, tenantId: number,
saleInvoice: ISaleInvoice, saleInvoiceId: number,
saleInvoiceDate: Date,
override?: boolean override?: boolean
) { ): Promise<void> {
this.logger.info('[sale_invoice] saving inventory transactions'); // Gets the next inventory lot number.
const invTransactions: IInventoryTransaction[] = saleInvoice.entries.map( const lotNumber = this.inventoryService.getNextLotNumber(tenantId);
(entry: IItemEntry) => ({
...pick(entry, ['itemId', 'quantity', 'rate']),
lotNumber: 1,
transactionType: 'SaleInvoice',
transactionId: saleInvoice.id,
direction: 'OUT',
date: saleInvoice.invoiceDate,
entryId: entry.id,
})
);
return this.inventoryService.recordInventoryTransactions( // Loads the inventory items entries of the given sale invoice.
const inventoryEntries = await this.itemsEntriesService.getInventoryEntries(
tenantId, tenantId,
invTransactions, 'SaleInvoice',
saleInvoiceId
);
// Can't continue if there is no entries has inventory items in the invoice.
if (inventoryEntries.length <= 0) return;
// Inventory transactions.
const inventoryTranscations = this.inventoryService.transformItemEntriesToInventory(
inventoryEntries,
'SaleInvoice',
saleInvoiceId,
'OUT',
saleInvoiceDate,
lotNumber
);
// Records the inventory transactions of the given sale invoice.
await this.inventoryService.recordInventoryTransactions(
tenantId,
inventoryTranscations,
override override
); );
// Increment and save the next lot number settings.
await this.inventoryService.incrementNextLotNumber(tenantId);
// Triggers `onInventoryTransactionsCreated` event.
await this.eventDispatcher.dispatch(
events.saleInvoice.onInventoryTransactionsCreated,
{
tenantId,
saleInvoiceId,
}
);
}
/**
* Records the journal entries of the given sale invoice just
* in case the invoice has no inventory items entries.
*
* @param {number} tenantId -
* @param {number} saleInvoiceId
* @param {boolean} override
* @return {Promise<void>}
*/
public async recordNonInventoryJournalEntries(
tenantId: number,
saleInvoiceId: number,
override: boolean = false
): Promise<void> {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Loads the inventory items entries of the given sale invoice.
const inventoryEntries = await this.itemsEntriesService.getInventoryEntries(
tenantId,
'SaleInvoice',
saleInvoiceId
);
// Can't continue if the sale invoice has inventory items entries.
if (inventoryEntries.length > 0) return;
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.withGraphFetched('entries.item');
await this.writeNonInventoryInvoiceEntries(tenantId, saleInvoice, override);
} }
/** /**
* Reverting the inventory transactions once the invoice deleted. * Reverting the inventory transactions once the invoice deleted.
* @param {number} tenantId - Tenant id. * @param {number} tenantId - Tenant id.
* @param {number} billId - Bill id. * @param {number} billId - Bill id.
* @return {Promise<void>}
*/ */
public revertInventoryTransactions( public async revertInventoryTransactions(
tenantId: number, tenantId: number,
billId: number saleInvoiceId: number
): Promise<void> { ): Promise<void> {
return this.inventoryService.deleteInventoryTransactions( await this.inventoryService.deleteInventoryTransactions(
tenantId, tenantId,
billId, saleInvoiceId,
'SaleInvoice' 'SaleInvoice'
); );
} // Triggers 'onInventoryTransactionsDeleted' event.
this.eventDispatcher.dispatch(
/** events.saleInvoice.onInventoryTransactionsDeleted,
* Deletes the inventory transactions. { tenantId, saleInvoiceId },
* @param {string} transactionType );
* @param {number} transactionId
*/
private async revertInventoryTransactions_(
tenantId: number,
inventoryTransactions: array
) {
const { InventoryTransaction } = this.tenancy.models(tenantId);
const opers: Promise<[]>[] = [];
this.logger.info('[sale_invoice] reverting inventory transactions');
inventoryTransactions.forEach((trans: any) => {
switch (trans.direction) {
case 'OUT':
if (trans.inventoryTransactionId) {
const revertRemaining = InventoryTransaction.query()
.where('id', trans.inventoryTransactionId)
.where('direction', 'OUT')
.increment('remaining', trans.quanitity);
opers.push(revertRemaining);
}
break;
case 'IN':
const removeRelationOper = InventoryTransaction.query()
.where('inventory_transaction_id', trans.id)
.where('direction', 'IN')
.update({
inventory_transaction_id: null,
});
opers.push(removeRelationOper);
break;
}
});
return Promise.all(opers);
} }
/** /**
@@ -480,63 +495,29 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
saleInvoiceId: number, saleInvoiceId: number,
override?: boolean override?: boolean
) { ) {
const { SaleInvoice } = this.tenancy.models(tenantId); const { SaleInvoice, Item } = this.tenancy.models(tenantId);
// Retrieve the sale invoice with associated entries.
const saleInvoice: ISaleInvoice = await SaleInvoice.query() const saleInvoice: ISaleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId) .findById(saleInvoiceId)
.withGraphFetched('entries.item'); .withGraphFetched('entries');
const inventoryItemsIds = chain(saleInvoice.entries) // Retrieve the inventory items that associated to the sale invoice entries.
.filter((entry: IItemEntry) => entry.item.type === 'inventory') const inventoryItems = await Item.query()
.map((entry: IItemEntry) => entry.itemId) .whereIn('id', map(saleInvoice.entries, 'itemId'))
.uniq() .where('type', 'inventory');
.value();
if (inventoryItemsIds.length === 0) { const inventoryItemsIds = map(inventoryItems, 'id');
await this.writeNonInventoryInvoiceJournals(
tenantId, if (inventoryItemsIds.length > 0) {
saleInvoice,
override
);
} else {
await this.scheduleComputeItemsCost( await this.scheduleComputeItemsCost(
tenantId, tenantId,
inventoryItemsIds, inventoryItemsIds,
saleInvoice.invoice_date saleInvoice.invoiceDate
); );
} }
} }
/**
* Writes the sale invoice journal entries.
* @param {SaleInvoice} saleInvoice -
*/
async writeNonInventoryInvoiceJournals(
tenantId: number,
saleInvoice: ISaleInvoice,
override: boolean
) {
const { AccountTransaction } = this.tenancy.models(tenantId);
const journal = new JournalPoster(tenantId);
if (override) {
const oldTransactions = await AccountTransaction.query()
.where('reference_type', 'SaleInvoice')
.where('reference_id', saleInvoice.id)
.withGraphFetched('account.type');
journal.loadEntries(oldTransactions);
journal.removeEntries();
}
this.saleInvoiceJournal(saleInvoice, journal);
await Promise.all([
journal.deleteEntries(),
journal.saveEntries(),
journal.saveBalance(),
]);
}
/** /**
* Retrieve sales invoices filterable and paginated list. * Retrieve sales invoices filterable and paginated list.
* @param {Request} req * @param {Request} req

View File

@@ -3,7 +3,8 @@ import JournalPoster from 'services/Accounting/JournalPoster';
import JournalEntry from 'services/Accounting/JournalEntry'; import JournalEntry from 'services/Accounting/JournalEntry';
import InventoryService from 'services/Inventory/Inventory'; import InventoryService from 'services/Inventory/Inventory';
import TenancyService from 'services/Tenancy/TenancyService'; import TenancyService from 'services/Tenancy/TenancyService';
import { ISaleInvoice, IItemEntry } from 'interfaces'; import { ISaleInvoice, IItemEntry, IInventoryLotCost, IItem } from 'interfaces';
import JournalCommands from 'services/Accounting/JournalCommands';
@Service() @Service()
export default class SaleInvoicesCost { export default class SaleInvoicesCost {
@@ -45,6 +46,7 @@ export default class SaleInvoicesCost {
*/ */
scheduleWriteJournalEntries(tenantId: number, startingDate?: Date) { scheduleWriteJournalEntries(tenantId: number, startingDate?: Date) {
const agenda = Container.get('agenda'); const agenda = Container.get('agenda');
return agenda.schedule('in 3 seconds', 'rewrite-invoices-journal-entries', { return agenda.schedule('in 3 seconds', 'rewrite-invoices-journal-entries', {
startingDate, tenantId, startingDate, tenantId,
}); });
@@ -58,16 +60,23 @@ export default class SaleInvoicesCost {
*/ */
async writeJournalEntries(tenantId: number, startingDate: Date, override: boolean) { async writeJournalEntries(tenantId: number, startingDate: Date, override: boolean) {
const { AccountTransaction, SaleInvoice, Account } = this.tenancy.models(tenantId); const { AccountTransaction, SaleInvoice, Account } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
const receivableAccount = await accountRepository.findOne({
slug: 'accounts-receivable',
});
const salesInvoices = await SaleInvoice.query() const salesInvoices = await SaleInvoice.query()
.onBuild((builder: any) => { .onBuild((builder: any) => {
builder.modify('filterDateRange', startingDate); builder.modify('filterDateRange', startingDate);
builder.orderBy('invoice_date', 'ASC'); builder.orderBy('invoice_date', 'ASC');
builder.withGraphFetched('entries.item') builder.withGraphFetched('entries.item');
builder.withGraphFetched('costTransactions(groupedEntriesCost)'); builder.withGraphFetched('costTransactions(groupedEntriesCost)');
}); });
const accountsDepGraph = await Account.depGraph().query(); const accountsDepGraph = await accountRepository.getDependencyGraph();
const journal = new JournalPoster(accountsDepGraph); const journal = new JournalPoster(tenantId, accountsDepGraph);
const journalCommands = new JournalCommands(journal);
if (override) { if (override) {
const oldTransactions = await AccountTransaction.query() const oldTransactions = await AccountTransaction.query()
@@ -77,12 +86,14 @@ export default class SaleInvoicesCost {
}) })
.withGraphFetched('account.type'); .withGraphFetched('account.type');
journal.loadEntries(oldTransactions); journal.fromTransactions(oldTransactions);
journal.removeEntries(); journal.removeEntries();
} }
salesInvoices.forEach((saleInvoice: ISaleInvoice & {
salesInvoices.forEach((saleInvoice: ISaleInvoice) => { costTransactions: IInventoryLotCost[],
this.saleInvoiceJournal(saleInvoice, journal); entries: IItemEntry & { item: IItem },
}) => {
journalCommands.saleInvoice(saleInvoice, receivableAccount.id);
}); });
return Promise.all([ return Promise.all([
journal.deleteEntries(), journal.deleteEntries(),
@@ -92,64 +103,39 @@ export default class SaleInvoicesCost {
} }
/** /**
* Writes journal entries for given sale invoice. * Writes the sale invoice journal entries.
* @param {ISaleInvoice} saleInvoice * @param {SaleInvoice} saleInvoice -
* @param {JournalPoster} journal
*/ */
saleInvoiceJournal(saleInvoice: ISaleInvoice, journal: JournalPoster) { async writeNonInventoryInvoiceEntries(
let inventoryTotal: number = 0; tenantId: number,
const receivableAccount = { id: 10 }; saleInvoice: ISaleInvoice,
const commonEntry = { override: boolean
referenceType: 'SaleInvoice', ) {
referenceId: saleInvoice.id, const { accountRepository } = this.tenancy.repositories(tenantId);
date: saleInvoice.invoiceDate, const { AccountTransaction } = this.tenancy.models(tenantId);
};
const costTransactions: Map<number, number> = new Map(
saleInvoice?.costTransactions?.map((trans: IItemEntry) => [
trans.entryId, trans.cost,
]),
);
// XXX Debit - Receivable account.
const receivableEntry = new JournalEntry({
...commonEntry,
debit: saleInvoice.balance,
account: receivableAccount.id,
});
journal.debit(receivableEntry);
saleInvoice.entries.forEach((entry: IItemEntry) => { // Receivable account.
const cost: number = costTransactions.get(entry.id); const receivableAccount = await accountRepository.findOne({
const income: number = entry.quantity * entry.rate; slug: 'accounts-receivable',
if (entry.item.type === 'inventory' && cost) {
// XXX Debit - Cost account.
const costEntry = new JournalEntry({
...commonEntry,
debit: cost,
account: entry.item.costAccountId,
note: entry.description,
});
journal.debit(costEntry);
inventoryTotal += cost;
}
// XXX Credit - Income account.
const incomeEntry = new JournalEntry({
...commonEntry,
credit: income,
account: entry.item.sellAccountId,
note: entry.description,
});
journal.credit(incomeEntry);
if (inventoryTotal > 0) {
// XXX Credit - Inventory account.
const inventoryEntry = new JournalEntry({
...commonEntry,
credit: inventoryTotal,
account: entry.item.inventoryAccountId,
});
journal.credit(inventoryEntry);
}
}); });
const journal = new JournalPoster(tenantId);
const journalCommands = new JournalCommands(journal);
if (override) {
const oldTransactions = await AccountTransaction.query()
.where('reference_type', 'SaleInvoice')
.where('reference_id', saleInvoice.id)
.withGraphFetched('account.type');
journal.fromTransactions(oldTransactions);
journal.removeEntries();
}
journalCommands.saleInvoiceNonInventory(saleInvoice, receivableAccount.id);
await Promise.all([
journal.deleteEntries(),
journal.saveEntries(),
journal.saveBalance(),
]);
} }
} }

View File

@@ -117,7 +117,8 @@ export default class BillSubscriber {
this.logger.info('[bill] writing the inventory transactions', { tenantId }); this.logger.info('[bill] writing the inventory transactions', { tenantId });
this.billsService.recordInventoryTransactions( this.billsService.recordInventoryTransactions(
tenantId, tenantId,
bill, bill.id,
bill.billDate,
); );
} }
@@ -129,7 +130,8 @@ export default class BillSubscriber {
this.logger.info('[bill] overwriting the inventory transactions.', { tenantId }); this.logger.info('[bill] overwriting the inventory transactions.', { tenantId });
this.billsService.recordInventoryTransactions( this.billsService.recordInventoryTransactions(
tenantId, tenantId,
bill, bill.id,
bill.billDate,
true, true,
); );
} }
@@ -145,4 +147,19 @@ export default class BillSubscriber {
billId, billId,
); );
} }
/**
* Schedules items cost compute jobs once the inventory transactions created
* of the bill transaction.
*/
@On(events.bill.onInventoryTransactionsCreated)
public async handleComputeItemsCosts({ tenantId, billId }) {
this.logger.info('[bill] trying to compute the bill items cost.', {
tenantId, billId,
});
await this.billsService.scheduleComputeBillItemsCost(
tenantId,
billId,
);
}
} }

View File

@@ -82,6 +82,8 @@ export default {
onDeleted: 'onSaleInvoiceDeleted', onDeleted: 'onSaleInvoiceDeleted',
onBulkDelete: 'onSaleInvoiceBulkDeleted', onBulkDelete: 'onSaleInvoiceBulkDeleted',
onPublished: 'onSaleInvoicePublished', onPublished: 'onSaleInvoicePublished',
onInventoryTransactionsCreated: 'onInvoiceInventoryTransactionsCreated',
onInventoryTransactionsDeleted: 'onInvoiceInventoryTransactionsDeleted',
}, },
/** /**
@@ -125,6 +127,7 @@ export default {
onDeleted: 'onBillDeleted', onDeleted: 'onBillDeleted',
onBulkDeleted: 'onBillBulkDeleted', onBulkDeleted: 'onBillBulkDeleted',
onPublished: 'onBillPublished', onPublished: 'onBillPublished',
onInventoryTransactionsCreated: 'onBillInventoryTransactionsCreated'
}, },
/** /**
@@ -165,5 +168,14 @@ export default {
onEdited: 'onItemEdited', onEdited: 'onItemEdited',
onDeleted: 'onItemDeleted', onDeleted: 'onItemDeleted',
onBulkDeleted: 'onItemBulkDeleted', onBulkDeleted: 'onItemBulkDeleted',
},
/**
* Inventory service.
*/
inventory: {
onComputeItemCostJobScheduled: 'onComputeItemCostJobScheduled',
onComputeItemCostJobStarted: 'onComputeItemCostJobStarted',
onComputeItemCostJobCompleted: 'onComputeItemCostJobCompleted'
} }
} }

View File

@@ -0,0 +1,34 @@
import { Container } from 'typedi';
import { EventSubscriber, On } from 'event-dispatch';
import events from 'subscribers/events';
import SaleInvoicesCost from 'services/Sales/SalesInvoicesCost';
@EventSubscriber()
export class InventorySubscriber {
depends: number = 0;
startingDate: Date;
/**
* Handle run writing the journal entries once the compute items jobs completed.
*/
@On(events.inventory.onComputeItemCostJobCompleted)
async onComputeItemCostJobFinished({ itemId, tenantId, startingDate }) {
const saleInvoicesCost = Container.get(SaleInvoicesCost);
const agenda = Container.get('agenda');
const dependsComputeJobs = await agenda.jobs({
name: 'compute-item-cost',
nextRunAt: { $ne: null },
'data.tenantId': tenantId,
});
// There is no scheduled compute jobs waiting.
if (dependsComputeJobs.length === 0) {
this.startingDate = null;
await saleInvoicesCost.scheduleWriteJournalEntries(
tenantId,
startingDate
);
}
}
}

View File

@@ -60,6 +60,64 @@ export default class SaleInvoiceSubscriber {
} }
} }
/**
* Handles sale invoice next number increment once invoice created.
*/
@On(events.saleInvoice.onCreated)
public async handleInvoiceNextNumberIncrement({
tenantId,
saleInvoiceId,
saleInvoice,
}) {
await this.settingsService.incrementNextNumber(tenantId, {
key: 'next_number',
group: 'sales_invoices',
});
}
/**
* Handles the writing inventory transactions once the invoice created.
*/
@On(events.saleInvoice.onCreated)
public async handleWritingInventoryTransactions({ tenantId, saleInvoice }) {
this.logger.info('[sale_invoice] trying to write inventory transactions.', {
tenantId,
});
await this.saleInvoicesService.recordInventoryTranscactions(
tenantId,
saleInvoice.id,
saleInvoice.invoiceDate,
);
}
/**
* Records journal entries of the non-inventory invoice.
*/
@On(events.saleInvoice.onCreated)
@On(events.saleInvoice.onEdited)
public async handleWritingNonInventoryEntries({ tenantId, saleInvoice }) {
await this.saleInvoicesService.recordNonInventoryJournalEntries(
tenantId,
saleInvoice.id,
);
}
/**
*
*/
@On(events.saleInvoice.onEdited)
public async handleRewritingInventoryTransactions({ tenantId, saleInvoice }) {
this.logger.info('[sale_invoice] trying to write inventory transactions.', {
tenantId,
});
await this.saleInvoicesService.recordInventoryTranscactions(
tenantId,
saleInvoice.id,
saleInvoice.invoiceDate,
true,
);
}
/** /**
* Handles customer balance diff balnace change once sale invoice edited. * Handles customer balance diff balnace change once sale invoice edited.
*/ */
@@ -103,35 +161,6 @@ export default class SaleInvoiceSubscriber {
); );
} }
/**
* Handles sale invoice next number increment once invoice created.
*/
@On(events.saleInvoice.onCreated)
public async handleInvoiceNextNumberIncrement({
tenantId,
saleInvoiceId,
saleInvoice,
}) {
await this.settingsService.incrementNextNumber(tenantId, {
key: 'next_number',
group: 'sales_invoices',
});
}
/**
* Handles the writing inventory transactions once the invoice created.
*/
@On(events.saleInvoice.onCreated)
public async handleWritingInventoryTransactions({ tenantId, saleInvoice }) {
this.logger.info('[sale_invoice] trying to write inventory transactions.', {
tenantId,
});
await this.saleInvoicesService.recordInventoryTranscactions(
tenantId,
saleInvoice,
);
}
/** /**
* Handles deleting the inventory transactions once the invoice deleted. * Handles deleting the inventory transactions once the invoice deleted.
*/ */
@@ -145,4 +174,18 @@ export default class SaleInvoiceSubscriber {
saleInvoiceId, saleInvoiceId,
); );
} }
/**
* Schedules compute invoice items cost job.
*/
@On(events.saleInvoice.onInventoryTransactionsCreated)
public async handleComputeItemsCosts({ tenantId, saleInvoiceId }) {
this.logger.info('[sale_invoice] trying to compute the invoice items cost.', {
tenantId, saleInvoiceId,
});
await this.saleInvoicesService.scheduleComputeInvoiceItemsCost(
tenantId,
saleInvoiceId,
);
}
} }