feat: Concurrency control items cost compute.

This commit is contained in:
Ahmed Bouhuolia
2020-08-23 23:38:42 +02:00
parent 45088b2d3b
commit ab6bc0517f
28 changed files with 463 additions and 341 deletions

View File

@@ -6,6 +6,12 @@ exports.up = function (knex) {
table.integer('parent_category_id').unsigned(); table.integer('parent_category_id').unsigned();
table.text('description'); table.text('description');
table.integer('user_id').unsigned(); table.integer('user_id').unsigned();
table.integer('cost_account_id').unsigned();
table.integer('sell_account_id').unsigned();
table.integer('inventory_account_id').unsigned();
table.string('cost_method');
table.timestamps(); table.timestamps();
}); });
}; };

View File

@@ -13,8 +13,9 @@ exports.up = function(knex) {
table.integer('lot_number'); table.integer('lot_number');
table.string('transaction_type'); table.string('transaction_type');
table.integer('transaction_id'); table.integer('transaction_id').unsigned();
table.integer('entry_id').unsigned();
table.timestamps(); table.timestamps();
}); });
}; };

View File

@@ -3,17 +3,18 @@ exports.up = function(knex) {
return knex.schema.createTable('inventory_cost_lot_tracker', table => { return knex.schema.createTable('inventory_cost_lot_tracker', table => {
table.increments(); table.increments();
table.date('date'); table.date('date');
table.string('direction'); table.string('direction');
table.integer('item_id').unsigned(); table.integer('item_id').unsigned();
table.integer('quantity').unsigned(); table.integer('quantity').unsigned();
table.decimal('rate', 13, 3); table.decimal('rate', 13, 3);
table.integer('remaining'); table.integer('remaining');
table.integer('cost');
table.integer('lot_number'); table.integer('lot_number');
table.string('transaction_type'); table.string('transaction_type');
table.integer('transaction_id'); table.integer('transaction_id').unsigned();
table.integer('entry_id').unsigned();
}); });
}; };

View File

@@ -90,6 +90,9 @@ export default {
} }
const lastLoginAt = moment().format('YYYY/MM/DD HH:mm:ss'); const lastLoginAt = moment().format('YYYY/MM/DD HH:mm:ss');
const tenantDb = TenantsManager.knexInstance(user.tenant.organizationId);
TenantModel.knexBinded = tenantDb;
const updateTenantUser = TenantUser.tenant().query() const updateTenantUser = TenantUser.tenant().query()
.where('id', user.id) .where('id', user.id)
.update({ last_login_at: lastLoginAt }); .update({ last_login_at: lastLoginAt });

View File

