feat(reports): inventory valuation report.

feat(reports): sales by items report.
feat(reports): purchases by items report.
This commit is contained in:
a.bouhuolia
2021-03-29 10:51:24 +02:00
parent 9a204282a2
commit 2d6d642f3b
19 changed files with 1106 additions and 30 deletions

View File

@@ -8,6 +8,9 @@ import JournalSheetController from './FinancialStatements/JournalSheet';
import ProfitLossController from './FinancialStatements/ProfitLossSheet';
import ARAgingSummary from './FinancialStatements/ARAgingSummary';
import APAgingSummary from './FinancialStatements/APAgingSummary';
import PurchasesByItemsController from './FinancialStatements/PurchasesByItem';
import SalesByItemsController from './FinancialStatements/SalesByItems';
import InventoryValuationController from './FinancialStatements/InventoryValuationSheet';
@Service()
export default class FinancialStatementsService {
@@ -42,7 +45,18 @@ export default class FinancialStatementsService {
'/payable_aging_summary',
Container.get(APAgingSummary).router()
);
router.use(
'/purchases-by-items',
Container.get(PurchasesByItemsController).router()
);
router.use(
'/sales-by-items',
Container.get(SalesByItemsController).router()
);
router.use(
'/inventory-valuation',
Container.get(InventoryValuationController).router()
);
return router;
}
}

View File

