diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts index d31954398..bac67231c 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts @@ -1,17 +1,17 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { query, ValidationChain } from 'express-validator'; -import moment from 'moment'; +import { query, ValidationChain, ValidationSchema } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseFinancialReportController from './BaseFinancialReportController'; -import SalesByItemsReportService from '@/services/FinancialStatements/SalesByItems/SalesByItemsService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { SalesByItemsApplication } from '@/services/FinancialStatements/SalesByItems/SalesByItemsApplication'; @Service() export default class SalesByItemsReportController extends BaseFinancialReportController { @Inject() - salesByItemsService: SalesByItemsReportService; + salesByItemsApp: SalesByItemsApplication; /** * Router constructor. @@ -24,13 +24,14 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon CheckPolicies(ReportsAction.READ_SALES_BY_ITEMS, AbilitySubject.Report), this.validationSchema, this.validationResult, - asyncMiddleware(this.purchasesByItems.bind(this)) + asyncMiddleware(this.salesByItems.bind(this)) ); return router; } /** * Validation schema. + * @returns {ValidationChain[]} */ private get validationSchema(): ValidationChain[] { return [ @@ -60,26 +61,44 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon * @param {Request} req - * @param {Response} res - */ - private async purchasesByItems( - req: Request, - res: Response, - next: NextFunction - ) { + private async salesByItems(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; const filter = this.matchedQueryData(req); + const accept = this.accepts(req); - try { - const { data, query, meta } = await this.salesByItemsService.salesByItems( - tenantId, - filter + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_XLSX, + ]); + // Retrieves the csv format. + if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.salesByItemsApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the json table format. + } else if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.salesByItemsApp.table(tenantId, filter); + + return res.status(200).send(table); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = this.salesByItemsApp.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), - }); - } catch (error) { - next(error); + return res.send(buffer); + // Retrieves the json format. + } else { + const sheet = await this.salesByItemsApp.sheet(tenantId, filter); + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/interfaces/SalesByItemsSheet.ts b/packages/server/src/interfaces/SalesByItemsSheet.ts index 0e0a41f9e..84f49960b 100644 --- a/packages/server/src/interfaces/SalesByItemsSheet.ts +++ b/packages/server/src/interfaces/SalesByItemsSheet.ts @@ -1,45 +1,54 @@ -import { - INumberFormatQuery, -} from './FinancialStatements'; +import { INumberFormatQuery } from './FinancialStatements'; +import { IFinancialTable } from './Table'; export interface ISalesByItemsReportQuery { fromDate: Date | string; toDate: Date | string; - itemsIds: number[], + itemsIds: number[]; numberFormat: INumberFormatQuery; noneTransactions: boolean; - onlyActive: boolean; -}; + onlyActive: boolean; +} export interface ISalesByItemsSheetMeta { - organizationName: string, - baseCurrency: string, -}; + 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, -}; + 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, + quantitySold: number; + soldCost: number; + quantitySoldFormatted: string; + soldCostFormatted: string; + currencyCode: string; +} + +export type ISalesByItemsSheetData = { + items: ISalesByItemsItem[]; + total: ISalesByItemsTotal; }; -export type ISalesByItemsSheetStatement = { - items: ISalesByItemsItem[], - total: ISalesByItemsTotal -} | {}; +export interface ISalesByItemsSheet { + data: ISalesByItemsSheetData; + query: ISalesByItemsReportQuery; + meta: ISalesByItemsSheetMeta; +} +export interface ISalesByItemsTable extends IFinancialTable { + query: ISalesByItemsReportQuery; + meta: ISalesByItemsSheetMeta; +} \ No newline at end of file diff --git a/packages/server/src/lib/Xlsx/TableSheet.tsx b/packages/server/src/lib/Xlsx/TableSheet.ts similarity index 100% rename from packages/server/src/lib/Xlsx/TableSheet.tsx rename to packages/server/src/lib/Xlsx/TableSheet.ts diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts new file mode 100644 index 000000000..b3b51869f --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts @@ -0,0 +1,74 @@ +import { Inject, Service } from 'typedi'; +import { + ISalesByItemsReportQuery, + ISalesByItemsSheet, + ISalesByItemsSheetData, + ISalesByItemsTable, +} from '@/interfaces'; +import { SalesByItemsReportService } from './SalesByItemsService'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; +import { SalesByItemsExport } from './SalesByItemsExport'; + +@Service() +export class SalesByItemsApplication { + @Inject() + private salesByItemsSheet: SalesByItemsReportService; + + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + @Inject() + private salesByItemsExport: SalesByItemsExport; + + /** + * Retrieves the sales by items report in json format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public sheet( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsSheet.salesByItems(tenantId, filter); + } + + /** + * Retrieves the sales by items report in table format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public table( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsTable.table(tenantId, filter); + } + + /** + * Retrieves the sales by items report in csv format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public csv( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsExport.csv(tenantId, filter); + } + + /** + * Retrieves the sales by items report in xlsx format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public xlsx( + tenantId: number, + filter: ISalesByItemsReportQuery + ): Promise { + return this.salesByItemsExport.xlsx(tenantId, filter); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts new file mode 100644 index 000000000..067aab546 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; + +@Service() +export class SalesByItemsExport { + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + /** + * Retrieves the trial balance sheet in XLSX format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: ISalesByItemsReportQuery) { + const table = await this.salesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToXLSX(); + + return tableSheet.convertToBuffer(tableCsv, 'xlsx'); + } + + /** + * Retrieves the trial balance sheet in CSV format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: ISalesByItemsReportQuery + ): Promise { + const table = await this.salesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts index 6f81e1489..bad73e34b 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts @@ -2,15 +2,15 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; import { ISalesByItemsReportQuery, - ISalesByItemsSheetStatement, - ISalesByItemsSheetMeta + ISalesByItemsSheetMeta, + ISalesByItemsSheet, } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import SalesByItems from './SalesByItems'; import { Tenant } from '@/system/models'; @Service() -export default class SalesByItemsReportService { +export class SalesByItemsReportService { @Inject() tenancy: TenancyService; @@ -63,20 +63,14 @@ export default class SalesByItemsReportService { /** * Retrieve balance sheet statement. - * ------------- * @param {number} tenantId * @param {IBalanceSheetQuery} query - * - * @return {IBalanceSheetStatement} + * @return {Promise} */ public async salesByItems( tenantId: number, query: ISalesByItemsReportQuery - ): Promise<{ - data: ISalesByItemsSheetStatement, - query: ISalesByItemsReportQuery, - meta: ISalesByItemsSheetMeta, - }> { + ): Promise { const { Item, InventoryTransaction } = this.tenancy.models(tenantId); const tenant = await Tenant.query() @@ -107,20 +101,19 @@ export default class SalesByItemsReportService { builder.whereIn('itemId', inventoryItemsIds); // Filter the date range of the sheet. - builder.modify('filterDateRange', filter.fromDate, filter.toDate) + builder.modify('filterDateRange', filter.fromDate, filter.toDate); } ); - - const purchasesByItemsInstance = new SalesByItems( + const sheet = new SalesByItems( filter, inventoryItems, inventoryTransactions, - tenant.metadata.baseCurrency, + tenant.metadata.baseCurrency ); - const purchasesByItemsData = purchasesByItemsInstance.reportData(); + const salesByItemsData = sheet.reportData(); return { - data: purchasesByItemsData, + data: salesByItemsData, query: filter, meta: this.reportMetadata(tenantId), }; diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts new file mode 100644 index 000000000..f876e9a02 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts @@ -0,0 +1,102 @@ +import * as R from 'ramda'; +import { + ISalesByItemsItem, + ISalesByItemsSheetStatement, + ISalesByItemsTotal, + ITableColumn, + ITableRow, +} from '@/interfaces'; +import { tableRowMapper } from '@/utils'; +import FinancialSheet from '../FinancialSheet'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import { ROW_TYPE } from './constants'; + +export class SalesByItemsTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private readonly data: ISalesByItemsSheetStatement; + + /** + * Constructor method. + * @param {ISalesByItemsSheetStatement} data + */ + constructor(data: ISalesByItemsSheetStatement) { + super(); + this.data = data; + } + + /** + * Retrieves the common table accessors. + * @returns {ITableColumn[]} + */ + private commonTableAccessors() { + return [ + { key: 'item_name', accessor: 'name' }, + { key: 'sold_quantity', accessor: 'quantitySoldFormatted' }, + { key: 'sold_amount', accessor: 'soldCostFormatted' }, + { key: 'average_price', accessor: 'averageSellPriceFormatted' }, + ]; + } + + /** + * Maps the given item node to table row. + * @param {ISalesByItemsItem} item + * @returns {ITableRow} + */ + private itemMap = (item: ISalesByItemsItem): ITableRow => { + const columns = this.commonTableAccessors(); + const meta = { + rowTypes: [ROW_TYPE.ITEM], + }; + return tableRowMapper(item, columns, meta); + }; + + /** + * Maps the given items nodes to table rows. + * @param {ISalesByItemsItem[]} items + * @returns {ITableRow[]} + */ + private itemsMap = (items: ISalesByItemsItem[]): ITableRow[] => { + return R.map(this.itemMap, items); + }; + + /** + * Maps the given total node to table row. + * @param {ISalesByItemsTotal} total + * @returns {ITableRow[]} + */ + private totalMap = (total: ISalesByItemsTotal) => { + const columns = this.commonTableAccessors(); + const meta = { + rowTypes: [ROW_TYPE.TOTAL], + }; + return tableRowMapper(total, columns, meta); + }; + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableData(): ITableRow[] { + const itemsRows = this.itemsMap(this.data.items); + const totalRow = this.totalMap(this.data.total); + + return [...itemsRows, totalRow]; + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + const columns = [ + { key: 'item_name', label: 'Item name' }, + { key: 'sold_quantity', label: 'Sold quantity' }, + { key: 'sold_amount', label: 'Sold amount' }, + { key: 'average_price', label: 'Average price' }, + ]; + return R.compose(this.tableColumnsCellIndexing)(columns); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.ts new file mode 100644 index 000000000..c947cfa86 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.ts @@ -0,0 +1,33 @@ +import { Inject, Service } from 'typedi'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsReportService } from './SalesByItemsService'; +import { SalesByItemsTable } from './SalesByItemsTable'; + +@Service() +export class SalesByItemsTableInjectable { + @Inject() + private salesByItemSheet: SalesByItemsReportService; + + /** + * Retrieves the sales by items report in table format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public async table(tenantId: number, filter: ISalesByItemsReportQuery) { + const { data, query, meta } = await this.salesByItemSheet.salesByItems( + tenantId, + filter + ); + const table = new SalesByItemsTable(data); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableData(), + }, + meta, + query, + }; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts b/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts new file mode 100644 index 000000000..0eb1e2311 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts @@ -0,0 +1,6 @@ + + +export enum ROW_TYPE { + ITEM = 'ITEM', + TOTAL = 'TOTAL', +} \ No newline at end of file diff --git a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx index 8b06996dc..16c2ad44f 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx @@ -1,7 +1,7 @@ // @ts-nocheck -import React, { createContext, useContext } from 'react'; +import { createContext, useContext } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useSalesByItems } from '@/hooks/query'; +import { useSalesByItemsTable } from '@/hooks/query'; import { transformFilterFormToQuery } from '../common'; const SalesByItemsContext = createContext(); @@ -12,7 +12,7 @@ function SalesByItemProvider({ query, ...props }) { isFetching, isLoading, refetch, - } = useSalesByItems( + } = useSalesByItemsTable( { ...transformFilterFormToQuery(query), }, diff --git a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx index 56b025f35..9ac5a46a0 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx @@ -19,6 +19,7 @@ import withSalesByItemsActions from './withSalesByItemsActions'; import { compose, saveInvoke } from '@/utils'; import { useSalesByItemsContext } from './SalesByItemProvider'; +import { SalesByItemsSheetExportMenu } from './components'; function SalesByItemsActionsBar({ // #withSalesByItems @@ -108,11 +109,18 @@ function SalesByItemsActionsBar({ icon={} text={} /> -