import Joi from "joi"; import { NullOr } from "../../../../utilities"; import { DomainError, handleDomainError } from "../errors"; 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 | null; 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; const ruleEmpty = RuleValidator.RULE_ALLOW_EMPTY; const ruleNumber = RuleValidator.RULE_IS_TYPE_NUMBER.label( options.label ? options.label : "amount" ); const ruleString = RuleValidator.RULE_IS_TYPE_STRING.regex(/^[-]?\d+$/).label( options.label ? options.label : "amount" ); const ruleScale = Joi.number() .min(Quantity.MIN_SCALE) .max(Quantity.MAX_SCALE) .label(options.label ? options.label : "scale"); const validationResults = new ResultCollection([ RuleValidator.validate>( Joi.alternatives(ruleNull, ruleEmpty, ruleNumber, ruleString), value ), RuleValidator.validate>( Joi.alternatives( RuleValidator.RULE_IS_TYPE_NUMBER.label(options.label ? options.label : "scale"), ruleScale ), scale ), ]); if (validationResults.hasSomeFaultyResult()) { return validationResults.getFirstFaultyResult(); } 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( handleDomainError(DomainError.INVALID_INPUT_DATA, validationResult.error.message, _options) ); } let _amount: NullOr = Quantity._sanitize(amount); const _props = { amount: _amount === null ? 0 : _amount, scale, }; return Result.ok(new Quantity(_props, _amount === null, options)); } public static createFromFormattedValue( value: NullOr, _options: IQuantityOptions = {} ) { if (value === null || value === "") { return Quantity.create({ amount: null, scale: Quantity.DEFAULT_SCALE, }); } const valueStr = String(value); const [integerPart, decimalPart] = valueStr.split(","); let _amount = integerPart; let _scale = 2; if (decimalPart === undefined) { // 99 _scale = 0; } else { if (decimalPart === "") { // 99, _amount = integerPart + decimalPart.padEnd(1, "0"); _scale = 1; } if (decimalPart.length === 1) { // 99,1 _amount = integerPart + decimalPart.padEnd(1, "0"); _scale = 1; } else { if (decimalPart.length === 2) { // 99,12 _amount = integerPart + decimalPart.padEnd(2, "0"); _scale = 2; } } } return Quantity.create( { amount: _amount, scale: _scale, }, _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 _toString(value: NullOr, scale: number): string { if (value === null) { return ""; } const factor = Math.pow(10, scale); const amount = Number(value) / factor; return 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 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 toString(): string { return Quantity._toString(this.amount, this.scale); } public toNumber(): number { return this.isNull() ? 0 : Number(this.toString()); } public toFormat(): string { return this._isNull ? "" : Intl.NumberFormat("es-ES", { maximumFractionDigits: 2, }).format(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, 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 ); } }