@@ -0,0 +1,70 @@
import { Router, Request, Response, NextFunction } from 'express';
import { query, ValidationChain } from 'express-validator';
import { Inject, Service } from 'typedi';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseFinancialReportController from './BaseFinancialReportController';
import InventoryValuationService from 'services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService';
@Service()
export default class InventoryValuationReportController extends BaseFinancialReportController {
@Inject()
inventoryValuationService: InventoryValuationService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
this.validationResult,
asyncMiddleware(this.inventoryValuation.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get validationSchema(): ValidationChain[] {
return [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('none_transactions').default(true).isBoolean().toBoolean(),
query('orderBy').optional().isIn(['created_at', 'name', 'code']),
query('order').optional().isIn(['desc', 'asc']),
];
}
/**
* Retrieve the general ledger financial statement.
* @param {Request} req -
* @param {Response} res -
*/
async inventoryValuation(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const filter = this.matchedQueryData(req);
try {
const {
data,
query,
meta,
} = await this.inventoryValuationService.inventoryValuationSheet(
tenantId,
filter
);
return res.status(200).send({
meta: this.transfromToResponse(meta),
data: this.transfromToResponse(data),
query: this.transfromToResponse(query),
});
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,71 @@
import { Router, Request, Response, NextFunction } from 'express';
import { query, ValidationChain } from 'express-validator';
import moment from 'moment';
import { Inject, Service } from 'typedi';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseFinancialReportController from './BaseFinancialReportController';
import PurchasesByItemsService from 'services/FinancialStatements/PurchasesByItems/PurchasesByItemsService';
@Service()
export default class PurchasesByItemReportController extends BaseFinancialReportController {
@Inject()
purchasesByItemsService: PurchasesByItemsService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
this.validationResult,
asyncMiddleware(this.purchasesByItems.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get validationSchema(): ValidationChain[] {
return [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('none_transactions').default(true).isBoolean().toBoolean(),
query('orderBy').optional().isIn(['created_at', 'name', 'code']),
query('order').optional().isIn(['desc', 'asc']),
];
}
/**
* Retrieve the general ledger financial statement.
* @param {Request} req -
* @param {Response} res -
*/
async purchasesByItems(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const filter = this.matchedQueryData(req);
try {
const {
data,
query,
meta,
} = await this.purchasesByItemsService.purchasesByItems(
tenantId,
filter
);
return res.status(200).send({
meta: this.transfromToResponse(meta),
data: this.transfromToResponse(data),
query: this.transfromToResponse(query),
});
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,71 @@
import { Router, Request, Response, NextFunction } from 'express';
import { query, ValidationChain } from 'express-validator';
import moment from 'moment';
import { Inject, Service } from 'typedi';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseFinancialReportController from './BaseFinancialReportController';
import SalesByItemsReportService from 'services/FinancialStatements/SalesByItems/SalesByItemsService';
@Service()
export default class SalesByItemsReportController extends BaseFinancialReportController {
@Inject()
salesByItemsService: SalesByItemsReportService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
this.validationResult,
asyncMiddleware(this.purchasesByItems.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get validationSchema(): ValidationChain[] {
return [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('none_transactions').default(true).isBoolean().toBoolean(),
query('orderBy').optional().isIn(['created_at', 'name', 'code']),
query('order').optional().isIn(['desc', 'asc']),
];
}
/**
* Retrieve the general ledger financial statement.
* @param {Request} req -
* @param {Response} res -
*/
async purchasesByItems(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const filter = this.matchedQueryData(req);
try {
const {
data,
query,
meta,
} = await this.salesByItemsService.salesByItems(
tenantId,
filter
);
return res.status(200).send({
meta: this.transfromToResponse(meta),
data: this.transfromToResponse(data),
query: this.transfromToResponse(query),
});
} catch (error) {
next(error);
}
}
}

View File

@@ -16,7 +16,7 @@ export default class ItemsController extends BaseController {
@Inject()
dynamicListService: DynamicListingService;
/**
* Router constructor.
*/

View File

@@ -1,29 +1,36 @@
exports.up = function (knex) {
return knex.schema
.createTable('accounts_transactions', (table) => {
table.increments();
table.decimal('credit', 13, 3);
table.decimal('debit', 13, 3);
table.string('transaction_type').index();
table.string('reference_type').index();
table.integer('reference_id').index();
table
.integer('account_id')
.unsigned()
.index()
.references('id')
.inTable('accounts');
table.string('contact_type').nullable().index();
table.integer('contact_id').unsigned().nullable().index();
table.string('transaction_number').nullable().index();
table.string('reference_number').nullable().index();
table.integer('item_id').unsigned().nullable().index();
table.integer('item_quantity').unsigned().nullable().index(),
table.string('note');
table.integer('user_id').unsigned().index();
exports.up = function(knex) {
return knex.schema.createTable('accounts_transactions', (table) => {
table.increments();
table.decimal('credit', 13, 3);
table.decimal('debit', 13, 3);
table.string('transaction_type').index();
table.string('reference_type').index();
table.integer('reference_id').index();
table.integer('account_id').unsigned().index().references('id').inTable('accounts');
table.string('contact_type').nullable().index();
table.integer('contact_id').unsigned().nullable().index();
table.string('transaction_number').nullable().index();
table.string('reference_number').nullable().index();
table.integer('item_id').unsigned().nullable().index();
table.string('note');
table.integer('user_id').unsigned().index();
table.integer('index_group').unsigned().index();
table.integer('index').unsigned().index();
table.integer('index_group').unsigned().index();
table.integer('index').unsigned().index();
table.date('date').index();
table.datetime('created_at').index();
}).raw('ALTER TABLE `ACCOUNTS_TRANSACTIONS` AUTO_INCREMENT = 1000');
table.date('date').index();
table.datetime('created_at').index();
})
.raw('ALTER TABLE `ACCOUNTS_TRANSACTIONS` AUTO_INCREMENT = 1000');
};
exports.down = function(knex) {
exports.down = function (knex) {
return knex.schema.dropTableIfExists('accounts_transactions');
};

View File

@@ -9,8 +9,8 @@ export interface IBalanceSheetQuery {
fromDate: Date | string;
toDate: Date | string;
numberFormat: INumberFormatQuery;
noneZero: boolean;
noneTransactions: boolean;
noneZero: boolean;
basis: 'cash' | 'accural';
accountIds: number[];
}

View File

@@ -0,0 +1,42 @@
import {
INumberFormatQuery,
IFormatNumberSettings,
} from './FinancialStatements';
export interface IInventoryValuationReportQuery {
asDate: Date | string;
numberFormat: INumberFormatQuery;
noneTransactions: boolean;
};
export interface IInventoryValuationSheetMeta {
organizationName: string,
baseCurrency: string,
};
export interface IInventoryValuationItem {
id: number,
name: string,
code: string,
valuation: number,
quantity: number,
average: number,
valuationFormatted: string,
quantityFormatted: string,
averageFormatted: string,
currencyCode: string,
};
export interface IInventoryValuationTotal {
valuation: number,
quantity: number,
valuationFormatted: string,
quantityFormatted: string,
}
export interface IInventoryValuationStatement {
items: IInventoryValuationItem[],
total: IInventoryValuationTotal
};

View File

@@ -0,0 +1,43 @@
import {
INumberFormatQuery,
} from './FinancialStatements';
export interface ISalesByItemsReportQuery {
fromDate: Date | string;
toDate: Date | string;
numberFormat: INumberFormatQuery;
noneTransactions: boolean;
};
export interface ISalesByItemsSheetMeta {
organizationName: string,
baseCurrency: string,
};
export interface ISalesByItemsItem {
id: number,
name: string,
code: string,
quantitySold: number,
soldCost: number,
averageSellPrice: number,
quantitySoldFormatted: string,
soldCostFormatted: string,
averageSellPriceFormatted: string,
currencyCode: string,
};
export interface ISalesByItemsTotal {
quantitySold: number,
soldCost: number,
quantitySoldFormatted: string,
soldCostFormatted: string,
currencyCode: string,
};
export interface ISalesByItemsSheetStatement {
items: ISalesByItemsItem[],
total: ISalesByItemsTotal
};

View File

@@ -40,4 +40,6 @@ export * from './ARAgingSummaryReport';
export * from './APAgingSummaryReport';
export * from './Mailable';
export * from './InventoryAdjustment';
export * from './Setup'
export * from './Setup'
export * from './IInventoryValuationSheet';
export * from './SalesByItemsSheet';

View File

@@ -1,4 +1,4 @@
import { Model } from 'objection';
import { Model, raw } from 'objection';
import moment from 'moment';
import TenantModel from 'models/TenantModel';
@@ -34,6 +34,22 @@ export default class InventoryTransaction extends TenantModel {
query.where('date', '<=', toDate);
}
},
itemsTotals(builder) {
builder.select('itemId');
builder.sum('rate as rate');
builder.sum('quantity as quantity');
builder.select(raw('SUM(`QUANTITY` * `RATE`) as COST'));
builder.groupBy('itemId');
},
INDirection(builder) {
builder.where('direction', 'IN');
},
OUTDirection(builder) {
builder.where('direction', 'OUT');
},
};
}

View File

@@ -98,6 +98,7 @@ export default class JournalCommands {
: entry.costAccountId,
index: index + 2,
itemId: entry.itemId,
itemQuantity: entry.quantity,
});
this.journal.debit(debitEntry);
});
@@ -416,6 +417,7 @@ export default class JournalCommands {
note: entry.description,
index: index + 2,
itemId: entry.itemId,
itemQuantity: entry.quantity,
});
this.journal.credit(incomeEntry);
});
@@ -465,6 +467,8 @@ export default class JournalCommands {
account: entry.item.sellAccountId,
note: entry.description,
index: index + 2,
itemId: entry.itemId,
itemQuantity: entry.quantity,
});
this.journal.credit(incomeEntry);
}

View File

@@ -0,0 +1,131 @@
import { sumBy, get } from 'lodash';
import FinancialSheet from '../FinancialSheet';
import {
IItem,
IInventoryValuationReportQuery,
IInventoryValuationItem,
IAccountTransaction,
IInventoryValuationTotal
} from 'interfaces';
import { transformToMap } from 'utils'
export default class InventoryValuationSheet extends FinancialSheet {
readonly query: IInventoryValuationReportQuery;
readonly items: IItem[];
readonly INInventoryCostLots: Map<number, IAccountTransaction>;
readonly OUTInventoryCostLots: Map<number, IAccountTransaction>;
readonly baseCurrency: string;
/**
* Constructor method.
* @param {IInventoryValuationReportQuery} query
* @param items
* @param INInventoryCostLots
* @param OUTInventoryCostLots
* @param baseCurrency
*/
constructor(
query: IInventoryValuationReportQuery,
items: IItem[],
INInventoryCostLots: IAccountTransaction[],
OUTInventoryCostLots: IAccountTransaction[],
baseCurrency: string
) {
super();
this.query = query;
this.items = items;
this.INInventoryCostLots = transformToMap(INInventoryCostLots, 'itemId');
this.OUTInventoryCostLots = transformToMap(OUTInventoryCostLots, 'itemId');
this.baseCurrency = baseCurrency;
this.numberFormat = this.query.numberFormat;
}
getItemTransaction(
transactionsMap,
itemId: number,
): { cost: number, quantity: number } {
const meta = transactionsMap.get(itemId);
const cost = get(meta, 'cost', 0);
const quantity = get(meta, 'cost', 0);
return { cost, quantity };
}
getItemINTransaction(
itemId: number,
): { cost: number, quantity: number } {
return this.getItemTransaction(this.INInventoryCostLots, itemId);
}
getItemOUTTransaction(
itemId: number,
): { cost: number, quantity: number } {
return this.getItemTransaction(this.OUTInventoryCostLots, itemId);
}
getItemValuation(itemId: number): number {
const { cost: INValuation } = this.getItemINTransaction(itemId);
const { cost: OUTValuation } = this.getItemOUTTransaction(itemId);
return INValuation - OUTValuation;
}
getItemQuantity(itemId: number): number {
const { quantity: INQuantity } = this.getItemINTransaction(itemId);
const { quantity: OUTQuantity } = this.getItemOUTTransaction(itemId);
return INQuantity - OUTQuantity;
}
calcAverage(valuation: number, quantity: number): number {
return quantity ? valuation / quantity : 0;
}
itemMapper(item: IItem): IInventoryValuationItem {
const valuation = this.getItemValuation(item.id);
const quantity = this.getItemQuantity(item.id);
const average = this.calcAverage(valuation, quantity);
return {
id: item.id,
name: item.name,
code: item.code,
valuation,
quantity,
average,
valuationFormatted: this.formatNumber(valuation),
quantityFormatted: this.formatNumber(quantity, { money: false }),
averageFormatted: this.formatNumber(average, { money: false }),
currencyCode: this.baseCurrency
};
}
/**
*
* @returns
*/
itemsSection() {
return this.items.map(this.itemMapper.bind(this));
}
totalSection(items: IInventoryValuationItem[]): IInventoryValuationTotal {
const valuation = sumBy(items, item => item.valuation);
const quantity = sumBy(items, item => item.quantity);
return {
valuation,
quantity,
valuationFormatted: this.formatNumber(valuation),
quantityFormatted: this.formatNumber(quantity, { money: false }),
};
}
reportData() {
const items = this.itemsSection();
const total = this.totalSection(items);
return { items, total };
}
}

View File

@@ -0,0 +1,117 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import {
IInventoryValuationReportQuery,
IInventoryValuationSheetMeta,
} from 'interfaces';
import TenancyService from 'services/Tenancy/TenancyService';
import InventoryValuationSheet from './InventoryValuationSheet';
@Service()
export default class InventoryValuationSheetService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults balance sheet filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery(): IInventoryValuationReportQuery {
return {
asDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
precision: 2,
divideOn1000: false,
showZero: false,
formatMoney: 'always',
negativeFormat: 'mines',
},
noneTransactions: false,
};
}
/**
* Retrieve the balance sheet meta.
* @param {number} tenantId -
* @returns {IBalanceSheetMeta}
*/
reportMetadata(tenantId: number): IInventoryValuationSheetMeta {
const settings = this.tenancy.settings(tenantId);
const organizationName = settings.get({
group: 'organization',
key: 'name',
});
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
return {
organizationName,
baseCurrency,
};
}
/**
* Inventory valuation sheet.
* @param {number} tenantId - Tenant id.
* @param {IInventoryValuationReportQuery} query - Valuation query.
*/
public async inventoryValuationSheet(
tenantId: number,
query: IInventoryValuationReportQuery,
) {
const { Item, InventoryCostLotTracker } = this.tenancy.models(tenantId);
const inventoryItems = await Item.query().where('type', 'inventory');
const inventoryItemsIds = inventoryItems.map((item) => item.id);
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
const filter = {
...this.defaultQuery,
...query,
};
const commonQuery = (builder) => {
builder.whereIn('item_id', inventoryItemsIds);
builder.sum('rate as rate');
builder.sum('quantity as quantity');
builder.sum('cost as cost');
builder.select('itemId');
builder.groupBy('itemId');
};
const INTransactions = await InventoryCostLotTracker.query()
.onBuild(commonQuery)
.where('direction', 'IN');
const OUTTransactions = await InventoryCostLotTracker.query()
.onBuild(commonQuery)
.where('direction', 'OUT');
const inventoryValuationInstance = new InventoryValuationSheet(
filter,
inventoryItems,
INTransactions,
OUTTransactions,
baseCurrency,
);
const inventoryValuationData = inventoryValuationInstance.reportData();
return {
data: inventoryValuationData,
query: filter,
meta: this.reportMetadata(tenantId),
}
}
}

