import Joi from "joi"; import { isNull } from "lodash"; import { NullOr } from "../../../../utilities"; import { RuleValidator } from "../RuleValidator"; import { INullableValueObjectOptions, NullableValueObject } from "./NullableValueObject"; import { Result } from "./Result"; import { ResultCollection } from "./ResultCollection"; const DEFAULT_SCALE = 2; export interface IQuantityOptions extends INullableValueObjectOptions {} export interface IQuantityProps { amount: NullOr; scale?: number; } interface IQuantity { amount: NullOr; scale: number; } export interface QuantityObject { amount: number; scale: number; } const defaultQuantityProps = { amount: 0, scale: DEFAULT_SCALE, }; export class Quantity extends NullableValueObject { public static readonly DEFAULT_SCALE = DEFAULT_SCALE; public static readonly MIN_SCALE = 0; public static readonly MAX_SCALE = 2; private readonly _isNull: boolean; private readonly _options: IQuantityOptions; protected static validate( value: NullOr, scale: NullOr, options: IQuantityOptions = {} ) { const ruleNull = RuleValidator.RULE_ALLOW_NULL_OR_UNDEFINED.default( defaultQuantityProps.amount ); const ruleNumber = RuleValidator.RULE_IS_TYPE_NUMBER.label( options.label ? options.label : "quantity" ); const ruleString = RuleValidator.RULE_IS_TYPE_STRING.regex(/^[-]?\d+$/).label( options.label ? options.label : "quantity" ); const ruleScale = Joi.number() .min(Quantity.MIN_SCALE) .max(Quantity.MAX_SCALE) .label(options.label ? options.label : "quantity"); const rules = Joi.alternatives(ruleNull, ruleNumber, ruleString); const validationResults = new ResultCollection([ RuleValidator.validate>( Joi.alternatives(ruleNull, ruleNumber, ruleString), value ), RuleValidator.validate>( Joi.alternatives(ruleNull, RuleValidator.RULE_IS_TYPE_NUMBER, ruleScale), scale ), ]); if (validationResults.hasSomeFaultyResult()) { return validationResults.getFirstFaultyResult(); } // Convert the value to a number if it's a string let numericValue = typeof value === "string" ? parseInt(value, 10) : Number(value); // Check if scale is null, and set to default if so let numericScale = isNull(scale) ? Quantity.DEFAULT_SCALE : Number(scale); // Calculate the adjusted value const adjustedValue = numericValue / Math.pow(10, numericScale); return Result.ok(); } public static create( props: IQuantityProps = defaultQuantityProps, options: IQuantityOptions = {} ) { if (props === null) { throw new Error(`InvalidParams: props params is missing`); } const { amount = defaultQuantityProps.amount, scale = defaultQuantityProps.scale } = props; const _options = { label: "quantity", ...options, }; const validationResult = Quantity.validate(amount, scale, _options); if (validationResult.isFailure) { return Result.fail(validationResult.error); } let _amount: NullOr = Quantity._sanitize(amount); const _props = { amount: isNull(_amount) ? 0 : _amount, scale, }; return Result.ok(new this(_props, isNull(_amount), options)); } private static _sanitize(value: NullOr): NullOr { let _value: NullOr = null; if (typeof value === "string") { _value = parseInt(value, 10); } else { _value = value; } return _value; } private static _toNumber(value: NullOr, scale: number): number { if (isNull(value)) { return 0; } const factor = Math.pow(10, scale); const amount = Number(value) / factor; return Number(amount.toFixed(scale)); } constructor(quantity: IQuantity, isNull: boolean, options: IQuantityOptions) { super(quantity); this._isNull = Object.freeze(isNull); this._options = Object.freeze(options); } get amount(): NullOr { return this.isNull() ? null : Number(this.props?.amount); } get scale(): number { return this.isNull() ? 0 : Number(this.props?.scale); } public getAmount(): NullOr { return this.isNull() ? null : Number(this.props?.amount); } public getScale(): number { return this.isNull() ? 0 : Number(this.props?.scale); } public isEmpty = (): boolean => { return this.isNull(); }; public isNull = (): boolean => { return this._isNull; }; public toNumber(): number { return Quantity._toNumber(this.amount, this.scale); } public toString(): string { return this.isNull() ? "" : String(this.toNumber()); } public toPrimitive(): NullOr { if (this.scale !== Quantity.DEFAULT_SCALE) { return this.convertScale(Quantity.DEFAULT_SCALE).toPrimitive(); } else { return this.amount; } } public toPrimitives() { return this.toObject(); } public toObject(): QuantityObject { return { amount: this.amount ? this.amount : 0, scale: this.scale, }; } public convertScale(newScale: number): Quantity { if (newScale < Quantity.MIN_SCALE || newScale > Quantity.MAX_SCALE) { throw new Error(`Scale out of range: ${newScale}`); } if (this.isNull()) { return new Quantity({ amount: null, scale: newScale }, true, this._options); } const oldFactor = Math.pow(10, this.scale); const value = Number(this.amount) / oldFactor; const newFactor = Math.pow(10, newScale); const newValue = Math.round(value * newFactor); return new Quantity({ amount: newValue, scale: newScale }, false, this._options); } public hasSameScale(quantity: Quantity) { return this.scale === quantity.scale; } public increment(anotherQuantity?: Quantity) { if (this.isNull()) { return anotherQuantity ? Quantity.create(anotherQuantity.toObject()) : Quantity.create(); } if (!anotherQuantity) { return Quantity.create( { amount: Number(this.amount) + 1, scale: this.scale, }, this._options ); } if (!this.hasSameScale(anotherQuantity)) { return Result.fail(Error("No se pueden sumar cantidades con diferentes escalas.")); } return Quantity.create( { amount: Number(this.amount) + Number(anotherQuantity.amount), scale: this.scale, }, this._options ); } public decrement(anotherQuantity?: Quantity) { if (this.isNull()) { return anotherQuantity ? Quantity.create(anotherQuantity.toObject()) : Quantity.create(); } if (!anotherQuantity) { return Quantity.create( { amount: Number(this.amount) - 1, scale: this.scale, }, this._options ); } if (!this.hasSameScale(anotherQuantity)) { return Result.fail(Error("No se pueden restar cantidades con diferentes escalas.")); } return Quantity.create( { amount: Number(this.amount) - Number(anotherQuantity.amount), scale: this.scale, }, this._options ); } }