Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import { FormulaComponentDto } from "../dtos/FormulaDto";
|
||||
import FormulaHelpers from "../utils/FormulaHelpers";
|
||||
|
||||
export default function FormulaComponentBadge({ item }: { item: FormulaComponentDto }) {
|
||||
return (
|
||||
<div>
|
||||
{item.type === "variable" && <span className="text-muted-foreground text-sm">{item.value}</span>}
|
||||
{item.type === "operator" && <span className="text-foreground text-sm font-bold">{FormulaHelpers.getOperatorSymbol(item.value)}</span>}
|
||||
{item.type === "parenthesis" && <span className="text-muted-foreground">{item.value === "OPEN" ? "(" : ")"}</span>}
|
||||
{item.type === "value" && <span className="text-sm font-bold text-blue-900">{item.value}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Form } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import Modal from "~/components/ui/modals/Modal";
|
||||
import { FormulaComponentDto, FormulaOperatorTypes, FormulaComponentType } from "../dtos/FormulaDto";
|
||||
import FormulaHelpers from "../utils/FormulaHelpers";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
import InputRadioGroupCards from "~/components/ui/input/InputRadioGroupCards";
|
||||
|
||||
export default function FormulaComponentModal({
|
||||
item,
|
||||
idx,
|
||||
order,
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
item: FormulaComponentDto | undefined;
|
||||
idx: number | undefined;
|
||||
order: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (item: FormulaComponentDto) => void;
|
||||
onRemove?: (idx: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [type, setType] = useState<FormulaComponentType>(item?.type ?? "variable");
|
||||
const [value, setValue] = useState<string>(item?.value ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setType(item?.type ?? "variable");
|
||||
setValue(item?.value ?? "");
|
||||
}, [item]);
|
||||
|
||||
function onConfirm() {
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
onSave({ order, type, value });
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
function onClosing() {
|
||||
setType("variable");
|
||||
setValue("");
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={onClosing} size="lg">
|
||||
<Form onSubmit={onSubmit} className="inline-block h-full w-full overflow-visible p-1 text-left align-bottom sm:align-middle">
|
||||
<input name="action" type="hidden" value="create" readOnly hidden />
|
||||
<div className="mt-3">
|
||||
<h3 className="text-foreground text-lg font-medium leading-6">
|
||||
{idx === undefined ? t("pages.formulas.components.new") : t("pages.formulas.components.edit")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2" key={idx}>
|
||||
<div className="grid gap-2">
|
||||
<div className="">
|
||||
<InputRadioGroupCards
|
||||
columns={3}
|
||||
name="type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e?.toString() as FormulaComponentType)}
|
||||
required
|
||||
options={[
|
||||
{ name: t("pages.formulas.components.types.variable"), value: "variable" },
|
||||
{ name: t("pages.formulas.components.types.operator"), value: "operator" },
|
||||
// { name: "parenthesis", value: "parenthesis" },
|
||||
{ name: t("pages.formulas.components.types.value"), value: "value" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{type === "variable" ? (
|
||||
<InputText autoFocus name="name" title={t("shared.name")} value={value} setValue={(e) => setValue(e.toString())} required />
|
||||
) : type === "operator" ? (
|
||||
<InputSelect
|
||||
autoFocus
|
||||
name="operator"
|
||||
title={t("pages.formulas.operator")}
|
||||
value={value}
|
||||
setValue={(e) => setValue(FormulaHelpers.getOperatorType(e?.toString() ?? ""))}
|
||||
required
|
||||
options={FormulaOperatorTypes.map((item) => {
|
||||
return { name: t(`pages.formulas.operators.${item}`), value: item };
|
||||
})}
|
||||
/>
|
||||
) : type === "parenthesis" ? (
|
||||
<InputSelect
|
||||
autoFocus
|
||||
name="parenthesis"
|
||||
title={t("pages.formulas.parenthesis.title")}
|
||||
value={value}
|
||||
setValue={(e) => setValue(FormulaHelpers.getParenthesisType(e?.toString() ?? ""))}
|
||||
required
|
||||
options={["OPEN", "CLOSE"].map((item) => {
|
||||
return { name: t(`pages.formulas.parenthesis.${item}`), value: item };
|
||||
})}
|
||||
/>
|
||||
) : type === "value" ? (
|
||||
<InputText autoFocus name="value" title={t("shared.value")} value={value} setValue={(e) => setValue(e.toString())} required />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-between space-x-2">
|
||||
<div>
|
||||
{onRemove && idx !== undefined && (
|
||||
<ButtonSecondary
|
||||
type="button"
|
||||
destructive
|
||||
onClick={() => {
|
||||
onRemove(idx);
|
||||
onClosing();
|
||||
}}
|
||||
>
|
||||
{t("shared.remove")}
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<ButtonPrimary type="submit">{t("shared.save")}</ButtonPrimary>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useNavigation, useNavigate, Form } from "react-router";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
import { FormulaCalculationTriggerTypes, FormulaComponentDto, FormulaDto, FormulaResultAsTypes } from "../dtos/FormulaDto";
|
||||
import FormulaHelpers from "../utils/FormulaHelpers";
|
||||
import FormulaComponentModal from "./FormulaComponentModal";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
|
||||
import FormulaComponentBadge from "./FormulaComponentBadge";
|
||||
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export default function FormulaForm({ item, onDelete }: { item?: FormulaDto; onDelete?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const confirmModalDelete = useRef<RefConfirmModal>(null);
|
||||
|
||||
const [showModal, setShowModal] = useState<{
|
||||
item?: FormulaComponentDto;
|
||||
idx?: number;
|
||||
}>();
|
||||
|
||||
const [name, setName] = useState(item?.name || "");
|
||||
const [description, setDescription] = useState(item?.description || "");
|
||||
const [resultAs, setResultAs] = useState(item?.resultAs || "number");
|
||||
const [calculationTrigger, setCalculationTrigger] = useState(item?.calculationTrigger || "IF_UNSET");
|
||||
const [withLogs, setWithLogs] = useState(item?.withLogs || false);
|
||||
|
||||
const [components, setComponents] = useState<FormulaComponentDto[]>(item?.components || []);
|
||||
|
||||
function onSave(item: FormulaComponentDto) {
|
||||
const idx = showModal?.idx;
|
||||
if (idx !== undefined) {
|
||||
components[idx] = item;
|
||||
} else {
|
||||
components.push({
|
||||
...item,
|
||||
order: components.length + 1,
|
||||
});
|
||||
}
|
||||
setComponents([...components]);
|
||||
setShowModal(undefined);
|
||||
}
|
||||
function getActionName() {
|
||||
return item ? "edit" : "new";
|
||||
}
|
||||
function onDeleteClick() {
|
||||
confirmModalDelete.current?.show(
|
||||
t("pages.formulas.confirmDelete.title"),
|
||||
t("shared.delete"),
|
||||
t("shared.cancel"),
|
||||
t("pages.formulas.confirmDelete.description")
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
|
||||
<input type="hidden" name="action" value={getActionName()} hidden readOnly />
|
||||
{components.map((component, index) => {
|
||||
return <input type="hidden" name="components[]" value={JSON.stringify(component)} key={index} hidden readOnly />;
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InputText autoFocus name="name" title={t("shared.name")} value={name} setValue={setName} required />
|
||||
<InputText name="description" title={t("pages.formulas.fields.description")} value={description} setValue={setDescription} />
|
||||
<InputSelect
|
||||
name="resultAs"
|
||||
title={t("pages.formulas.fields.resultAs")}
|
||||
value={resultAs}
|
||||
setValue={(e) => setResultAs(FormulaHelpers.getResultAs(e?.toString() ?? ""))}
|
||||
options={FormulaResultAsTypes.map((item) => {
|
||||
return { name: t(`pages.formulas.resultAs.${item}`), value: item };
|
||||
})}
|
||||
/>
|
||||
|
||||
<InputSelect
|
||||
name="calculationTrigger"
|
||||
title={t("pages.formulas.fields.calculationTrigger")}
|
||||
value={calculationTrigger}
|
||||
setValue={(e) => setCalculationTrigger(FormulaHelpers.getCalculationTrigger(e?.toString() ?? ""))}
|
||||
options={FormulaCalculationTriggerTypes.map((item) => {
|
||||
return { name: t(`pages.formulas.calculationTriggers.${item}`), value: item };
|
||||
})}
|
||||
/>
|
||||
|
||||
<InputCheckboxWithDescription
|
||||
className="col-span-2"
|
||||
name="withLogs"
|
||||
title={t("pages.formulas.fields.withLogs")}
|
||||
description={t("pages.formulas.fields.withLogsDescription")}
|
||||
value={withLogs}
|
||||
setValue={setWithLogs}
|
||||
/>
|
||||
|
||||
<div className="col-span-2">
|
||||
<div className="mb-1 flex items-center justify-between space-x-2 text-xs">
|
||||
<label className="text-muted-foreground font-medium">{t("pages.formulas.components.title")}</label>
|
||||
<button type="button" onClick={() => setComponents([])} className="text-muted-foreground hover:text-foreground/80">
|
||||
{t("shared.clear")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-foreground flex flex-wrap">
|
||||
{components.map((item, idx) => {
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setShowModal({ item, idx: idx })}
|
||||
className="border-border hover:border-border relative m-1 block rounded-lg border-2 border-dashed p-1 text-center text-sm focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
<FormulaComponentBadge item={item} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal({ item: undefined, idx: undefined })}
|
||||
className="relative m-1 block w-full rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 p-1 text-center text-blue-600 hover:border-blue-400 hover:bg-blue-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="block text-sm font-medium">{t("pages.formulas.components.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
|
||||
<div>
|
||||
{onDelete && getUserHasPermission(appOrAdminData, "admin.formulas.delete") && (
|
||||
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDeleteClick} destructive>
|
||||
<div>{t("shared.delete")}</div>
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<ButtonSecondary onClick={() => navigate(adminPath("entities/formulas", { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
|
||||
<LoadingButton actionName={getActionName()} type="submit" disabled={navigation.state === "submitting"}>
|
||||
{t("shared.save")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<FormulaComponentModal
|
||||
open={showModal !== undefined}
|
||||
item={showModal?.item}
|
||||
idx={showModal?.idx}
|
||||
order={components.length + 1}
|
||||
onClose={() => setShowModal(undefined)}
|
||||
onSave={(item) => onSave(item)}
|
||||
onRemove={(idx) => {
|
||||
components.splice(idx, 1);
|
||||
setComponents([...components]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmModal ref={confirmModalDelete} onYes={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { FormulaComponentLog, FormulaLog, Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export type FormulaLogWithDetails = FormulaLog & {
|
||||
formula: { id: string; name: string };
|
||||
user: { id: string; email: string } | null;
|
||||
tenant: { id: string; name: string } | null;
|
||||
components: FormulaComponentLog[];
|
||||
};
|
||||
export async function getFormulaLogs({
|
||||
id,
|
||||
pagination,
|
||||
filters,
|
||||
}: {
|
||||
id: string | undefined;
|
||||
pagination: { pageSize: number; page: number };
|
||||
filters: FiltersDto;
|
||||
}): Promise<{ items: FormulaLogWithDetails[]; total: number }> {
|
||||
let where: Prisma.FormulaLogWhereInput = {
|
||||
formulaId: id,
|
||||
};
|
||||
const statusFilter = filters.properties.find((filter) => filter.name === "status")?.value;
|
||||
if (statusFilter) {
|
||||
if (statusFilter === "success") {
|
||||
where = { ...where, error: null, result: { not: "" } };
|
||||
} else if (statusFilter === "error") {
|
||||
where = { ...where, error: { not: null } };
|
||||
} else if (statusFilter === "empty") {
|
||||
where = { ...where, result: "", error: null };
|
||||
}
|
||||
}
|
||||
const hasRowIdFilter = filters.properties.find((filter) => filter.name === "hasRowId")?.value;
|
||||
if (hasRowIdFilter) {
|
||||
where = { ...where, components: { some: { rowId: hasRowIdFilter } } };
|
||||
}
|
||||
filters.properties
|
||||
.filter((f) => !f.manual && f.value)
|
||||
.forEach((filter) => {
|
||||
where = { ...where, [filter.name]: filter.value };
|
||||
});
|
||||
const items = await db.formulaLog.findMany({
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
where,
|
||||
include: {
|
||||
formula: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, email: true } },
|
||||
tenant: { select: { id: true, name: true } },
|
||||
components: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
const total = await db.formulaLog.count({ where });
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function countLogs(ids: string[]): Promise<{ formulaId: string; count: number }[]> {
|
||||
return (
|
||||
await db.formulaLog.groupBy({
|
||||
by: ["formulaId"],
|
||||
where: { formulaId: { in: ids } },
|
||||
_count: { formulaId: true },
|
||||
})
|
||||
).map((item) => ({ formulaId: item.formulaId, count: item._count.formulaId }));
|
||||
}
|
||||
|
||||
export async function createFormulaLog(data: {
|
||||
formulaId: string;
|
||||
userId: string | null;
|
||||
tenantId: string | null;
|
||||
originalTrigger: string;
|
||||
triggeredBy: string;
|
||||
expresion: string;
|
||||
result: string;
|
||||
error: string | null;
|
||||
rowValueId: string | null;
|
||||
components: {
|
||||
order: number;
|
||||
type: string;
|
||||
value: string;
|
||||
rowId: string | null;
|
||||
}[];
|
||||
}) {
|
||||
return await db.formulaLog.create({
|
||||
data: {
|
||||
formulaId: data.formulaId,
|
||||
userId: data.userId,
|
||||
tenantId: data.tenantId,
|
||||
originalTrigger: data.originalTrigger,
|
||||
triggeredBy: data.triggeredBy,
|
||||
expression: data.expresion,
|
||||
duration: 0,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
rowValueId: data.rowValueId,
|
||||
components: {
|
||||
create: data.components.map((component) => ({
|
||||
order: component.order,
|
||||
type: component.type,
|
||||
value: component.value,
|
||||
rowId: component.rowId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateFormulaLog(
|
||||
id: string,
|
||||
data: {
|
||||
duration: number;
|
||||
result: string;
|
||||
error: string | null;
|
||||
}
|
||||
) {
|
||||
return await db.formulaLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Entity, Formula, FormulaComponent, Prisma, Property } from "@toolctrl/db/prisma-browser";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export type FormulaWithDetails = Formula & {
|
||||
components: FormulaComponent[];
|
||||
inProperties: (Property & { entity: Entity })[];
|
||||
};
|
||||
|
||||
export async function getAllFormulas(): Promise<FormulaWithDetails[]> {
|
||||
return await db.formula.findMany({
|
||||
include: {
|
||||
components: { orderBy: { order: "asc" } },
|
||||
inProperties: { include: { entity: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllFormulasIdsAndNames(): Promise<{ id: string; name: string }[]> {
|
||||
return await db.formula.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllFormulasInIds(ids: string[]): Promise<FormulaWithDetails[]> {
|
||||
return await db.formula.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
components: { orderBy: { order: "asc" } },
|
||||
inProperties: { include: { entity: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFormula(id: string): Promise<FormulaWithDetails | null> {
|
||||
return await db.formula.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
components: { orderBy: { order: "asc" } },
|
||||
inProperties: { include: { entity: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFormulaWithLogs(id: string): Promise<FormulaWithDetails | null> {
|
||||
return await db.formula.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
components: { orderBy: { order: "asc" } },
|
||||
inProperties: { include: { entity: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateFormulaDto = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
resultAs: string;
|
||||
calculationTrigger: string;
|
||||
withLogs: boolean;
|
||||
components: {
|
||||
order: number;
|
||||
type: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
export async function createFormula(data: CreateFormulaDto): Promise<Formula> {
|
||||
return await db.formula.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
resultAs: data.resultAs,
|
||||
calculationTrigger: data.calculationTrigger,
|
||||
withLogs: data.withLogs,
|
||||
components: {
|
||||
create: data.components.map((component) => ({
|
||||
order: component.order,
|
||||
type: component.type,
|
||||
value: component.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateFormula(
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
resultAs?: string;
|
||||
calculationTrigger?: string;
|
||||
withLogs?: boolean;
|
||||
components?: {
|
||||
order: number;
|
||||
type: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
): Promise<Formula> {
|
||||
const update: Prisma.FormulaUpdateInput = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
resultAs: data.resultAs,
|
||||
calculationTrigger: data.calculationTrigger,
|
||||
withLogs: data.withLogs,
|
||||
};
|
||||
if (data.components) {
|
||||
update.components = {
|
||||
deleteMany: {},
|
||||
create: data.components,
|
||||
};
|
||||
}
|
||||
return await db.formula.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFormula(id: string): Promise<Formula> {
|
||||
return await db.formula.delete({ where: { id } });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
export type FormulaDto = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
components: FormulaComponentDto[];
|
||||
resultAs?: FormulaResultAsType;
|
||||
calculationTrigger?: FormulaCalculationTriggerType;
|
||||
withLogs?: boolean;
|
||||
createdAt?: Date;
|
||||
inProperties?: { id: string; name: string; entity: { id: string; name: string } }[];
|
||||
};
|
||||
|
||||
export const FormulaCalculationTriggerTypes = ["ALWAYS", "NEVER", "BEFORE_LISTED", "AFTER_CREATED", "BEFORE_VIEWED", "AFTER_UPDATED", "IF_UNSET"] as const;
|
||||
export type FormulaCalculationTriggerType = (typeof FormulaCalculationTriggerTypes)[number];
|
||||
export const FormulaResultAsTypes = ["number", "boolean", "string", "date"] as const;
|
||||
export type FormulaResultAsType = (typeof FormulaResultAsTypes)[number];
|
||||
|
||||
export const FormulaOperatorTypes = [
|
||||
"ADD",
|
||||
"SUBTRACT",
|
||||
"MULTIPLY",
|
||||
"DIVIDE",
|
||||
"CONCAT",
|
||||
"EQUALS",
|
||||
"NOT_EQUALS",
|
||||
"GREATER_THAN",
|
||||
"LESS_THAN",
|
||||
"GREATER_THAN_OR_EQUAL",
|
||||
"LESS_THAN_OR_EQUAL",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"DATE_ADD_DAYS",
|
||||
] as const;
|
||||
export type FormulaOperatorType = (typeof FormulaOperatorTypes)[number];
|
||||
export type FormulaParenthesisType = "OPEN" | "CLOSE";
|
||||
|
||||
type RowMedia = {};
|
||||
type RowValueMultiple = { order: number; value: string };
|
||||
type RowValueRange = { numberMin: Decimal | null; numberMax: Decimal | null; dateMin: Date | null; dateMax: Date | null };
|
||||
export type FormulaValueType = string | number | boolean | RowMedia[] | RowValueMultiple[] | RowValueRange | Date | null;
|
||||
export type FormulaEndResult = string | number | boolean | Date | null;
|
||||
|
||||
export const FormulaComponentTypes = [
|
||||
{ value: "variable", name: "Variable" },
|
||||
{ value: "operator", name: "Operator" },
|
||||
{ value: "parenthesis", name: "Parenthesis" },
|
||||
{ value: "value", name: "Value" },
|
||||
] as const;
|
||||
export type FormulaComponentType = (typeof FormulaComponentTypes)[number]["value"];
|
||||
export type FormulaComponentDto = {
|
||||
order: number;
|
||||
type: FormulaComponentType;
|
||||
value: string;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import { TenantWithDetails } from "~/utils/db/tenants.db.server";
|
||||
import { UserWithDetails } from "~/utils/db/users.db.server";
|
||||
|
||||
export type FormulaVariableValueDto = {
|
||||
plain?: {
|
||||
variable: string;
|
||||
textValue?: string | undefined;
|
||||
numberValue?: number | undefined;
|
||||
dateValue?: Date | undefined;
|
||||
booleanValue?: boolean | undefined;
|
||||
};
|
||||
row?: {
|
||||
entity: EntityWithDetails;
|
||||
item: RowWithDetails;
|
||||
};
|
||||
tenant?: TenantWithDetails;
|
||||
user?: UserWithDetails;
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { CreateFormulaDto, createFormula } from "../../db/formulas.db.server";
|
||||
import { defaultFormulas } from "../../utils/DefaultFormulas";
|
||||
|
||||
async function createDefault(formulaName?: string) {
|
||||
let toCreate: CreateFormulaDto[] = defaultFormulas().map((f) => ({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
resultAs: f.resultAs ?? "string",
|
||||
calculationTrigger: f.calculationTrigger ?? "NEVER",
|
||||
withLogs: false,
|
||||
components: f.components,
|
||||
}));
|
||||
if (formulaName) {
|
||||
toCreate = toCreate.filter((t) => t.name === formulaName);
|
||||
}
|
||||
|
||||
const createdFormulas = await Promise.all(
|
||||
toCreate.map(async (data) => {
|
||||
await createFormula(data);
|
||||
})
|
||||
);
|
||||
|
||||
return createdFormulas;
|
||||
}
|
||||
|
||||
export default {
|
||||
createDefault,
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import RowValueService, { RowValueUpdateDto } from "~/utils/helpers/.server/RowValueService";
|
||||
import { FormulaWithDetails, getFormula } from "../../db/formulas.db.server";
|
||||
import { FormulaCalculationTriggerType, FormulaDto, FormulaEndResult, FormulaValueType } from "../../dtos/FormulaDto";
|
||||
import { FormulaVariableValueDto } from "../../dtos/FormulaVariableValueDto";
|
||||
import FormulaHelpers from "../../utils/FormulaHelpers";
|
||||
import FormulaCalculator from "../../utils/FormulaCalculator";
|
||||
import FormulaRowService from "../FormulaRowService";
|
||||
import { EntityWithDetails, PropertyWithDetails, getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { createFormulaLog, updateFormulaLog } from "../../db/formulaLogs.db.server";
|
||||
import { FormulaLog } from "@toolctrl/db/prisma-browser";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
async function calculate({
|
||||
formula,
|
||||
variables,
|
||||
allEntities,
|
||||
session,
|
||||
originalTrigger,
|
||||
triggeredBy,
|
||||
rowValueId,
|
||||
isDebugging,
|
||||
t,
|
||||
}: {
|
||||
formula: FormulaWithDetails;
|
||||
variables: FormulaVariableValueDto[];
|
||||
allEntities: EntityWithDetails[];
|
||||
session: { tenantId?: string | null; userId?: string } | undefined;
|
||||
originalTrigger: string;
|
||||
triggeredBy: string;
|
||||
rowValueId?: string | null;
|
||||
isDebugging?: boolean;
|
||||
t: TFunction;
|
||||
}): Promise<FormulaEndResult> {
|
||||
const formulaDto = FormulaHelpers.getFormulaDto(formula);
|
||||
|
||||
const { values, usedRows } = getTranslatedValues(formulaDto, variables, allEntities, t);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[FormulaService.calculate] variable values", { values });
|
||||
const expression: { order: number; type: string; value: string; rowId: string | null }[] = [];
|
||||
formulaDto.components
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.forEach((component) => {
|
||||
let value = "";
|
||||
if (component.type === "variable") {
|
||||
if (Object.keys(values).includes(component.value)) {
|
||||
value = FormulaHelpers.getValueAsString(values[component.value]);
|
||||
} else {
|
||||
value = "[Unset]";
|
||||
}
|
||||
} else if (component.type === "operator") {
|
||||
value = FormulaHelpers.getOperatorSymbol(component.value);
|
||||
} else if (component.type === "parenthesis") {
|
||||
value = FormulaHelpers.getParenthesisType(component.value);
|
||||
} else {
|
||||
value = `[Unknown component #${component.order}]`;
|
||||
}
|
||||
let rowId: string | null = null;
|
||||
if (Object.keys(usedRows).includes(component.value ?? "")) {
|
||||
rowId = usedRows[component.value ?? ""].id;
|
||||
}
|
||||
expression.push({ order: component.order, type: component.type, value, rowId });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log({ components: formulaDto.components, values, variables });
|
||||
|
||||
let formulaLog: FormulaLog | null = null;
|
||||
|
||||
if (!isDebugging && formulaDto.id && formulaDto.withLogs) {
|
||||
formulaLog = await createFormulaLog({
|
||||
formulaId: formulaDto.id,
|
||||
userId: session?.userId ?? null,
|
||||
tenantId: session?.tenantId ?? null,
|
||||
originalTrigger,
|
||||
triggeredBy,
|
||||
expresion: expression.map((e) => e.value).join(" "),
|
||||
result: "",
|
||||
error: null,
|
||||
components: expression.map((e) => ({ order: e.order, type: e.type, value: e.value, rowId: e.rowId })),
|
||||
rowValueId: rowValueId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const result = FormulaCalculator.calculateFormula(formulaDto, values);
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
if (formulaLog) {
|
||||
await updateFormulaLog(formulaLog.id, {
|
||||
duration,
|
||||
result: FormulaHelpers.getValueAsString(result),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
if (formulaLog) {
|
||||
await updateFormulaLog(formulaLog.id, {
|
||||
duration,
|
||||
result: "",
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function trigger({
|
||||
trigger,
|
||||
entity,
|
||||
rows,
|
||||
session,
|
||||
t,
|
||||
}: {
|
||||
trigger: FormulaCalculationTriggerType;
|
||||
entity: EntityWithDetails;
|
||||
rows: RowWithDetails[];
|
||||
session: { tenantId?: string | null; userId?: string } | undefined;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const formulaProperties = entity.properties.filter((f) => f.formulaId);
|
||||
if (formulaProperties.length === 0) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[FormulaService.trigger]", { trigger, entity: entity.name, rows: rows.length });
|
||||
|
||||
const promises = formulaProperties.map(async (property) => {
|
||||
return triggerRowProperty({ trigger, entity, rows, session, property, t }).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[FormulaService] Error", {
|
||||
trigger,
|
||||
property: property.name,
|
||||
error: e.message,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
return await Promise.all(promises);
|
||||
// run in parallel
|
||||
}
|
||||
|
||||
async function triggerRowProperty({
|
||||
trigger,
|
||||
entity,
|
||||
rows,
|
||||
session,
|
||||
property,
|
||||
t,
|
||||
}: {
|
||||
trigger: FormulaCalculationTriggerType;
|
||||
entity: EntityWithDetails;
|
||||
rows: RowWithDetails[];
|
||||
session: { tenantId?: string | null; userId?: string } | undefined;
|
||||
property: PropertyWithDetails;
|
||||
t: TFunction;
|
||||
}) {
|
||||
if (rows.length === 0 || !property.formulaId) {
|
||||
return;
|
||||
}
|
||||
const formula = await getFormula(property.formulaId);
|
||||
if (!formula) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formulaTrigger = FormulaHelpers.getCalculationTrigger(formula.calculationTrigger);
|
||||
const allEntities = await getAllEntities({ tenantId: session?.tenantId });
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
return await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const resultAs = FormulaHelpers.getResultAs(formula.resultAs);
|
||||
|
||||
let triggeredBy = trigger;
|
||||
let willCalculate = formulaTrigger === trigger;
|
||||
if (!willCalculate) {
|
||||
if (formulaTrigger === "ALWAYS") {
|
||||
willCalculate = true;
|
||||
triggeredBy = "ALWAYS";
|
||||
} else if (formulaTrigger === "IF_UNSET") {
|
||||
const rowValue = RowHelper.getPropertyValue({
|
||||
entity,
|
||||
item: row,
|
||||
property,
|
||||
});
|
||||
willCalculate = rowValue === null || rowValue === undefined;
|
||||
if (willCalculate) {
|
||||
triggeredBy = "IF_UNSET";
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log({ row: row.id, formulaName: formula.name, currentTrigger: trigger, formulaTrigger, willCalculate });
|
||||
|
||||
if (!willCalculate) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rowValueId = row.values.find((v) => v.propertyId === property.id)?.id ?? null;
|
||||
const variables: FormulaVariableValueDto[] = [{ row: { entity, item: row } }];
|
||||
const value = await calculate({
|
||||
formula,
|
||||
variables,
|
||||
allEntities,
|
||||
session,
|
||||
originalTrigger: trigger,
|
||||
triggeredBy,
|
||||
rowValueId,
|
||||
t,
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[FormulaService] Result for row ${RowHelper.getTextDescription({ entity, item: row, t })}`, {
|
||||
value,
|
||||
});
|
||||
let valueToUpdate: RowValueUpdateDto | null = null;
|
||||
switch (resultAs) {
|
||||
case "number":
|
||||
valueToUpdate = { name: property.name, numberValue: value as number };
|
||||
break;
|
||||
case "string":
|
||||
valueToUpdate = { name: property.name, textValue: value as string };
|
||||
break;
|
||||
case "boolean":
|
||||
valueToUpdate = { name: property.name, booleanValue: value as boolean };
|
||||
break;
|
||||
case "date":
|
||||
valueToUpdate = { name: property.name, dateValue: value as Date };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (valueToUpdate) {
|
||||
await RowValueService.update({
|
||||
values: [valueToUpdate],
|
||||
session: { tenantId: session?.tenantId ?? row.tenantId, userId: session?.userId },
|
||||
entity,
|
||||
row,
|
||||
checkPermissions: false,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getTranslatedValues(
|
||||
formula: FormulaDto,
|
||||
values: FormulaVariableValueDto[],
|
||||
allEntities: EntityWithDetails[],
|
||||
t: TFunction
|
||||
): {
|
||||
values: Record<string, FormulaValueType>;
|
||||
usedRows: Record<string, RowWithDetails>;
|
||||
} {
|
||||
const variables: Record<string, FormulaValueType> = {};
|
||||
const usedRows: Record<string, RowWithDetails> = {};
|
||||
formula.components
|
||||
.filter((c) => c.type === "variable" || c.type === "value")
|
||||
.forEach((component) => {
|
||||
const componentName = component.value ?? "";
|
||||
if (component.type === "value") {
|
||||
variables[componentName] = component.value;
|
||||
return;
|
||||
}
|
||||
const plainValue = values.find((v) => v.plain?.variable === componentName)?.plain;
|
||||
if (plainValue) {
|
||||
if (plainValue?.textValue !== undefined) {
|
||||
// if value.textValue is number an formula returnsAsNumber
|
||||
if (formula.resultAs === "number") {
|
||||
variables[componentName] = Number(plainValue.textValue);
|
||||
} else {
|
||||
variables[componentName] = plainValue.textValue;
|
||||
}
|
||||
} else if (plainValue?.numberValue !== undefined) {
|
||||
variables[componentName] = plainValue.numberValue;
|
||||
} else if (plainValue?.dateValue !== undefined) {
|
||||
variables[componentName] = plainValue.dateValue;
|
||||
} else if (plainValue?.booleanValue !== undefined) {
|
||||
variables[componentName] = plainValue.booleanValue;
|
||||
}
|
||||
} else if (componentName.startsWith("row.")) {
|
||||
const rowValue = values.find((v) => v.row);
|
||||
if (rowValue?.row === undefined) {
|
||||
throw new Error(`Row required for component ${componentName}`);
|
||||
}
|
||||
if (rowValue.row.item) {
|
||||
usedRows[componentName] = rowValue.row.item;
|
||||
}
|
||||
variables[componentName] = FormulaRowService.getRowValue({ componentName, row: rowValue.row, allEntities, t });
|
||||
} else if (componentName?.startsWith("user.")) {
|
||||
throw new Error("User variables are not supported yet");
|
||||
} else if (componentName?.startsWith("tenant.")) {
|
||||
throw new Error("Tenant variables are not supported yet");
|
||||
}
|
||||
// else {
|
||||
// throw new Error(`Formula component value is unset: ${componentName}`);
|
||||
// }
|
||||
});
|
||||
return { values: variables, usedRows };
|
||||
}
|
||||
|
||||
export default {
|
||||
calculate,
|
||||
trigger,
|
||||
getTranslatedValues,
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails, RowWithValues } from "~/utils/db/entities/rows.db.server";
|
||||
import { FormulaValueType } from "../dtos/FormulaDto";
|
||||
import { Property } from "@toolctrl/db/prisma-browser";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
function getRowValue({
|
||||
componentName,
|
||||
row,
|
||||
allEntities,
|
||||
t,
|
||||
}: {
|
||||
componentName: string;
|
||||
row: { entity: EntityWithDetails; item: RowWithDetails };
|
||||
allEntities: EntityWithDetails[];
|
||||
t: TFunction;
|
||||
}): FormulaValueType {
|
||||
const { entity, item } = row;
|
||||
const parts = componentName.split(".");
|
||||
if (parts.length === 0 || parts[0] !== "row") {
|
||||
throw new Error(`Invalid component ${componentName} for entity (${entity.name})`);
|
||||
}
|
||||
const entityName = parts[1];
|
||||
const propertyName = parts[2];
|
||||
let customFunction: string | null = null;
|
||||
if (parts.length > 3) {
|
||||
customFunction = parts[3];
|
||||
}
|
||||
if (parts[1] === entity.name) {
|
||||
if (propertyName === "order") {
|
||||
return item.order;
|
||||
}
|
||||
}
|
||||
const foundParentEntity = entity.parentEntities.find((i) => i.parent.name === entityName);
|
||||
const foundChildEntity = entity.childEntities.find((i) => i.child.name === entityName);
|
||||
let property: Property | undefined = entity.properties.find((i) => i.name === propertyName);
|
||||
if (property) {
|
||||
const value = RowHelper.getPropertyValue({ entity, item, propertyName });
|
||||
if (customFunction === "inverse") {
|
||||
throw new Error("Inverse not implemented");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (foundParentEntity) {
|
||||
const parentRows = item.parentRows.filter((i) => i.relationshipId === foundParentEntity.id);
|
||||
const parentEntity = allEntities.find((i) => i.id === foundParentEntity.parent.id);
|
||||
return findRelatedRowsValue({ entity: parentEntity!, rows: parentRows.map((f) => f.parent), propertyName });
|
||||
} else if (foundChildEntity) {
|
||||
const childRows = item.childRows.filter((i) => i.relationshipId === foundChildEntity.id);
|
||||
const childEntity = allEntities.find((i) => i.id === foundChildEntity.child.id);
|
||||
const value = findRelatedRowsValue({ entity: childEntity!, rows: childRows.map((f) => f.child), propertyName });
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
throw new Error(
|
||||
`Invalid component ${componentName} for entity (${entity.name}). Unknown property: ${propertyName} in (${entity.properties
|
||||
.map((f) => f.name)
|
||||
.join(",")}).`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid component ${componentName} for entity (${entity.name}), property (${propertyName}), and row ${RowHelper.getTextDescription({ entity, item, t })}`
|
||||
);
|
||||
}
|
||||
|
||||
function findRelatedRowsValue({
|
||||
entity,
|
||||
rows,
|
||||
propertyName,
|
||||
customFunction,
|
||||
}: {
|
||||
entity: EntityWithDetails & { properties: Property[] };
|
||||
rows: RowWithValues[];
|
||||
propertyName: string;
|
||||
customFunction?: string | null;
|
||||
}): FormulaValueType {
|
||||
if (propertyName === "order") {
|
||||
return rows.length > 0 ? rows[0].order : 0;
|
||||
}
|
||||
const property = entity.properties.find((i) => i.name === propertyName);
|
||||
if (!property) {
|
||||
throw new Error(`Property ${propertyName} not found on entity ${entity.name}`);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return RowHelper.getPropertyDefaultValue(property);
|
||||
} else if (rows.length === 1) {
|
||||
const value = RowHelper.getPropertyValue({ entity, item: rows[0], propertyName });
|
||||
if (customFunction === "inverse") {
|
||||
throw new Error("Inverse not implemented");
|
||||
}
|
||||
return value;
|
||||
} else if (!customFunction) {
|
||||
throw new Error(`Property ${propertyName} has multiple values but no custom function was specified`);
|
||||
}
|
||||
throw new Error(`Property ${propertyName} has multiple values but custom function ${customFunction} is not implemented`);
|
||||
}
|
||||
|
||||
export default {
|
||||
getRowValue,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { FormulaEndResult, FormulaOperatorType, FormulaValueType } from "../dtos/FormulaDto";
|
||||
|
||||
type BinaryOperatorFn = (leftOperand: FormulaValueType, rightOperand: FormulaValueType) => FormulaEndResult;
|
||||
|
||||
const binaryOperators: Record<FormulaOperatorType, BinaryOperatorFn> = {
|
||||
ADD: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
if (a !== null) return a as number;
|
||||
return 0;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a + b;
|
||||
} else if (typeof a === "string" || typeof b === "string") {
|
||||
return Number(a) + Number(b);
|
||||
} else if (typeof a === "boolean" && typeof b === "boolean") {
|
||||
return a || b;
|
||||
} else if (a instanceof Date && typeof b === "number") {
|
||||
const newDate = new Date(a);
|
||||
newDate.setDate(newDate.getDate() + b);
|
||||
return newDate;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
SUBTRACT: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
if (a !== null) return a as number;
|
||||
return 0;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a - b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a.replace(b, "");
|
||||
} else if (typeof a === "boolean" && typeof b === "boolean") {
|
||||
return a && !b;
|
||||
} else if (a instanceof Date && typeof b === "number") {
|
||||
const newDate = new Date(a);
|
||||
newDate.setDate(newDate.getDate() - b);
|
||||
return newDate;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
MULTIPLY: (a: FormulaValueType, b: FormulaValueType) => (a !== null && b !== null ? Number(a) * Number(b) : false),
|
||||
DIVIDE: (a: FormulaValueType, b: FormulaValueType) => (a !== null && b !== null && b !== 0 ? Number(a) / Number(b) : false),
|
||||
CONCAT: (a: FormulaValueType, b: FormulaValueType) => (a?.toString() ?? "") + (b?.toString() ?? ""),
|
||||
EQUALS: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a === b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a === b;
|
||||
} else if (typeof a === "boolean" && typeof b === "boolean") {
|
||||
return a === b;
|
||||
} else if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() === b.getTime();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
NOT_EQUALS: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a !== b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a !== b;
|
||||
} else if (typeof a === "boolean" && typeof b === "boolean") {
|
||||
return a !== b;
|
||||
} else if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() !== b.getTime();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
GREATER_THAN: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a > b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a > b;
|
||||
} else if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() > b.getTime();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
LESS_THAN: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a < b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a < b;
|
||||
} else if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() < b.getTime();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
GREATER_THAN_OR_EQUAL: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a >= b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a >= b;
|
||||
} else if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() >= b.getTime();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
LESS_THAN_OR_EQUAL: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a === null || b === null) {
|
||||
return false;
|
||||
} else if (typeof a === "number" && typeof b === "number") {
|
||||
return a <= b;
|
||||
} else if (typeof a === "string" && typeof b === "string") {
|
||||
return a <= b;
|
||||
} else if (a instanceof Date && b instanceof Date) {
|
||||
return a.getTime() <= b.getTime();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
AND: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (typeof a === "boolean" && typeof b === "boolean") {
|
||||
return a && b;
|
||||
} else {
|
||||
let aIsTrue = (a || a === "true") && a !== "false";
|
||||
let bIsTrue = (b || b === "true") && b !== "false";
|
||||
return aIsTrue && bIsTrue;
|
||||
}
|
||||
},
|
||||
OR: (a: FormulaValueType, b: FormulaValueType) => Boolean(a || b),
|
||||
NOT: (a: FormulaValueType) => !a,
|
||||
DATE_ADD_DAYS: (a: FormulaValueType, b: FormulaValueType) => {
|
||||
if (a instanceof Date || typeof a === "string") {
|
||||
const newDate = new Date(a);
|
||||
newDate.setDate(newDate.getDate() + Number(b ?? 0));
|
||||
return newDate;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
binaryOperators,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FormulaDto } from "../dtos/FormulaDto";
|
||||
|
||||
export function defaultFormulas(): FormulaDto[] {
|
||||
const formulas: FormulaDto[] = [
|
||||
{
|
||||
name: "Concatenates",
|
||||
description: "calculates with texts",
|
||||
resultAs: "string",
|
||||
calculationTrigger: "NEVER",
|
||||
components: [
|
||||
{ order: 1, type: "variable", value: "text1" },
|
||||
{ order: 2, type: "operator", value: "CONCAT" },
|
||||
{ order: 3, type: "variable", value: "text2" },
|
||||
{ order: 4, type: "operator", value: "CONCAT" },
|
||||
{ order: 5, type: "variable", value: "text3" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Evaluates Booleans",
|
||||
description: "calculates with booleans",
|
||||
resultAs: "boolean",
|
||||
components: [
|
||||
{ order: 1, type: "variable", value: "boolean1" },
|
||||
{ order: 2, type: "operator", value: "AND" },
|
||||
{ order: 3, type: "variable", value: "boolean2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Adds Days to Date",
|
||||
description: "calculates with dates",
|
||||
resultAs: "date",
|
||||
components: [
|
||||
{ order: 1, type: "variable", value: "date" },
|
||||
{ order: 2, type: "operator", value: "DATE_ADD_DAYS" },
|
||||
{ order: 3, type: "variable", value: "days" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Assigns Static Value",
|
||||
description: "sets static value",
|
||||
resultAs: "number",
|
||||
components: [{ order: 1, type: "variable", value: "number" }],
|
||||
},
|
||||
{
|
||||
name: "Adds Two Numbers",
|
||||
description: "calculates addition with two values",
|
||||
resultAs: "number",
|
||||
components: [
|
||||
{ order: 1, type: "variable", value: "number1" },
|
||||
{ order: 2, type: "operator", value: "ADD" },
|
||||
{ order: 3, type: "variable", value: "number2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return formulas;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { FormulaOperatorType, FormulaDto, FormulaValueType, FormulaResultAsType, FormulaEndResult } from "../dtos/FormulaDto";
|
||||
import BinaryOperatorUtils from "./BinaryOperatorUtils";
|
||||
import FormulaHelpers from "./FormulaHelpers";
|
||||
|
||||
function calculateFormula(formula: FormulaDto, values: Record<string, FormulaValueType>): FormulaEndResult {
|
||||
const resultAs: FormulaResultAsType = FormulaHelpers.getResultAs(formula.resultAs);
|
||||
let lastComponent: string | null = null;
|
||||
let result: FormulaValueType | null = null;
|
||||
let currentOperand: FormulaValueType = null;
|
||||
let currentOperator: FormulaOperatorType | null = null;
|
||||
let openParenthesesCount = 0;
|
||||
let closeParenthesesCount = 0;
|
||||
let insideParentheses: FormulaDto | null = null;
|
||||
|
||||
formula.components.forEach((component) => {
|
||||
if (component.type === "variable") {
|
||||
lastComponent = "variable: " + component.value;
|
||||
if (insideParentheses !== null) {
|
||||
insideParentheses.components.push(component);
|
||||
} else {
|
||||
currentOperand = values[component.value];
|
||||
}
|
||||
} else if (component.type === "operator") {
|
||||
lastComponent = "operator: " + component.value;
|
||||
if (currentOperator !== null) {
|
||||
if (insideParentheses !== null) {
|
||||
insideParentheses.components.push(component);
|
||||
} else {
|
||||
result = BinaryOperatorUtils.binaryOperators[currentOperator](result, currentOperand);
|
||||
}
|
||||
} else {
|
||||
if (insideParentheses !== null) {
|
||||
throw new Error("Invalid formula: parentheses must be used in pairs");
|
||||
} else {
|
||||
result = currentOperand;
|
||||
}
|
||||
}
|
||||
// Reset the operand for the next iteration
|
||||
currentOperand = null;
|
||||
currentOperator = FormulaHelpers.getOperatorType(component.value);
|
||||
} else if (component.type === "parenthesis") {
|
||||
lastComponent = "parenthesis: " + component.value;
|
||||
const parenthesis = FormulaHelpers.getParenthesisType(component.value);
|
||||
if (parenthesis === "OPEN") {
|
||||
if (insideParentheses !== null) {
|
||||
throw new Error("Invalid formula: parentheses must be used in pairs");
|
||||
} else {
|
||||
insideParentheses = { name: "", description: "", resultAs: "number", calculationTrigger: formula.calculationTrigger, components: [] };
|
||||
openParenthesesCount++;
|
||||
}
|
||||
} else if (parenthesis === "CLOSE") {
|
||||
if (insideParentheses === null) {
|
||||
throw new Error("Invalid formula: parentheses must be used in pairs");
|
||||
} else {
|
||||
closeParenthesesCount++;
|
||||
if (closeParenthesesCount > openParenthesesCount) {
|
||||
throw new Error("Invalid formula: parentheses must be used in pairs");
|
||||
} else if (closeParenthesesCount === openParenthesesCount) {
|
||||
currentOperand = calculateFormula(insideParentheses, values);
|
||||
insideParentheses = null;
|
||||
} else {
|
||||
insideParentheses.components.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (component.type === "value") {
|
||||
lastComponent = "value: " + component.value;
|
||||
if (insideParentheses !== null) {
|
||||
insideParentheses.components.push(component);
|
||||
} else {
|
||||
currentOperand = component.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (formula.components.length === 1 && currentOperand) {
|
||||
result = currentOperand;
|
||||
}
|
||||
|
||||
if (currentOperand !== null && currentOperator !== null) {
|
||||
const operator = currentOperator as FormulaOperatorType;
|
||||
result = BinaryOperatorUtils.binaryOperators[operator](result, currentOperand);
|
||||
} else if (result === null) {
|
||||
if (!lastComponent) {
|
||||
throw new Error("Invalid formula");
|
||||
} else {
|
||||
throw new Error(`Invalid formula component ${lastComponent}`);
|
||||
}
|
||||
}
|
||||
|
||||
switch (resultAs) {
|
||||
case "number":
|
||||
return result !== null ? Number(result) : null;
|
||||
case "boolean":
|
||||
return result !== null ? Boolean(result) : null;
|
||||
case "date":
|
||||
return !result === null || result instanceof Date === false ? null : result;
|
||||
case "string":
|
||||
return result !== null ? String(result) : null;
|
||||
default:
|
||||
throw new Error("Invalid resultAs value in formula");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
calculateFormula,
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { FormulaWithDetails } from "../db/formulas.db.server";
|
||||
import {
|
||||
FormulaResultAsType,
|
||||
FormulaOperatorType,
|
||||
FormulaParenthesisType,
|
||||
FormulaCalculationTriggerType,
|
||||
FormulaDto,
|
||||
FormulaValueType,
|
||||
FormulaComponentType,
|
||||
} from "../dtos/FormulaDto";
|
||||
|
||||
function getFormulaDto(formula: FormulaWithDetails) {
|
||||
const formulaDto: FormulaDto = {
|
||||
id: formula.id,
|
||||
createdAt: formula.createdAt,
|
||||
name: formula.name,
|
||||
description: formula.description,
|
||||
resultAs: getResultAs(formula.resultAs),
|
||||
calculationTrigger: getCalculationTrigger(formula.calculationTrigger),
|
||||
withLogs: formula.withLogs,
|
||||
components: formula.components
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((component) => ({
|
||||
order: component.order,
|
||||
type: getComponentType(component.type),
|
||||
value: component.value ?? undefined,
|
||||
})),
|
||||
inProperties: formula.inProperties.map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
entity: {
|
||||
id: x.entity.id,
|
||||
name: x.entity.name,
|
||||
},
|
||||
})),
|
||||
};
|
||||
return formulaDto;
|
||||
}
|
||||
|
||||
function getResultAs(resultAs?: string): FormulaResultAsType {
|
||||
switch (resultAs) {
|
||||
case "number":
|
||||
return "number";
|
||||
case "string":
|
||||
return "string";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "date":
|
||||
return "date";
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentType(type: string): FormulaComponentType {
|
||||
switch (type) {
|
||||
case "variable":
|
||||
return "variable";
|
||||
case "operator":
|
||||
return "operator";
|
||||
case "value":
|
||||
return "value";
|
||||
case "parenthesis":
|
||||
return "parenthesis";
|
||||
default:
|
||||
return "variable";
|
||||
}
|
||||
}
|
||||
|
||||
function getCalculationTrigger(calculationTrigger?: string): FormulaCalculationTriggerType {
|
||||
switch (calculationTrigger) {
|
||||
case "ALWAYS":
|
||||
return "ALWAYS";
|
||||
case "BEFORE_LISTED":
|
||||
return "BEFORE_LISTED";
|
||||
case "AFTER_CREATED":
|
||||
return "AFTER_CREATED";
|
||||
case "BEFORE_VIEWED":
|
||||
return "BEFORE_VIEWED";
|
||||
case "AFTER_UPDATED":
|
||||
return "AFTER_UPDATED";
|
||||
case "IF_UNSET":
|
||||
return "IF_UNSET";
|
||||
case "NEVER":
|
||||
return "NEVER";
|
||||
default:
|
||||
return "NEVER";
|
||||
}
|
||||
}
|
||||
|
||||
function getOperatorType(operator: string): FormulaOperatorType {
|
||||
switch (operator) {
|
||||
case "ADD":
|
||||
return "ADD";
|
||||
case "SUBTRACT":
|
||||
return "SUBTRACT";
|
||||
case "MULTIPLY":
|
||||
return "MULTIPLY";
|
||||
case "DIVIDE":
|
||||
return "DIVIDE";
|
||||
case "CONCAT":
|
||||
return "CONCAT";
|
||||
case "EQUALS":
|
||||
return "EQUALS";
|
||||
case "NOT_EQUALS":
|
||||
return "NOT_EQUALS";
|
||||
case "GREATER_THAN":
|
||||
return "GREATER_THAN";
|
||||
case "LESS_THAN":
|
||||
return "LESS_THAN";
|
||||
case "GREATER_THAN_OR_EQUAL":
|
||||
return "GREATER_THAN_OR_EQUAL";
|
||||
case "LESS_THAN_OR_EQUAL":
|
||||
return "LESS_THAN_OR_EQUAL";
|
||||
case "AND":
|
||||
return "AND";
|
||||
case "OR":
|
||||
return "OR";
|
||||
case "NOT":
|
||||
return "NOT";
|
||||
case "DATE_ADD_DAYS":
|
||||
return "DATE_ADD_DAYS";
|
||||
default:
|
||||
return "ADD";
|
||||
}
|
||||
}
|
||||
|
||||
function getOperatorSymbol(operator?: string): string {
|
||||
switch (getOperatorType(operator ?? "")) {
|
||||
case "ADD":
|
||||
return "+";
|
||||
case "DATE_ADD_DAYS":
|
||||
return "+";
|
||||
case "SUBTRACT":
|
||||
return "-";
|
||||
case "MULTIPLY":
|
||||
return "*";
|
||||
case "DIVIDE":
|
||||
return "/";
|
||||
case "CONCAT":
|
||||
return "&";
|
||||
case "EQUALS":
|
||||
return "=";
|
||||
case "NOT_EQUALS":
|
||||
return "!=";
|
||||
case "GREATER_THAN":
|
||||
return ">";
|
||||
case "LESS_THAN":
|
||||
return "<";
|
||||
case "GREATER_THAN_OR_EQUAL":
|
||||
return ">=";
|
||||
case "LESS_THAN_OR_EQUAL":
|
||||
return "<=";
|
||||
case "AND":
|
||||
return "AND";
|
||||
case "OR":
|
||||
return "OR";
|
||||
case "NOT":
|
||||
return "NOT";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getParenthesisType(parenthesis: string): FormulaParenthesisType {
|
||||
switch (parenthesis) {
|
||||
case "OPEN":
|
||||
return "OPEN";
|
||||
case "CLOSE":
|
||||
return "CLOSE";
|
||||
default:
|
||||
return "OPEN";
|
||||
}
|
||||
}
|
||||
|
||||
function getValueAsString(value: FormulaValueType) {
|
||||
let valueStr = "";
|
||||
if (value === null) {
|
||||
valueStr = "[null]";
|
||||
} else if (value === undefined) {
|
||||
valueStr = "[undefined]";
|
||||
} else if (typeof value === "string") {
|
||||
valueStr = `"${value}"`;
|
||||
} else if (typeof value === "boolean") {
|
||||
valueStr = `${value ? "true" : "false"}`;
|
||||
} else if (typeof value === "number") {
|
||||
valueStr = `${value}`;
|
||||
} else if (value instanceof Date) {
|
||||
valueStr = `"${value.toISOString()}"`;
|
||||
} else {
|
||||
valueStr = `"${value.toString()}"`;
|
||||
}
|
||||
return valueStr;
|
||||
}
|
||||
|
||||
export default {
|
||||
getFormulaDto,
|
||||
getResultAs,
|
||||
getComponentType,
|
||||
getCalculationTrigger,
|
||||
getOperatorType,
|
||||
getOperatorSymbol,
|
||||
getParenthesisType,
|
||||
getValueAsString,
|
||||
};
|
||||
Reference in new issue
Block a user