View File

@@ -0,0 +1,120 @@
import { get, sumBy } from 'lodash';
import FinancialSheet from '../FinancialSheet';
import { transformToMap } from 'utils';
import {
IAccountTransaction,
IInventoryValuationTotal,
IInventoryValuationItem,
IInventoryValuationReportQuery,
IInventoryValuationStatement,
IItem,
} from 'interfaces';
export default class InventoryValuationReport extends FinancialSheet {
readonly baseCurrency: string;
readonly items: IItem[];
readonly itemsTransactions: Map<number, IAccountTransaction>;
readonly query: IInventoryValuationReportQuery;
/**
* Constructor method.
* @param {IInventoryValuationReportQuery} query
* @param {IItem[]} items
* @param {IAccountTransaction[]} itemsTransactions
* @param {string} baseCurrency
*/
constructor(
query: IInventoryValuationReportQuery,
items: IItem[],
itemsTransactions: IAccountTransaction[],
baseCurrency: string
) {
super();
this.baseCurrency = baseCurrency;
this.items = items;
this.itemsTransactions = transformToMap(itemsTransactions, 'itemId');
this.query = query;
this.numberFormat = this.query.numberFormat;
}
/**
* Retrieve the item purchase item, cost and average cost price.
* @param {number} itemId
*/
getItemTransaction(
itemId: number
): { quantity: number; cost: number; average: number } {
const transaction = this.itemsTransactions.get(itemId);
const quantity = get(transaction, 'quantity', 0);
const cost = get(transaction, 'cost', 0);
const average = cost / quantity;
return { quantity, cost, average };
}
/**
* Mapping the given item section.
* @param {IInventoryValuationItem} item
* @returns
*/
itemSectionMapper(item: IItem): IInventoryValuationItem {
const meta = this.getItemTransaction(item.id);
return {
id: item.id,
name: item.name,
code: item.code,
quantityPurchased: meta.quantity,
purchaseCost: meta.cost,
averageCostPrice: meta.average,
quantityPurchasedFormatted: this.formatNumber(meta.quantity, {
money: false,
}),
purchaseCostFormatted: this.formatNumber(meta.cost),
averageCostPriceFormatted: this.formatNumber(meta.average),
currencyCode: this.baseCurrency,
};
}
/**
* Retrieve the items sections.
* @returns {IInventoryValuationItem[]}
*/
itemsSection(): IInventoryValuationItem[] {
return this.items.map(this.itemSectionMapper.bind(this));
}
/**
* Retrieve the total section of the sheet.
* @param {IInventoryValuationItem[]} items
* @returns {IInventoryValuationTotal}
*/
totalSection(items: IInventoryValuationItem[]): IInventoryValuationTotal {
const quantityPurchased = sumBy(items, (item) => item.quantityPurchased);
const purchaseCost = sumBy(items, (item) => item.purchaseCost);
return {
quantityPurchased,
purchaseCost,
quantityPurchasedFormatted: this.formatNumber(quantityPurchased, {
money: false,
}),
purchaseCostFormatted: this.formatNumber(purchaseCost),
currencyCode: this.baseCurrency,
};
}
/**
* Retrieve the sheet data.
* @returns
*/
reportData(): IInventoryValuationStatement {
const items = this.itemsSection();
const total = this.totalSection(items);
return { items, total };
}
}

