diff --git a/fyo/utils/format.ts b/fyo/utils/format.ts index 74a5a564..d53dd6f9 100644 --- a/fyo/utils/format.ts +++ b/fyo/utils/format.ts @@ -33,7 +33,7 @@ export function format( } if (field.fieldtype === FieldTypeEnum.Datetime) { - return formatDate(value, fyo); + return formatDatetime(value, fyo); } if (field.fieldtype === FieldTypeEnum.Check) { @@ -47,18 +47,33 @@ export function format( return String(value); } +function toDatetime(value: DocValue) { + if (typeof value === 'string') { + return DateTime.fromISO(value); + } else if (value instanceof Date) { + return DateTime.fromJSDate(value); + } else { + return DateTime.fromSeconds(value as number); + } +} + +function formatDatetime(value: DocValue, fyo: Fyo): string { + const dateFormat = + (fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT; + const formattedDatetime = toDatetime(value).toFormat(`${dateFormat} HH:mm:ss`); + + if (value === 'Invalid DateTime') { + return ''; + } + + return formattedDatetime; +} + function formatDate(value: DocValue, fyo: Fyo): string { const dateFormat = (fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT; - let dateValue: DateTime; - if (typeof value === 'string') { - dateValue = DateTime.fromISO(value); - } else if (value instanceof Date) { - dateValue = DateTime.fromJSDate(value); - } else { - dateValue = DateTime.fromSeconds(value as number); - } + const dateValue: DateTime = toDatetime(value); const formattedDate = dateValue.toFormat(dateFormat); if (value === 'Invalid DateTime') { diff --git a/reports/inventory/StockLedger.ts b/reports/inventory/StockLedger.ts index afa0d945..efcc72cc 100644 --- a/reports/inventory/StockLedger.ts +++ b/reports/inventory/StockLedger.ts @@ -1,72 +1,335 @@ -import { t } from 'fyo'; -import { RawValueMap } from 'fyo/core/types'; +import { Fyo, t } from 'fyo'; import { Action } from 'fyo/model/types'; +import { cloneDeep } from 'lodash'; +import { DateTime } from 'luxon'; import { ModelNameEnum } from 'models/types'; import getCommonExportActions from 'reports/commonExporter'; import { Report } from 'reports/Report'; import { ColumnField, ReportCell, ReportData, ReportRow } from 'reports/types'; -import { Field, RawValue } from 'schemas/types'; +import { Field } from 'schemas/types'; import { isNumeric } from 'src/utils'; +import { getRawStockLedgerEntries, getStockLedgerEntries } from './helpers'; +import { ComputedStockLedgerEntry } from './types'; export class StockLedger extends Report { static title = t`Stock Ledger`; static reportName = 'stock-ledger'; + usePagination: boolean = true; + _rawData?: ComputedStockLedgerEntry[]; loading: boolean = false; + shouldRefresh: boolean = false; + + item?: string; + location?: string; + fromDate?: string; + toDate?: string; + ascending?: boolean; + groupBy: 'none' | 'item' | 'location' = 'none'; + + constructor(fyo: Fyo) { + super(fyo); + this._setObservers(); + } + + async setDefaultFilters() { + if (!this.toDate) { + this.toDate = DateTime.now().plus({ days: 1 }).toISODate(); + this.fromDate = DateTime.now().minus({ years: 1 }).toISODate(); + } + } + async setReportData( filter?: string | undefined, force?: boolean | undefined ): Promise { this.loading = true; - this.reportData = await this._getReportData(); + this.reportData = await this._getReportData(force); this.loading = false; } - async _getReportData(): Promise { - const columns = this.getColumns(); - const fieldnames = columns.map(({ fieldname }) => fieldname); - const rawData = await this.fyo.db.getAllRaw( - ModelNameEnum.StockLedgerEntry, - { - fields: fieldnames, - } - ); - - return this.convertRawDataToReportData(rawData, columns); - } - - convertRawDataToReportData( - rawData: RawValueMap[], - fields: Field[] - ): ReportData { - const reportData: ReportData = []; - for (const row of rawData) { - reportData.push(this.convertRawDataRowToReportRow(row, fields)); + async _getReportData(force?: boolean): Promise { + if (this.shouldRefresh || force || !this._rawData?.length) { + await this._setRawData(); } - return reportData; + + const rawData = cloneDeep(this._rawData); + if (!rawData) { + return []; + } + + const filtered = this._getFilteredRawData(rawData); + const grouped = this._getGroupedRawData(filtered); + + return grouped.map((row) => this._convertRawDataRowToReportRow(row)); } - convertRawDataRowToReportRow(row: RawValueMap, fields: Field[]): ReportRow { + async _setRawData() { + const rawSLEs = await getRawStockLedgerEntries(this.fyo); + this._rawData = getStockLedgerEntries(rawSLEs); + } + + _getFilteredRawData(rawData: ComputedStockLedgerEntry[]) { + const filteredRawData: ComputedStockLedgerEntry[] = []; + if (!rawData.length) { + return []; + } + + const fromDate = this.fromDate ? new Date(this.fromDate) : null; + const toDate = this.toDate ? new Date(this.toDate) : null; + + if (!this.ascending) { + rawData.reverse(); + } + + let i = 0; + for (const idx in rawData) { + const row = rawData[idx]; + if (this.item && row.item !== this.item) { + continue; + } + + if (this.location && row.location !== this.location) { + continue; + } + + if (toDate && row.date > toDate) { + console.log('here'); + continue; + } + + if (fromDate && row.date < fromDate) { + console.log('here'); + continue; + } + + row.name = ++i; + filteredRawData.push(row); + } + + return filteredRawData; + } + + _getGroupedRawData(rawData: ComputedStockLedgerEntry[]) { + const groupBy = this.groupBy; + if (groupBy === 'none') { + return rawData; + } + + const groups: Map = new Map(); + for (const row of rawData) { + const key = row[groupBy]; + if (!groups.has(key)) { + groups.set(key, []); + } + + groups.get(key)?.push(row); + } + + const groupedRawData: (ComputedStockLedgerEntry | { name: null })[] = []; + let i = 0; + for (const key of groups.keys()) { + for (const row of groups.get(key) ?? []) { + row.name = ++i; + groupedRawData.push(row); + } + + groupedRawData.push({ name: null }); + } + + if (groupedRawData.at(-1)?.name === null) { + groupedRawData.pop(); + } + + return groupedRawData; + } + + _convertRawDataRowToReportRow( + row: ComputedStockLedgerEntry | { name: null } + ): ReportRow { const cells: ReportCell[] = []; - for (const { fieldname, fieldtype } of fields) { - const rawValue = row[fieldname] as RawValue; + const columns = this.getColumns(); + + if (row.name === null) { + return { + isEmpty: true, + cells: columns.map((c) => ({ + rawValue: '', + value: '', + width: c.width ?? 1, + })), + }; + } + + for (const col of columns) { + const fieldname = col.fieldname as keyof ComputedStockLedgerEntry; + const fieldtype = col.fieldtype; + + const rawValue = row[fieldname]; const value = this.fyo.format(rawValue, fieldtype); const align = isNumeric(fieldtype) ? 'right' : 'left'; + const isColoured = + fieldname === 'quantity' || fieldname === 'valueChange'; - cells.push({ rawValue, value, align }); + let color: 'red' | 'green' | undefined = undefined; + if (isColoured && rawValue > 0) { + color = 'green'; + } else if (isColoured && rawValue < 0) { + color = 'red'; + } + + cells.push({ rawValue, value, align, color, width: col.width }); } return { cells }; } - getColumns(): ColumnField[] { - return ( - this.fyo.schemaMap[ModelNameEnum.StockLedgerEntry]?.fields ?? [] - ).filter((f) => !f.meta); + _setObservers() { + const listener = () => (this.shouldRefresh = true); + + this.fyo.doc.observer.on( + `sync:${ModelNameEnum.StockLedgerEntry}`, + listener + ); + + this.fyo.doc.observer.on( + `delete:${ModelNameEnum.StockLedgerEntry}`, + listener + ); } - getFilters(): Field[] | Promise { - return []; + getColumns(): ColumnField[] { + return [ + { + fieldname: 'name', + label: '#', + fieldtype: 'Int', + width: 0.5, + }, + { + fieldname: 'date', + label: 'Date', + fieldtype: 'Datetime', + width: 1.25, + }, + { + fieldname: 'item', + label: 'Item', + fieldtype: 'Link', + }, + { + fieldname: 'location', + label: 'Location', + fieldtype: 'Link', + }, + { + fieldname: 'quantity', + label: 'Quantity', + fieldtype: 'Float', + }, + { + fieldname: 'balanceQuantity', + label: 'Balance Qty.', + fieldtype: 'Float', + }, + { + fieldname: 'incomingRate', + label: 'Incoming rate', + fieldtype: 'Currency', + }, + { + fieldname: 'valuationRate', + label: 'Valuation rate', + fieldtype: 'Currency', + }, + { + fieldname: 'balanceValue', + label: 'Balance Value', + fieldtype: 'Currency', + }, + { + fieldname: 'valueChange', + label: 'Value Change', + fieldtype: 'Currency', + }, + { + fieldname: 'referenceName', + label: 'Ref. Name', + fieldtype: 'DynamicLink', + }, + { + fieldname: 'referenceType', + label: 'Ref. Type', + fieldtype: 'Data', + }, + ]; + } + + getFilters(): Field[] { + return [ + /* + { + fieldtype: 'Select', + options: [ + { label: t`All`, value: 'All' }, + { label: t`Stock Movements`, value: 'StockMovement' }, + ], + + label: t`Ref Type`, + fieldname: 'referenceType', + placeholder: t`Ref Type`, + }, + { + fieldtype: 'DynamicLink', + label: t`Ref Name`, + references: 'referenceType', + placeholder: t`Ref Name`, + emptyMessage: t`Change Ref Type`, + fieldname: 'referenceName', + }, + */ + { + fieldtype: 'Link', + target: 'Item', + placeholder: t`Item`, + label: t`Item`, + fieldname: 'item', + }, + { + fieldtype: 'Link', + target: 'Location', + placeholder: t`Location`, + label: t`Location`, + fieldname: 'location', + }, + { + fieldtype: 'Date', + placeholder: t`From Date`, + label: t`From Date`, + fieldname: 'fromDate', + }, + { + fieldtype: 'Date', + placeholder: t`To Date`, + label: t`To Date`, + fieldname: 'toDate', + }, + { + fieldtype: 'Select', + label: t`Group By`, + fieldname: 'groupBy', + options: [ + { label: t`None`, value: 'none' }, + { label: t`Item`, value: 'item' }, + { label: t`Location`, value: 'location' }, + ], + }, + { + fieldtype: 'Check', + label: t`Ascending Order`, + fieldname: 'ascending', + }, + ] as Field[]; } getActions(): Action[] { diff --git a/reports/inventory/helpers.ts b/reports/inventory/helpers.ts new file mode 100644 index 00000000..7fdef982 --- /dev/null +++ b/reports/inventory/helpers.ts @@ -0,0 +1,91 @@ +import { Fyo } from 'fyo'; +import { StockQueue } from 'models/inventory/stockQueue'; +import { ModelNameEnum } from 'models/types'; +import { safeParseFloat, safeParseInt } from 'utils/index'; +import { ComputedStockLedgerEntry, RawStockLedgerEntry } from './types'; + +export async function getRawStockLedgerEntries(fyo: Fyo) { + const fieldnames = [ + 'name', + 'date', + 'item', + 'rate', + 'quantity', + 'location', + 'referenceName', + 'referenceType', + ]; + + return (await fyo.db.getAllRaw(ModelNameEnum.StockLedgerEntry, { + fields: fieldnames, + orderBy: 'date', + order: 'asc', + })) as RawStockLedgerEntry[]; +} + +export function getStockLedgerEntries( + rawSLEs: RawStockLedgerEntry[] +): ComputedStockLedgerEntry[] { + type Item = string; + type Location = string; + + const computedSLEs: ComputedStockLedgerEntry[] = []; + const stockQueues: Record> = {}; + + for (const sle of rawSLEs) { + const name = safeParseInt(sle.name); + const date = new Date(sle.date); + const rate = safeParseFloat(sle.rate); + const { item, location, quantity, referenceName, referenceType } = sle; + + if (quantity === 0) { + continue; + } + + stockQueues[item] ??= {}; + stockQueues[item][location] ??= new StockQueue(); + + const q = stockQueues[item][location]; + const initialValue = q.value; + + let incomingRate: number | null; + if (quantity > 0) { + incomingRate = q.inward(rate, quantity); + } else { + incomingRate = q.outward(-quantity); + } + + if (incomingRate === null) { + continue; + } + + const balanceQuantity = q.quantity; + const valuationRate = q.fifo; + const balanceValue = q.value; + const valueChange = balanceValue - initialValue; + + const csle: ComputedStockLedgerEntry = { + name, + date, + + item, + location, + + quantity, + balanceQuantity, + + incomingRate, + valuationRate, + + balanceValue, + valueChange, + + referenceName, + referenceType, + }; + + computedSLEs.push(csle); + } + + return computedSLEs; +} diff --git a/reports/inventory/types.ts b/reports/inventory/types.ts new file mode 100644 index 00000000..36c97092 --- /dev/null +++ b/reports/inventory/types.ts @@ -0,0 +1,33 @@ + +export interface RawStockLedgerEntry { + name: string; + date: string; + item: string; + rate: string; + quantity: number; + location: string; + referenceName: string; + referenceType: string; + [key: string]: unknown; +} + + +export interface ComputedStockLedgerEntry{ + name: number; + date: Date; + + item: string; + location:string; + + quantity: number; + balanceQuantity: number; + + incomingRate: number; + valuationRate:number; + + balanceValue:number; + valueChange:number; + + referenceName: string; + referenceType: string; +}