diff --git a/server/src/api/controllers/FinancialStatements.ts b/server/src/api/controllers/FinancialStatements.ts index 43a0eb9b1..928b940c6 100644 --- a/server/src/api/controllers/FinancialStatements.ts +++ b/server/src/api/controllers/FinancialStatements.ts @@ -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; } } diff --git a/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts b/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts new file mode 100644 index 000000000..0353870b3 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts @@ -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); + } + } +} diff --git a/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts b/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts new file mode 100644 index 000000000..835e6412f --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts @@ -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); + } + } +} diff --git a/server/src/api/controllers/FinancialStatements/SalesByItems.ts b/server/src/api/controllers/FinancialStatements/SalesByItems.ts new file mode 100644 index 000000000..7b9fb4602 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/SalesByItems.ts @@ -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); + } + } +} diff --git a/server/src/api/controllers/Items.ts b/server/src/api/controllers/Items.ts index 0d34d6317..b2ed1f784 100644 --- a/server/src/api/controllers/Items.ts +++ b/server/src/api/controllers/Items.ts @@ -16,7 +16,7 @@ export default class ItemsController extends BaseController { @Inject() dynamicListService: DynamicListingService; - + /** * Router constructor. */ diff --git a/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js b/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js index 6a80545e6..50fe6d396 100644 --- a/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js +++ b/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js @@ -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'); }; diff --git a/server/src/interfaces/BalanceSheet.ts b/server/src/interfaces/BalanceSheet.ts index 29f6bf337..9212161bb 100644 --- a/server/src/interfaces/BalanceSheet.ts +++ b/server/src/interfaces/BalanceSheet.ts @@ -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[]; } diff --git a/server/src/interfaces/IInventoryValuationSheet.ts b/server/src/interfaces/IInventoryValuationSheet.ts new file mode 100644 index 000000000..9bf4f151b --- /dev/null +++ b/server/src/interfaces/IInventoryValuationSheet.ts @@ -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 +}; + diff --git a/server/src/interfaces/SalesByItemsSheet.ts b/server/src/interfaces/SalesByItemsSheet.ts new file mode 100644 index 000000000..50ddfaea0 --- /dev/null +++ b/server/src/interfaces/SalesByItemsSheet.ts @@ -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 +}; + diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 444d9d852..c2738ab30 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -40,4 +40,6 @@ export * from './ARAgingSummaryReport'; export * from './APAgingSummaryReport'; export * from './Mailable'; export * from './InventoryAdjustment'; -export * from './Setup' \ No newline at end of file +export * from './Setup' +export * from './IInventoryValuationSheet'; +export * from './SalesByItemsSheet'; \ No newline at end of file diff --git a/server/src/models/InventoryTransaction.js b/server/src/models/InventoryTransaction.js index 391c4dfcf..87146ced0 100644 --- a/server/src/models/InventoryTransaction.js +++ b/server/src/models/InventoryTransaction.js @@ -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'); + }, }; } diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts index a1ce9c0f1..2f73a3cf0 100644 --- a/server/src/services/Accounting/JournalCommands.ts +++ b/server/src/services/Accounting/JournalCommands.ts @@ -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); } diff --git a/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts b/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts new file mode 100644 index 000000000..e60d798fe --- /dev/null +++ b/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheet.ts @@ -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; + readonly OUTInventoryCostLots: Map; + 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 }; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts b/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts new file mode 100644 index 000000000..ec7642c28 --- /dev/null +++ b/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts @@ -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), + } + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts b/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts new file mode 100644 index 000000000..c6b119ae7 --- /dev/null +++ b/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItems.ts @@ -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; + 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 }; + } +} diff --git a/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts b/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts new file mode 100644 index 000000000..aa52120c1 --- /dev/null +++ b/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts @@ -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), + }; + } +} diff --git a/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts b/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts new file mode 100644 index 000000000..d527609a7 --- /dev/null +++ b/server/src/services/FinancialStatements/SalesByItems/SalesByItems.ts @@ -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; + 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 }; + } +} diff --git a/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts b/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts new file mode 100644 index 000000000..bbcc05100 --- /dev/null +++ b/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts @@ -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), + }; + } +} diff --git a/server/src/services/Inventory/InventoryAverageCost.ts b/server/src/services/Inventory/InventoryAverageCost.ts index 7833128a5..f5a55c42f 100644 --- a/server/src/services/Inventory/InventoryAverageCost.ts +++ b/server/src/services/Inventory/InventoryAverageCost.ts @@ -165,8 +165,6 @@ export default class InventoryAverageCostMethod 'transactionId', 'transactionType', 'createdAt', - 'sellAccountId', - 'costAccountId', ]), }; switch (invTransaction.direction) {