View File

@@ -0,0 +1,125 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import {
IInventoryValuationReportQuery,
IInventoryValuationStatement,
IInventoryValuationSheetMeta,
} from 'interfaces';
import TenancyService from 'services/Tenancy/TenancyService';
import PurchasesByItems from './PurchasesByItems';
@Service()
export default class InventoryValuationReportService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults balance sheet filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery(): IInventoryValuationReportQuery {
return {
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
precision: 2,
divideOn1000: false,
showZero: false,
formatMoney: 'always',
negativeFormat: 'mines',
},
noneTransactions: false,
};
}
/**
* Retrieve the balance sheet meta.
* @param {number} tenantId -
* @returns {IBalanceSheetMeta}
*/
reportMetadata(tenantId: number): IInventoryValuationSheetMeta {
const settings = this.tenancy.settings(tenantId);
const organizationName = settings.get({
group: 'organization',
key: 'name',
});
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
return {
organizationName,
baseCurrency,
};
}
/**
* Retrieve balance sheet statement.
* -------------
* @param {number} tenantId
* @param {IBalanceSheetQuery} query
*
* @return {IBalanceSheetStatement}
*/
public async purchasesByItems(
tenantId: number,
query: IInventoryValuationReportQuery
): Promise<{
data: IInventoryValuationStatement,
query: IInventoryValuationReportQuery,
meta: IInventoryValuationSheetMeta,
}> {
const { Item, InventoryTransaction } = this.tenancy.models(tenantId);
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[purchases_by_items] trying to calculate the report.', {
filter,
tenantId,
});
const inventoryItems = await Item.query().where('type', 'inventory');
const inventoryItemsIds = inventoryItems.map((item) => item.id);
// Calculates the total inventory total quantity and rate `IN` transactions.
const inventoryTransactions = await InventoryTransaction.query().onBuild(
(builder: any) => {
builder.modify('itemsTotals');
builder.modify('INDirection');
// Filter the inventory items only.
builder.whereIn('itemId', inventoryItemsIds);
// Filter the date range of the sheet.
builder.modify('filterDateRange', filter.fromDate, filter.toDate)
}
);
const purchasesByItemsInstance = new PurchasesByItems(
filter,
inventoryItems,
inventoryTransactions,
baseCurrency
);
const purchasesByItemsData = purchasesByItemsInstance.reportData();
return {
data: purchasesByItemsData,
query: filter,
meta: this.reportMetadata(tenantId),
};
}
}