@@ -1,4 +1,5 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { Container } from 'typedi';
export default class Ping { export default class Ping {
/** /**

View File

@@ -26,7 +26,6 @@ import Ping from '@/http/controllers/Ping';
import Agendash from '@/http/controllers/Agendash'; import Agendash from '@/http/controllers/Agendash';
export default (app) => { export default (app) => {
// app.use('/api/oauth2', OAuth2.router());
app.use('/api/auth', Authentication.router()); app.use('/api/auth', Authentication.router());
app.use('/api/invite', InviteUsers.router()); app.use('/api/invite', InviteUsers.router());

View File

@@ -0,0 +1,7 @@
export interface IItem{
id: number,
name: string,
type: string,
}

View File

@@ -2,5 +2,5 @@
interface IInventoryCostMethod { interface IInventoryCostMethod {
computeItemsCost(fromDate: Date): void, computeItemsCost(fromDate: Date): void,
initialize(): void, storeInventoryLotsCost(transactions: any[]): void,
} }

View File

@@ -0,0 +1,14 @@
export interface IItemEntry {
referenceType: string,
referenceId: number,
index: number,
itemId: number,
description: string,
discount: number,
quantity: number,
rate: number,
}

View File

@@ -0,0 +1,8 @@
export interface ISaleInvoice {
id: number,
balance: number,
invoiceDate: Date,
entries: [],
}

View File

@@ -1,6 +1,8 @@
import { IInventoryTransaction, IInventoryLotCost } from './InventoryTransaction'; import { IInventoryTransaction, IInventoryLotCost } from './InventoryTransaction';
import { IBillPaymentEntry, IBillPayment } from './BillPayment'; import { IBillPaymentEntry, IBillPayment } from './BillPayment';
import { IInventoryCostMethod } from './IInventoryCostMethod'; import { IInventoryCostMethod } from './IInventoryCostMethod';
import { IItemEntry } from './ItemEntry';
import { IItem } from './Item';
export { export {
IBillPaymentEntry, IBillPaymentEntry,
@@ -8,4 +10,6 @@ export {
IInventoryTransaction, IInventoryTransaction,
IInventoryLotCost, IInventoryLotCost,
IInventoryCostMethod, IInventoryCostMethod,
IItemEntry
IItem,
}; };

View File

@@ -6,9 +6,11 @@ export default class ComputeItemCostJob {
const Logger = Container.get('logger'); const Logger = Container.get('logger');
const { startingDate, itemId, costMethod = 'FIFO' } = job.attrs.data; const { startingDate, itemId, costMethod = 'FIFO' } = job.attrs.data;
Logger.debug(`Compute item cost - started: ${job.attrs.data}`);
try { try {
await InventoryService.computeItemCost(startingDate, itemId, costMethod); await InventoryService.computeItemCost(startingDate, itemId, costMethod);
Logger.debug(`Compute item cost: ${job.attrs.data}`); Logger.debug(`Compute item cost - completed: ${job.attrs.data}`);
done(); done();
} catch(e) { } catch(e) {
console.log(e); console.log(e);

View File

@@ -0,0 +1,22 @@
import { Container } from 'typedi';
import SalesInvoicesCost from '@/services/Sales/SalesInvoicesCost';
export default class WriteInvoicesJournalEntries {
public async handler(job, done: Function): Promise<void> {
const Logger = Container.get('logger');
const { startingDate } = job.attrs.data;
Logger.debug(`Write sales invoices journal entries - started: ${job.attrs.data}`);
try {
await SalesInvoicesCost.writeJournalEntries(startingDate, true);
Logger.debug(`Write sales invoices journal entries - completed: ${job.attrs.data}`);
done();
} catch(e) {
console.log(e);
Logger.error(`Write sales invoices journal entries: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -1,6 +1,7 @@
import Agenda from 'agenda'; import Agenda from 'agenda';
import WelcomeEmailJob from '@/Jobs/welcomeEmail'; import WelcomeEmailJob from '@/Jobs/welcomeEmail';
import ComputeItemCost from '@/Jobs/ComputeItemCost'; import ComputeItemCost from '@/Jobs/ComputeItemCost';
import RewriteInvoicesJournalEntries from '@/jobs/writeInvoicesJEntries';
export default ({ agenda }: { agenda: Agenda }) => { export default ({ agenda }: { agenda: Agenda }) => {
agenda.define( agenda.define(
@@ -10,8 +11,13 @@ export default ({ agenda }: { agenda: Agenda }) => {
); );
agenda.define( agenda.define(
'compute-item-cost', 'compute-item-cost',
{ priority: 'high' }, { priority: 'high', concurrency: 20 },
new ComputeItemCost().handler, new ComputeItemCost().handler,
); );
agenda.define(
'rewrite-invoices-journal-entries',
{ priority: 'normal', concurrency: 1, },
new RewriteInvoicesJournalEntries().handler,
);
agenda.start(); agenda.start();
}; };

View File

@@ -22,6 +22,16 @@ export default class InventoryCostLotTracker extends TenantModel {
*/ */
static get modifiers() { static get modifiers() {
return { return {
groupedEntriesCost(query) {
query.select(['entry_id', 'transaction_id', 'transaction_type']);
query.groupBy('item_id');
query.groupBy('entry_id');
query.groupBy('transaction_id');
query.groupBy('transaction_type');
query.sum('cost as cost');
},
filterDateRange(query, startDate, endDate, type = 'day') { filterDateRange(query, startDate, endDate, type = 'day') {
const dateFormat = 'YYYY-MM-DD HH:mm:ss'; const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const fromDate = moment(startDate).startOf(type).format(dateFormat); const fromDate = moment(startDate).startOf(type).format(dateFormat);

View File

@@ -17,7 +17,6 @@ export default class InventoryTransaction extends TenantModel {
return ['createdAt', 'updatedAt']; return ['createdAt', 'updatedAt'];
} }
/** /**
* Model modifiers. * Model modifiers.
*/ */
@@ -38,7 +37,6 @@ export default class InventoryTransaction extends TenantModel {
}; };
} }
/** /**
* Relationship mapping. * Relationship mapping.
*/ */

View File

@@ -32,4 +32,19 @@ export default class ItemEntry extends TenantModel {
return discount ? total - (total * discount * 0.01) : total; return discount ? total - (total * discount * 0.01) : total;
} }
static get relationMappings() {
const Item = require('@/models/Item');
return {
item: {
relation: Model.BelongsToOneRelation,
modelClass: this.relationBindKnex(Item.default),
join: {
from: 'items_entries.itemId',
to: 'items.id',
},
},
};
}
} }

View File

@@ -3,6 +3,7 @@ import moment from 'moment';
import TenantModel from '@/models/TenantModel'; import TenantModel from '@/models/TenantModel';
import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder'; import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder';
import CachableModel from '@/lib/Cachable/CachableModel'; import CachableModel from '@/lib/Cachable/CachableModel';
import InventoryCostLotTracker from './InventoryCostLotTracker';
export default class SaleInvoice extends mixin(TenantModel, [CachableModel]) { export default class SaleInvoice extends mixin(TenantModel, [CachableModel]) {
/** /**
@@ -26,6 +27,26 @@ export default class SaleInvoice extends mixin(TenantModel, [CachableModel]) {
return ['created_at', 'updated_at']; return ['created_at', 'updated_at'];
} }
/**
* Model modifiers.
*/
static get modifiers() {
return {
filterDateRange(query, startDate, endDate, type = 'day') {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const fromDate = moment(startDate).startOf(type).format(dateFormat);
const toDate = moment(endDate).endOf(type).format(dateFormat);
if (startDate) {
query.where('invoice_date', '>=', fromDate);
}
if (endDate) {
query.where('invoice_date', '<=', toDate);
}
},
};
}
/** /**
* Due amount of the given. * Due amount of the given.
*/ */
@@ -40,6 +61,7 @@ export default class SaleInvoice extends mixin(TenantModel, [CachableModel]) {
const AccountTransaction = require('@/models/AccountTransaction'); const AccountTransaction = require('@/models/AccountTransaction');
const ItemEntry = require('@/models/ItemEntry'); const ItemEntry = require('@/models/ItemEntry');
const Customer = require('@/models/Customer'); const Customer = require('@/models/Customer');
const InventoryCostLotTracker = require('@/models/InventoryCostLotTracker');
return { return {
entries: { entries: {
@@ -73,6 +95,18 @@ export default class SaleInvoice extends mixin(TenantModel, [CachableModel]) {
filter(builder) { filter(builder) {
builder.where('reference_type', 'SaleInvoice'); builder.where('reference_type', 'SaleInvoice');
}, },
},
costTransactions: {
relation: Model.HasManyRelation,
modelClass: this.relationBindKnex(InventoryCostLotTracker.default),
join: {
from: 'sales_invoices.id',
to: 'inventory_cost_lot_tracker.transactionId'
},
filter(builder) {
builder.where('transaction_type', 'SaleInvoice');
},
} }
}; };
} }

View File

@@ -13,13 +13,22 @@ export default class InventoryService {
/** /**
* 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.
* @param {Date} fromDate * @param {Date} fromDate -
* @param {number} itemId * @param {number} itemId -
*/ */
static async computeItemCost(fromDate: Date, itemId: number) { static async computeItemCost(fromDate: Date, itemId: number) {
const costMethod: TCostMethod = 'FIFO'; const item = await Item.tenant().query()
.findById(itemId)
.withGraphFetched('category');
// Cannot continue if the given item was not inventory item.
if (item.type !== 'inventory') {
throw new Error('You could not compute item cost has no inventory type.');
}
const costMethod: TCostMethod = item.category.costMethod;
let costMethodComputer: IInventoryCostMethod; let costMethodComputer: IInventoryCostMethod;
// Switch between methods based on the item cost method.
switch(costMethod) { switch(costMethod) {
case 'FIFO': case 'FIFO':
case 'LIFO': case 'LIFO':
@@ -27,10 +36,9 @@ export default class InventoryService {
break; break;
case 'AVG': case 'AVG':
costMethodComputer = new InventoryAverageCost(fromDate, itemId); costMethodComputer = new InventoryAverageCost(fromDate, itemId);
break break;
} }
await costMethodComputer.initialize(); return costMethodComputer.computeItemCost();
await costMethodComputer.computeItemCost()
} }
/** /**
@@ -41,10 +49,6 @@ export default class InventoryService {
static async scheduleComputeItemCost(itemId: number, startingDate: Date|string) { static async scheduleComputeItemCost(itemId: number, startingDate: Date|string) {
const agenda = Container.get('agenda'); const agenda = Container.get('agenda');
// Delete the scheduled job in case has the same given data.
await agenda.cancel({
name: 'compute-item-cost',
});
return agenda.schedule('in 3 seconds', 'compute-item-cost', { return agenda.schedule('in 3 seconds', 'compute-item-cost', {
startingDate, itemId, startingDate, itemId,
}); });

View File

@@ -1,36 +1,27 @@
import { Account, InventoryTransaction } from '@/models'; import { pick } from 'lodash';
import { InventoryTransaction } from '@/models';
import { IInventoryTransaction } from '@/interfaces'; import { IInventoryTransaction } from '@/interfaces';
import JournalPoster from '@/services/Accounting/JournalPoster'; import InventoryCostMethod from '@/services/Inventory/InventoryCostMethod';
import JournalCommands from '@/services/Accounting/JournalCommands';
export default class InventoryAverageCostMethod implements IInventoryCostMethod { export default class InventoryAverageCostMethod extends InventoryCostMethod implements IInventoryCostMethod {
journal: JournalPoster; startingDate: Date;
journalCommands: JournalCommands;
fromDate: Date;
itemId: number; itemId: number;
costTransactions: any[];
/** /**
* Constructor method. * Constructor method.
* @param {Date} fromDate - * @param {Date} startingDate -
* @param {number} itemId - * @param {number} itemId -
*/ */
constructor( constructor(
fromDate: Date, startingDate: Date,
itemId: number, itemId: number,
) { ) {
this.fromDate = fromDate; super();
this.startingDate = startingDate;
this.itemId = itemId; this.itemId = itemId;
} this.costTransactions = [];
/**
* Initialize the inventory average cost method.
* @async
*/
async initialize() {
const accountsDepGraph = await Account.tenant().depGraph().query();
this.journal = new JournalPoster(accountsDepGraph);
this.journalCommands = new JournalCommands(this.journal);
} }
/** /**
@@ -43,50 +34,41 @@ export default class InventoryAverageCostMethod implements IInventoryCostMethod
* after the given date. * after the given date.
* ---------- * ----------
* @asycn * @asycn
* @param {Date} fromDate * @param {Date} startingDate
* @param {number} referenceId * @param {number} referenceId
* @param {string} referenceType * @param {string} referenceType
*/ */
public async computeItemCost() { public async computeItemCost() {
const openingAvgCost = await this.getOpeningAvaregeCost(this.fromDate, this.itemId); const openingAvgCost = await this.getOpeningAvaregeCost(this.startingDate, this.itemId);
// @todo from `invTransactions`.
const afterInvTransactions: IInventoryTransaction[] = await InventoryTransaction const afterInvTransactions: IInventoryTransaction[] = await InventoryTransaction
.tenant() .tenant()
.query() .query()
.where('date', '>=', this.fromDate) .modify('filterDateRange', this.startingDate)
// .where('direction', 'OUT') .orderBy('date', 'ASC')
.orderBy('date', 'asc') .orderByRaw("FIELD(direction, 'IN', 'OUT')")
.where('item_id', this.itemId)
.withGraphFetched('item'); .withGraphFetched('item');
// Remove and revert accounts balance journal entries from // Tracking inventroy transactions and retrieve cost transactions
// inventory transactions. // based on average rate cost method.
await this.journalCommands const costTransactions = this.trackingCostTransactions(
.revertEntriesFromInventoryTransactions(afterInvTransactions);
// Re-write the journal entries from the new recorded inventory transactions.
await this.jEntriesFromItemInvTransactions(
afterInvTransactions, afterInvTransactions,
openingAvgCost, openingAvgCost,
); );
// Saves the new recorded journal entries to the storage. await this.storeInventoryLotsCost(costTransactions);
await Promise.all([
this.journal.deleteEntries(),
this.journal.saveEntries(),
this.journal.saveBalance(),
]);
} }
/** /**
* Get items Avarege cost from specific date from inventory transactions. * Get items Avarege cost from specific date from inventory transactions.
* @static * @static
* @param {Date} fromDate * @param {Date} startingDate
* @return {number} * @return {number}
*/ */
public async getOpeningAvaregeCost(fromDate: Date, itemId: number) { public async getOpeningAvaregeCost(startingDate: Date, itemId: number) {
const commonBuilder = (builder: any) => { const commonBuilder = (builder: any) => {
if (fromDate) { if (startingDate) {
builder.where('date', '<', fromDate); builder.where('date', '<', startingDate);
} }
builder.where('item_id', itemId); builder.where('item_id', itemId);
builder.groupBy('rate'); builder.groupBy('rate');
@@ -155,53 +137,45 @@ export default class InventoryAverageCostMethod implements IInventoryCostMethod
* @param {number} referenceId * @param {number} referenceId
* @param {JournalCommand} journalCommands * @param {JournalCommand} journalCommands
*/ */
async jEntriesFromItemInvTransactions( public trackingCostTransactions(
invTransactions: IInventoryTransaction[], invTransactions: IInventoryTransaction[],
openingAverageCost: number, openingAverageCost: number,
) { ) {
const transactions: any[] = []; const costTransactions: any[] = [];
let accQuantity: number = 0; let accQuantity: number = 0;
let accCost: number = 0; let accCost: number = 0;
invTransactions.forEach((invTransaction: IInventoryTransaction) => { invTransactions.forEach((invTransaction: IInventoryTransaction) => {
const commonEntry = { const commonEntry = {
date: invTransaction.date, invTransId: invTransaction.id,
referenceType: invTransaction.transactionType, ...pick(invTransaction, ['date', 'direction', 'itemId', 'quantity', 'rate', 'entryId',
referenceId: invTransaction.transactionId, 'transactionId', 'transactionType']),
}; };
switch(invTransaction.direction) { switch(invTransaction.direction) {
case 'IN': case 'IN':
accQuantity += invTransaction.quantity; accQuantity += invTransaction.quantity;
accCost += invTransaction.rate * invTransaction.quantity; accCost += invTransaction.rate * invTransaction.quantity;
const inventory = invTransaction.quantity * invTransaction.rate; costTransactions.push({
transactions.push({
...commonEntry, ...commonEntry,
inventory,
inventoryAccount: invTransaction.item.inventoryAccountId,
}); });
break; break;
case 'OUT': case 'OUT':
const income = invTransaction.quantity * invTransaction.rate;
const transactionAvgCost = accCost ? (accCost / accQuantity) : 0; const transactionAvgCost = accCost ? (accCost / accQuantity) : 0;
const averageCost = transactionAvgCost; const averageCost = transactionAvgCost;
const cost = (invTransaction.quantity * averageCost); const cost = (invTransaction.quantity * averageCost);
const income = (invTransaction.quantity * invTransaction.rate);
accQuantity -= invTransaction.quantity; accQuantity -= invTransaction.quantity;
accCost -= accCost; accCost -= income;
transactions.push({ costTransactions.push({
...commonEntry, ...commonEntry,
income,
cost, cost,
incomeAccount: invTransaction.item.sellAccountId,
costAccount: invTransaction.item.costAccountId,
inventoryAccount: invTransaction.item.inventoryAccountId,
}); });
break; break;
} }
}); });
this.journalCommands.inventoryEntries(transactions); return costTransactions;
} }
} }

View File

@@ -3,20 +3,15 @@ import moment from 'moment';
import { import {
InventoryTransaction, InventoryTransaction,
InventoryLotCostTracker, InventoryLotCostTracker,
Account,
Item, Item,
} from "@/models"; } from "@/models";
import { IInventoryLotCost, IInventoryTransaction } from "@/interfaces"; import { IInventoryLotCost, IInventoryTransaction } from "@/interfaces";
import JournalPoster from '@/services/Accounting/JournalPoster'; import InventoryCostMethod from '@/services/Inventory/InventoryCostMethod';
import JournalCommands from '@/services/Accounting/JournalCommands';
type TCostMethod = 'FIFO' | 'LIFO'; type TCostMethod = 'FIFO' | 'LIFO';
export default class InventoryCostLotTracker implements IInventoryCostMethod { export default class InventoryCostLotTracker extends InventoryCostMethod implements IInventoryCostMethod {
journal: JournalPoster;
journalCommands: JournalCommands;
startingDate: Date; startingDate: Date;
headDate: Date;
itemId: number; itemId: number;
costMethod: TCostMethod; costMethod: TCostMethod;
itemsById: Map<number, any>; itemsById: Map<number, any>;
@@ -25,7 +20,6 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
costLotsTransactions: IInventoryLotCost[]; costLotsTransactions: IInventoryLotCost[];
inTransactions: any[]; inTransactions: any[];
outTransactions: IInventoryTransaction[]; outTransactions: IInventoryTransaction[];
revertInvoiceTrans: any[];
revertJEntriesTransactions: IInventoryTransaction[]; revertJEntriesTransactions: IInventoryTransaction[];
/** /**
@@ -35,6 +29,8 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
* @param {string} costMethod - * @param {string} costMethod -
*/ */
constructor(startingDate: Date, itemId: number, costMethod: TCostMethod = 'FIFO') { constructor(startingDate: Date, itemId: number, costMethod: TCostMethod = 'FIFO') {
super();
this.startingDate = startingDate; this.startingDate = startingDate;
this.itemId = itemId; this.itemId = itemId;
this.costMethod = costMethod; this.costMethod = costMethod;
@@ -49,18 +45,6 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
this.inTransactions = []; this.inTransactions = [];
// Collects `OUT` transactions. // Collects `OUT` transactions.
this.outTransactions = []; this.outTransactions = [];
// Collects journal entries reference id and type that should be reverted.
this.revertInvoiceTrans = [];
}
/**
* Initialize the inventory average cost method.
* @async
*/
public async initialize() {
const accountsDepGraph = await Account.tenant().depGraph().query();
this.journal = new JournalPoster(accountsDepGraph);
this.journalCommands = new JournalCommands(this.journal);
} }
/** /**
@@ -88,22 +72,8 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
const storedTrackedInvLotsOper = this.storeInventoryLotsCost( const storedTrackedInvLotsOper = this.storeInventoryLotsCost(
this.costLotsTransactions, this.costLotsTransactions,
); );
// Remove and revert accounts balance journal entries from inventory transactions.
const revertJEntriesOper = this.revertJournalEntries(this.revertJEntriesTransactions);
// Records the journal entries operation.
this.recordJournalEntries(this.costLotsTransactions);
return Promise.all([ return Promise.all([
storedTrackedInvLotsOper, storedTrackedInvLotsOper,
revertJEntriesOper.then(() =>
Promise.all([
// Saves the new recorded journal entries to the storage.
this.journal.deleteEntries(),
this.journal.saveEntries(),
this.journal.saveBalance(),
])),
]); ]);
} }
@@ -122,6 +92,7 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
await InventoryTransaction.tenant() await InventoryTransaction.tenant()
.query() .query()
.modify('filterDateRange', this.startingDate) .modify('filterDateRange', this.startingDate)
.orderByRaw("FIELD(direction, 'IN', 'OUT')")
.orderBy('lot_number', (this.costMethod === 'LIFO') ? 'DESC' : 'ASC') .orderBy('lot_number', (this.costMethod === 'LIFO') ? 'DESC' : 'ASC')
.onBuild(commonBuilder) .onBuild(commonBuilder)
.withGraphFetched('item'); .withGraphFetched('item');
@@ -221,93 +192,6 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
return Promise.all([deleteInvLotsTrans, ...asyncOpers]); return Promise.all([deleteInvLotsTrans, ...asyncOpers]);
} }
/**
* Reverts the journal entries from inventory lots costs transaction.
* @param {} inventoryLots
*/
async revertJournalEntries(
transactions: IInventoryLotCost[],
) {
return this.journalCommands
.revertEntriesFromInventoryTransactions(transactions);
}
/**
* Records the journal entries transactions.
* @async
* @param {IInventoryLotCost[]} inventoryTransactions -
* @param {string} referenceType -
* @param {number} referenceId -
* @param {Date} date -
* @return {Promise}
*/
public recordJournalEntries(
inventoryLots: IInventoryLotCost[],
): void {
const outTransactions: any[] = [];
const inTransByLotNumber: any = {};
const transactions: any = [];
inventoryLots.forEach((invTransaction: IInventoryLotCost) => {
switch(invTransaction.direction) {
case 'IN':
inTransByLotNumber[invTransaction.lotNumber] = invTransaction;
break;
case 'OUT':
outTransactions.push(invTransaction);
break;
}
});
outTransactions.forEach((outTransaction: IInventoryLotCost) => {
const { lotNumber, quantity, rate, itemId } = outTransaction;
const income = quantity * rate;
const item = this.itemsById.get(itemId);
const transaction = {
date: outTransaction.date,
referenceType: outTransaction.transactionType,
referenceId: outTransaction.transactionId,
cost: 0,
income,
incomeAccount: item.sellAccountId,
costAccount: item.costAccountId,
inventoryAccount: item.inventoryAccountId,
};
if (lotNumber && inTransByLotNumber[lotNumber]) {
const inInvTrans = inTransByLotNumber[lotNumber];
transaction.cost = (outTransaction.quantity * inInvTrans.rate);
}
transactions.push(transaction);
});
this.journalCommands.inventoryEntries(transactions);
}
/**
* Stores the inventory lots costs transactions in bulk.
* @param {IInventoryLotCost[]} costLotsTransactions
* @return {Promise[]}
*/
storeInventoryLotsCost(costLotsTransactions: IInventoryLotCost[]): Promise<object> {
const opers: any = [];
costLotsTransactions.forEach((transaction: IInventoryLotCost) => {
if (transaction.lotTransId && transaction.decrement) {
const decrementOper = InventoryLotCostTracker.tenant()
.query()
.where('id', transaction.lotTransId)
.decrement('remaining', transaction.decrement);
opers.push(decrementOper);
} else if(!transaction.lotTransId) {
const operation = InventoryLotCostTracker.tenant().query()
.insert({
...omit(transaction, ['decrement', 'invTransId', 'lotTransId']),
});
opers.push(operation);
}
});
return Promise.all(opers);
}
/** /**
* Tracking inventory `IN` lots transactions. * Tracking inventory `IN` lots transactions.
* @public * @public
@@ -352,7 +236,7 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
const commonLotTransaction: IInventoryLotCost = { const commonLotTransaction: IInventoryLotCost = {
...pick(transaction, [ ...pick(transaction, [
'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId', 'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId', 'entryId',
'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining' 'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
]), ]),
}; };
@@ -373,6 +257,7 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0; const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0;
const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining; const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining;
const maxDecrement = Math.min(decrement, invRemaining); const maxDecrement = Math.min(decrement, invRemaining);
const cost = maxDecrement * _invINTransaction.rate;
_invINTransaction.decrement += maxDecrement; _invINTransaction.decrement += maxDecrement;
_invINTransaction.remaining = Math.max( _invINTransaction.remaining = Math.max(
@@ -383,6 +268,7 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
this.costLotsTransactions.push({ this.costLotsTransactions.push({
...commonLotTransaction, ...commonLotTransaction,
cost,
quantity: maxDecrement, quantity: maxDecrement,
lotNumber: _invINTransaction.lotNumber, lotNumber: _invINTransaction.lotNumber,
}); });

View File

@@ -0,0 +1,31 @@
import { omit } from 'lodash';
import { IInventoryLotCost } from '@/interfaces';
import { InventoryLotCostTracker } from '@/models';
export default class InventoryCostMethod {
/**
* Stores the inventory lots costs transactions in bulk.
* @param {IInventoryLotCost[]} costLotsTransactions
* @return {Promise[]}
*/
public storeInventoryLotsCost(costLotsTransactions: IInventoryLotCost[]): Promise<object> {
const opers: any = [];
costLotsTransactions.forEach((transaction: IInventoryLotCost) => {
if (transaction.lotTransId && transaction.decrement) {
const decrementOper = InventoryLotCostTracker.tenant()
.query()
.where('id', transaction.lotTransId)
.decrement('remaining', transaction.decrement);
opers.push(decrementOper);
} else if(!transaction.lotTransId) {
const operation = InventoryLotCostTracker.tenant().query()
.insert({
...omit(transaction, ['decrement', 'invTransId', 'lotTransId']),
});
opers.push(operation);
}
});
return Promise.all(opers);
}
}

View File

@@ -0,0 +1,5 @@
export default class ItemsCostService {
}

View File

@@ -1,5 +1,5 @@
import { difference } from "lodash"; import { difference } from "lodash";
import { Item } from '@/models'; import { Item, ItemTransaction } from '@/models';
export default class ItemsService { export default class ItemsService {

View File

@@ -14,13 +14,14 @@ import AccountsService from '@/services/Accounts/AccountsService';
import JournalPosterService from '@/services/Sales/JournalPosterService'; import JournalPosterService from '@/services/Sales/JournalPosterService';
import InventoryService from '@/services/Inventory/Inventory'; import InventoryService from '@/services/Inventory/Inventory';
import HasItemsEntries from '@/services/Sales/HasItemsEntries'; import HasItemsEntries from '@/services/Sales/HasItemsEntries';
import SalesInvoicesCost from '@/services/Sales/SalesInvoicesCost';
import { formatDateFields } from '@/utils'; import { formatDateFields } from '@/utils';
/** /**
* Vendor bills services. * Vendor bills services.
* @service * @service
*/ */
export default class BillsService { export default class BillsService extends SalesInvoicesCost {
/** /**
* Creates a new bill and stored it to the storage. * Creates a new bill and stored it to the storage.
* *
@@ -52,13 +53,18 @@ export default class BillsService {
bill.entries.forEach((entry) => { bill.entries.forEach((entry) => {
const oper = ItemEntry.tenant() const oper = ItemEntry.tenant()
.query() .query()
.insert({ .insertAndFetch({
reference_type: 'Bill', reference_type: 'Bill',
reference_id: storedBill.id, reference_id: storedBill.id,
...omit(entry, ['amount']), ...omit(entry, ['amount']),
}).then((itemEntry) => {
entry.id = itemEntry.id;
}); });
saveEntriesOpers.push(oper); saveEntriesOpers.push(oper);
}); });
// Await save all bill entries operations.
await Promise.all([...saveEntriesOpers]);
// Increments vendor balance. // Increments vendor balance.
const incrementOper = Vendor.changeBalance(bill.vendor_id, bill.amount); const incrementOper = Vendor.changeBalance(bill.vendor_id, bill.amount);
@@ -68,18 +74,16 @@ export default class BillsService {
); );
// Writes the journal entries for the given bill transaction. // Writes the journal entries for the given bill transaction.
const writeJEntriesOper = this.recordJournalTransactions({ const writeJEntriesOper = this.recordJournalTransactions({
id: storedBill.id, id: storedBill.id, ...bill,
...bill
}); });
await Promise.all([ await Promise.all([
...saveEntriesOpers,
incrementOper, incrementOper,
writeInvTransactionsOper, writeInvTransactionsOper,
writeJEntriesOper, writeJEntriesOper,
]); ]);
// Schedule bill re-compute based on the item cost // Schedule bill re-compute based on the item cost
// method and starting date. // method and starting date.
await this.scheduleComputeItemsCost(bill); await this.scheduleComputeBillItemsCost(bill);
return storedBill; return storedBill;
} }
@@ -147,7 +151,7 @@ export default class BillsService {
]); ]);
// Schedule sale invoice re-compute based on the item cost // Schedule sale invoice re-compute based on the item cost
// method and starting date. // method and starting date.
await this.scheduleComputeItemsCost(bill); await this.scheduleComputeBillItemsCost(bill);
} }
/** /**
@@ -192,10 +196,10 @@ export default class BillsService {
]); ]);
// Schedule sale invoice re-compute based on the item cost // Schedule sale invoice re-compute based on the item cost
// method and starting date. // method and starting date.
await this.scheduleComputeItemsCost(bill); await this.scheduleComputeBillItemsCost(bill);
} }
/** /**
* Records the inventory transactions from the given bill input. * Records the inventory transactions from the given bill input.
* @param {Bill} bill * @param {Bill} bill
* @param {number} billId * @param {number} billId
@@ -209,6 +213,7 @@ export default class BillsService {
transactionId: billId, transactionId: billId,
direction: 'IN', direction: 'IN',
date: bill.bill_date, date: bill.bill_date,
entryId: entry.id,
})); }));
return InventoryService.recordInventoryTransactions( return InventoryService.recordInventoryTransactions(
@@ -284,24 +289,6 @@ export default class BillsService {
]); ]);
} }
/**
* Schedule a job to re-compute the bill's items based on cost method
* of the each one.
* @param {Bill} bill
*/
static scheduleComputeItemsCost(bill) {
const asyncOpers = [];
bill.entries.forEach((entry) => {
const oper = InventoryService.scheduleComputeItemCost(
entry.item_id || entry.itemId,
bill.bill_date || bill.billDate,
);
asyncOpers.push(oper);
});
return Promise.all(asyncOpers);
}
/** /**
* Detarmines whether the bill exists on the storage. * Detarmines whether the bill exists on the storage.
* @param {Integer} billId * @param {Integer} billId
@@ -355,4 +342,16 @@ export default class BillsService {
.withGraphFetched('entries') .withGraphFetched('entries')
.first(); .first();
} }
/**
* Schedules compute bill items cost based on each item cost method.
* @param {IBill} bill
* @return {Promise}
*/
static scheduleComputeBillItemsCost(bill) {
return this.scheduleComputeItemsCost(
bill.entries.map((e) => e.item_id),
bill.bill_date,
);
}
} }

View File

@@ -58,4 +58,24 @@ export default class HasItemEntries {
}); });
return Promise.all([...opers]); return Promise.all([...opers]);
} }
static filterNonInventoryEntries(entries: [], items: []) {
const nonInventoryItems = items.filter((item: any) => item.type !== 'inventory');
const nonInventoryItemsIds = nonInventoryItems.map((i: any) => i.id);
return entries
.filter((entry: any) => (
(nonInventoryItemsIds.indexOf(entry.item_id)) !== -1
));
}
static filterInventoryEntries(entries: [], items: []) {
const inventoryItems = items.filter((item: any) => item.type === 'inventory');
const inventoryItemsIds = inventoryItems.map((i: any) => i.id);
return entries
.filter((entry: any) => (
(inventoryItemsIds.indexOf(entry.item_id)) !== -1
));
}
} }

View File

@@ -11,35 +11,14 @@ import JournalPoster from '@/services/Accounting/JournalPoster';
import HasItemsEntries from '@/services/Sales/HasItemsEntries'; import HasItemsEntries from '@/services/Sales/HasItemsEntries';
import CustomerRepository from '@/repositories/CustomerRepository'; import CustomerRepository from '@/repositories/CustomerRepository';
import InventoryService from '@/services/Inventory/Inventory'; import InventoryService from '@/services/Inventory/Inventory';
import SalesInvoicesCost from '@/services/Sales/SalesInvoicesCost';
import { formatDateFields } from '@/utils'; import { formatDateFields } from '@/utils';
import { Item } from '../../models';
import JournalCommands from '../Accounting/JournalCommands';
/** /**
* Sales invoices service * Sales invoices service
* @service * @service
*/ */
export default class SaleInvoicesService { export default class SaleInvoicesService extends SalesInvoicesCost {
static filterNonInventoryEntries(entries: [], items: []) {
const nonInventoryItems = items.filter((item: any) => item.type !== 'inventory');
const nonInventoryItemsIds = nonInventoryItems.map((i: any) => i.id);
return entries
.filter((entry: any) => (
(nonInventoryItemsIds.indexOf(entry.item_id)) !== -1
));
}
static filterInventoryEntries(entries: [], items: []) {
const inventoryItems = items.filter((item: any) => item.type === 'inventory');
const inventoryItemsIds = inventoryItems.map((i: any) => i.id);
return entries
.filter((entry: any) => (
(inventoryItemsIds.indexOf(entry.item_id)) !== -1
));
}
/** /**
* Creates a new sale invoices and store it to the storage * Creates a new sale invoices and store it to the storage
* with associated to entries and journal transactions. * with associated to entries and journal transactions.
@@ -66,81 +45,36 @@ export default class SaleInvoicesService {
saleInvoice.entries.forEach((entry: any) => { saleInvoice.entries.forEach((entry: any) => {
const oper = ItemEntry.tenant() const oper = ItemEntry.tenant()
.query() .query()
.insert({ .insertAndFetch({
reference_type: 'SaleInvoice', reference_type: 'SaleInvoice',
reference_id: storedInvoice.id, reference_id: storedInvoice.id,
...omit(entry, ['amount', 'id']), ...omit(entry, ['amount', 'id']),
}).then((itemEntry) => {
entry.id = itemEntry.id;
}); });
opers.push(oper); opers.push(oper);
}); });
// Increment the customer balance after deliver the sale invoice. // Increment the customer balance after deliver the sale invoice.
const incrementOper = Customer.incrementBalance( const incrementOper = Customer.incrementBalance(
saleInvoice.customer_id, saleInvoice.customer_id,
balance, balance,
); );
// Records the inventory transactions for inventory items.
const recordInventoryTransOpers = this.recordInventoryTranscactions(
saleInvoice, storedInvoice.id
);
// Records the non-inventory transactions of the entries items.
const recordNonInventoryJEntries = this.recordNonInventoryEntries(
saleInvoice, storedInvoice.id,
);
// Await all async operations. // Await all async operations.
await Promise.all([ await Promise.all([
...opers, ...opers, incrementOper,
incrementOper,
recordNonInventoryJEntries,
recordInventoryTransOpers,
]); ]);
// Records the inventory transactions for inventory items.
await this.recordInventoryTranscactions(
saleInvoice, storedInvoice.id
);
// Schedule sale invoice re-compute based on the item cost // Schedule sale invoice re-compute based on the item cost
// method and starting date. // method and starting date.
// await this.scheduleComputeItemsCost(saleInvoice); await this.scheduleComputeInvoiceItemsCost(saleInvoice);
return storedInvoice; return storedInvoice;
} }
/**
* Records the journal entries for non-inventory entries.
* @param {SaleInvoice} saleInvoice
*/
static async recordNonInventoryEntries(saleInvoice: any, saleInvoiceId: number) {
const saleInvoiceItems = saleInvoice.entries.map((entry: any) => entry.item_id);
// Retrieves items data to detarmines whether the item type.
const itemsMeta = await Item.tenant().query().whereIn('id', saleInvoiceItems);
const storedItemsMap = new Map(itemsMeta.map((item) => [item.id, item]));
// Filters the non-inventory and inventory entries based on the item type.
const nonInventoryEntries: any[] = this.filterNonInventoryEntries(saleInvoice.entries, itemsMeta);
const transactions: any = [];
const common = {
referenceType: 'SaleInvoice',
referenceId: saleInvoiceId,
date: saleInvoice.invoice_date,
};
nonInventoryEntries.forEach((entry) => {
const item = storedItemsMap.get(entry.item_id);
transactions.push({
...common,
income: entry.amount,
incomeAccountId: item.incomeAccountId,
})
});
const accountsDepGraph = await Account.tenant().depGraph().query();
const journal = new JournalPoster(accountsDepGraph);
const journalCommands = new JournalCommands(journal);
journalCommands.nonInventoryEntries(transactions);
return Promise.all([
journal.deleteEntries(),
journal.saveEntries(),
journal.saveBalance(),
]);
}
/** /**
* Edit the given sale invoice. * Edit the given sale invoice.
* @async * @async
@@ -193,7 +127,7 @@ export default class SaleInvoicesService {
// Schedule sale invoice re-compute based on the item cost // Schedule sale invoice re-compute based on the item cost
// method and starting date. // method and starting date.
await this.scheduleComputeItemsCost(saleInvoice); await this.scheduleComputeInvoiceItemsCost(saleInvoice);
} }
/** /**
@@ -260,12 +194,13 @@ export default class SaleInvoicesService {
static recordInventoryTranscactions(saleInvoice, saleInvoiceId: number, override?: boolean){ static recordInventoryTranscactions(saleInvoice, saleInvoiceId: number, override?: boolean){
const inventortyTransactions = saleInvoice.entries const inventortyTransactions = saleInvoice.entries
.map((entry) => ({ .map((entry) => ({
...pick(entry, ['item_id', 'quantity', 'rate']), ...pick(entry, ['item_id', 'quantity', 'rate',]),
lotNumber: saleInvoice.invLotNumber, lotNumber: saleInvoice.invLotNumber,
transactionType: 'SaleInvoice', transactionType: 'SaleInvoice',
transactionId: saleInvoiceId, transactionId: saleInvoiceId,
direction: 'OUT', direction: 'OUT',
date: saleInvoice.invoice_date, date: saleInvoice.invoice_date,
entryId: entry.id,
})); }));
return InventoryService.recordInventoryTransactions( return InventoryService.recordInventoryTransactions(
@@ -273,27 +208,6 @@ export default class SaleInvoicesService {
); );
} }
/**
* Schedule sale invoice re-compute based on the item
* cost method and starting date
*
* @private
* @param {SaleInvoice} saleInvoice -
* @return {Promise<Agenda>}
*/
private static scheduleComputeItemsCost(saleInvoice: any) {
const asyncOpers: Promise<[]>[] = [];
saleInvoice.entries.forEach((entry: any) => {
const oper: Promise<[]> = InventoryService.scheduleComputeItemCost(
entry.item_id || entry.itemId,
saleInvoice.bill_date || saleInvoice.billDate,
);
asyncOpers.push(oper);
});
return Promise.all(asyncOpers);
}
/** /**
* Deletes the inventory transactions. * Deletes the inventory transactions.
* @param {string} transactionType * @param {string} transactionType
@@ -392,4 +306,17 @@ export default class SaleInvoicesService {
const notStoredInvoices = difference(invoicesIds, storedInvoicesIds); const notStoredInvoices = difference(invoicesIds, storedInvoicesIds);
return notStoredInvoices; return notStoredInvoices;
} }
/**
* Schedules compute sale invoice items cost based on each item
* cost method.
* @param {ISaleInvoice} saleInvoice
* @return {Promise}
*/
static scheduleComputeInvoiceItemsCost(saleInvoice) {
return this.scheduleComputeItemsCost(
saleInvoice.entries.map((e) => e.item_id),
saleInvoice.invoice_date,
);
}
} }

View File

@@ -0,0 +1,145 @@
import { Container } from 'typedi';
import {
SaleInvoice,
Account,
AccountTransaction,
Item,
} from '@/models';
import JournalPoster from '@/services/Accounting/JournalPoster';
import JournalEntry from '@/services/Accounting/JournalEntry';
import InventoryService from '@/services/Inventory/Inventory';
import { ISaleInvoice, IItemEntry, IItem } from '@/interfaces';
export default class SaleInvoicesCost {
/**
* Schedule sale invoice re-compute based on the item
* cost method and starting date.
* @param {number[]} itemIds -
* @param {Date} startingDate -
* @return {Promise<Agenda>}
*/
static async scheduleComputeItemsCost(itemIds: number[], startingDate: Date) {
const items: IItem[] = await Item.tenant().query().whereIn('id', itemIds);
const inventoryItems: IItem[] = items.filter((item: IItem) => item.type === 'inventory');
const asyncOpers: Promise<[]>[] = [];
inventoryItems.forEach((item: IItem) => {
const oper: Promise<[]> = InventoryService.scheduleComputeItemCost(
item.id,
startingDate,
);
asyncOpers.push(oper);
});
const writeJEntriesOper: Promise<any> = this.scheduleWriteJournalEntries(startingDate);
return Promise.all([...asyncOpers, writeJEntriesOper]);
}
/**
* Schedule writing journal entries.
* @param {Date} startingDate
* @return {Promise<agenda>}
*/
static scheduleWriteJournalEntries(startingDate?: Date) {
const agenda = Container.get('agenda');
return agenda.schedule('in 3 seconds', 'rewrite-invoices-journal-entries', {
startingDate,
});
}
/**
* Writes journal entries from sales invoices.
* @param {Date} startingDate
* @param {boolean} override
*/
static async writeJournalEntries(startingDate: Date, override: boolean) {
const salesInvoices = await SaleInvoice.tenant()
.query()
.onBuild((builder: any) => {
builder.modify('filterDateRange', startingDate);
builder.orderBy('invoice_date', 'ASC');
builder.withGraphFetched('entries.item')
builder.withGraphFetched('costTransactions(groupedEntriesCost)');
});
const accountsDepGraph = await Account.tenant().depGraph().query();
const journal = new JournalPoster(accountsDepGraph);
if (override) {
const oldTransactions = await AccountTransaction.tenant()
.query()
.whereIn('reference_type', ['SaleInvoice'])
.onBuild((builder: any) => {
builder.modify('filterDateRange', startingDate);
})
.withGraphFetched('account.type');
journal.loadEntries(oldTransactions);
journal.removeEntries();
}
const receivableAccount = { id: 10 };
salesInvoices.forEach((saleInvoice: ISaleInvoice) => {
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: 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) => {
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,
});
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);
}
});
});
return Promise.all([
journal.deleteEntries(),
journal.saveEntries(),
journal.saveBalance(),
]);
}
}