View File

@@ -0,0 +1,120 @@
import { get, sumBy } from 'lodash';
import FinancialSheet from '../FinancialSheet';
import { transformToMap } from 'utils';
import {
ISalesByItemsReportQuery,
IAccountTransaction,
ISalesByItemsItem,
ISalesByItemsTotal,
ISalesByItemsSheetStatement,
IItem,
} from 'interfaces';
export default class SalesByItemsReport extends FinancialSheet {
readonly baseCurrency: string;
readonly items: IItem[];
readonly itemsTransactions: Map<number, IAccountTransaction>;
readonly query: ISalesByItemsReportQuery;
/**
* Constructor method.
* @param {ISalesByItemsReportQuery} query
* @param {IItem[]} items
* @param {IAccountTransaction[]} itemsTransactions
* @param {string} baseCurrency
*/
constructor(
query: ISalesByItemsReportQuery,
items: IItem[],
itemsTransactions: IAccountTransaction[],
baseCurrency: string
) {
super();
this.baseCurrency = baseCurrency;
this.items = items;
this.itemsTransactions = transformToMap(itemsTransactions, 'itemId');
this.query = query;
this.numberFormat = this.query.numberFormat;
}
/**
* Retrieve the item purchase item, cost and average cost price.
* @param {number} itemId - Item id.
*/
getItemTransaction(
itemId: number
): { quantity: number; cost: number; average: number } {
const transaction = this.itemsTransactions.get(itemId);
const quantity = get(transaction, 'quantity', 0);
const cost = get(transaction, 'cost', 0);
const average = cost / quantity;
return { quantity, cost, average };
}
/**
* Mapping the given item section.
* @param {ISalesByItemsItem} item
* @returns
*/
itemSectionMapper(item: IItem): ISalesByItemsItem {
const meta = this.getItemTransaction(item.id);
return {
id: item.id,
name: item.name,
code: item.code,
quantitySold: meta.quantity,
soldCost: meta.cost,
averageSellPrice: meta.average,
quantitySoldFormatted: this.formatNumber(meta.quantity, {
money: false,
}),
soldCostFormatted: this.formatNumber(meta.cost),
averageSellPriceFormatted: this.formatNumber(meta.average),
currencyCode: this.baseCurrency,
};
}
/**
* Retrieve the items sections.
* @returns {ISalesByItemsItem[]}
*/
itemsSection(): ISalesByItemsItem[] {
return this.items.map(this.itemSectionMapper.bind(this));
}
/**
* Retrieve the total section of the sheet.
* @param {IInventoryValuationItem[]} items
* @returns {IInventoryValuationTotal}
*/
totalSection(items: ISalesByItemsItem[]): ISalesByItemsTotal {
const quantitySold = sumBy(items, (item) => item.quantitySold);
const soldCost = sumBy(items, (item) => item.soldCost);
return {
quantitySold,
soldCost,
quantitySoldFormatted: this.formatNumber(quantitySold, {
money: false,
}),
soldCostFormatted: this.formatNumber(soldCost),
currencyCode: this.baseCurrency,
};
}
/**
* Retrieve the sheet data.
* @returns {ISalesByItemsSheetStatement}
*/
reportData(): ISalesByItemsSheetStatement {
const items = this.itemsSection();
const total = this.totalSection(items);
return { items, total };
}
}

View File

@@ -0,0 +1,125 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import {
ISalesByItemsReportQuery,
ISalesByItemsSheetStatement,
ISalesByItemsSheetMeta
} from 'interfaces';
import TenancyService from 'services/Tenancy/TenancyService';
import SalesByItems from './SalesByItems';
@Service()
export default class SalesByItemsReportService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults balance sheet filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery(): ISalesByItemsReportQuery {
return {
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
precision: 2,
divideOn1000: false,
showZero: false,
formatMoney: 'always',
negativeFormat: 'mines',
},
noneTransactions: false,
};
}
/**
* Retrieve the balance sheet meta.
* @param {number} tenantId -
* @returns {IBalanceSheetMeta}
*/
reportMetadata(tenantId: number): ISalesByItemsSheetMeta {
const settings = this.tenancy.settings(tenantId);
const organizationName = settings.get({
group: 'organization',
key: 'name',
});
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
return {
organizationName,
baseCurrency,
};
}
/**
* Retrieve balance sheet statement.
* -------------
* @param {number} tenantId
* @param {IBalanceSheetQuery} query
*
* @return {IBalanceSheetStatement}
*/
public async salesByItems(
tenantId: number,
query: ISalesByItemsReportQuery
): Promise<{
data: ISalesByItemsSheetStatement,
query: ISalesByItemsReportQuery,
meta: ISalesByItemsSheetMeta,
}> {
const { Item, InventoryTransaction } = this.tenancy.models(tenantId);
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[sales_by_items] trying to calculate the report.', {
filter,
tenantId,
});
const inventoryItems = await Item.query().where('type', 'inventory');
const inventoryItemsIds = inventoryItems.map((item) => item.id);
// Calculates the total inventory total quantity and rate `IN` transactions.
const inventoryTransactions = await InventoryTransaction.query().onBuild(
(builder: any) => {
builder.modify('itemsTotals');
builder.modify('OUTDirection');
// Filter the inventory items only.
builder.whereIn('itemId', inventoryItemsIds);
// Filter the date range of the sheet.
builder.modify('filterDateRange', filter.fromDate, filter.toDate)
}
);
const purchasesByItemsInstance = new SalesByItems(
filter,
inventoryItems,
inventoryTransactions,
baseCurrency
);
const purchasesByItemsData = purchasesByItemsInstance.reportData();
return {
data: purchasesByItemsData,
query: filter,
meta: this.reportMetadata(tenantId),
};
}
}

View File

@@ -165,8 +165,6 @@ export default class InventoryAverageCostMethod
'transactionId',
'transactionType',
'createdAt',
'sellAccountId',
'costAccountId',
]),
};
switch (invTransaction.direction) {