@things-factory/kpi 9.0.21 → 9.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/charts/kpi-boxplot-chart.ts +163 -0
- package/client/charts/kpi-radar-chart.ts +128 -0
- package/client/pages/kpi/kpi-list-page.ts +180 -22
- package/client/pages/kpi-category/kpi-category-list-page.ts +76 -3
- package/client/pages/kpi-category/kpi-category-value-calculator.ts +233 -0
- package/client/pages/kpi-dashboard/kpi-dashboard.ts +188 -0
- package/client/pages/kpi-metric/kpi-metric-list-page.ts +13 -1
- package/client/pages/kpi-metric-value/kpi-metric-value-list-page.ts +43 -1
- package/client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.ts +3 -13
- package/client/pages/kpi-metric-value/kpi-metric-value-manual-entry-page.ts +13 -1
- package/client/pages/kpi-value/kpi-value-list-page.ts +45 -1
- package/dist-client/charts/kpi-boxplot-chart.d.ts +22 -0
- package/dist-client/charts/kpi-boxplot-chart.js +198 -0
- package/dist-client/charts/kpi-boxplot-chart.js.map +1 -0
- package/dist-client/charts/kpi-radar-chart.d.ts +16 -0
- package/dist-client/charts/kpi-radar-chart.js +138 -0
- package/dist-client/charts/kpi-radar-chart.js.map +1 -0
- package/dist-client/pages/kpi/kpi-list-page.d.ts +2 -1
- package/dist-client/pages/kpi/kpi-list-page.js +180 -22
- package/dist-client/pages/kpi/kpi-list-page.js.map +1 -1
- package/dist-client/pages/kpi-category/kpi-category-list-page.d.ts +3 -0
- package/dist-client/pages/kpi-category/kpi-category-list-page.js +71 -3
- package/dist-client/pages/kpi-category/kpi-category-list-page.js.map +1 -1
- package/dist-client/pages/kpi-category/kpi-category-value-calculator.d.ts +13 -0
- package/dist-client/pages/kpi-category/kpi-category-value-calculator.js +256 -0
- package/dist-client/pages/kpi-category/kpi-category-value-calculator.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +11 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +185 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
- package/dist-client/pages/kpi-metric/kpi-metric-list-page.js +13 -1
- package/dist-client/pages/kpi-metric/kpi-metric-list-page.js.map +1 -1
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.d.ts +4 -1
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js +39 -2
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js.map +1 -1
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.js +3 -13
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.js.map +1 -1
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-page.js +13 -1
- package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-page.js.map +1 -1
- package/dist-client/pages/kpi-value/kpi-value-list-page.d.ts +1 -0
- package/dist-client/pages/kpi-value/kpi-value-list-page.js +45 -1
- package/dist-client/pages/kpi-value/kpi-value-list-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/calculator/evaluator.d.ts +8 -0
- package/dist-server/calculator/evaluator.js +42 -0
- package/dist-server/calculator/evaluator.js.map +1 -0
- package/dist-server/calculator/functions.d.ts +3 -0
- package/dist-server/calculator/functions.js +62 -0
- package/dist-server/calculator/functions.js.map +1 -0
- package/dist-server/calculator/index.d.ts +4 -0
- package/dist-server/calculator/index.js +8 -0
- package/dist-server/calculator/index.js.map +1 -0
- package/dist-server/calculator/parser.d.ts +21 -0
- package/dist-server/calculator/parser.js +121 -0
- package/dist-server/calculator/parser.js.map +1 -0
- package/dist-server/calculator/provider.d.ts +8 -0
- package/dist-server/calculator/provider.js +13 -0
- package/dist-server/calculator/provider.js.map +1 -0
- package/dist-server/controllers/kpi-metric-value-provider.d.ts +11 -0
- package/dist-server/controllers/kpi-metric-value-provider.js +63 -0
- package/dist-server/controllers/kpi-metric-value-provider.js.map +1 -0
- package/dist-server/controllers/kpi-value-provider.d.ts +11 -0
- package/dist-server/controllers/kpi-value-provider.js +46 -0
- package/dist-server/controllers/kpi-value-provider.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/service/kpi/aggregate-kpi.js +4 -4
- package/dist-server/service/kpi/aggregate-kpi.js.map +1 -1
- package/dist-server/service/kpi/kpi-grade.types.d.ts +11 -10
- package/dist-server/service/kpi/kpi-grade.types.js.map +1 -1
- package/dist-server/service/kpi/kpi-history.d.ts +2 -2
- package/dist-server/service/kpi/kpi-history.js.map +1 -1
- package/dist-server/service/kpi/kpi-mutation.d.ts +2 -0
- package/dist-server/service/kpi/kpi-mutation.js +126 -4
- package/dist-server/service/kpi/kpi-mutation.js.map +1 -1
- package/dist-server/service/kpi/kpi-type.d.ts +8 -5
- package/dist-server/service/kpi/kpi-type.js +22 -8
- package/dist-server/service/kpi/kpi-type.js.map +1 -1
- package/dist-server/service/kpi/kpi.d.ts +6 -3
- package/dist-server/service/kpi/kpi.js +29 -9
- package/dist-server/service/kpi/kpi.js.map +1 -1
- package/dist-server/service/kpi-category/kpi-category-mutation.d.ts +1 -1
- package/dist-server/service/kpi-category/kpi-category-mutation.js +3 -3
- package/dist-server/service/kpi-category/kpi-category-mutation.js.map +1 -1
- package/dist-server/service/kpi-category/kpi-category-query.d.ts +13 -0
- package/dist-server/service/kpi-category/kpi-category-query.js +180 -0
- package/dist-server/service/kpi-category/kpi-category-query.js.map +1 -1
- package/dist-server/service/kpi-category/kpi-category-type.d.ts +3 -0
- package/dist-server/service/kpi-category/kpi-category-type.js +16 -1
- package/dist-server/service/kpi-category/kpi-category-type.js.map +1 -1
- package/dist-server/service/kpi-category/kpi-category.d.ts +2 -0
- package/dist-server/service/kpi-category/kpi-category.js +10 -1
- package/dist-server/service/kpi-category/kpi-category.js.map +1 -1
- package/dist-server/service/kpi-metric/kpi-metric-type.d.ts +5 -3
- package/dist-server/service/kpi-metric/kpi-metric-type.js +5 -3
- package/dist-server/service/kpi-metric/kpi-metric-type.js.map +1 -1
- package/dist-server/service/kpi-metric/kpi-metric.d.ts +2 -8
- package/dist-server/service/kpi-metric/kpi-metric.js +3 -14
- package/dist-server/service/kpi-metric/kpi-metric.js.map +1 -1
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +67 -45
- package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -1
- package/dist-server/service/kpi-metric-value/kpi-metric-value.js +3 -2
- package/dist-server/service/kpi-metric-value/kpi-metric-value.js.map +1 -1
- package/dist-server/service/kpi-value/kpi-value-mutation.d.ts +2 -1
- package/dist-server/service/kpi-value/kpi-value-mutation.js +114 -6
- package/dist-server/service/kpi-value/kpi-value-mutation.js.map +1 -1
- package/dist-server/service/kpi-value/kpi-value-query.d.ts +0 -2
- package/dist-server/service/kpi-value/kpi-value-query.js +0 -12
- package/dist-server/service/kpi-value/kpi-value-query.js.map +1 -1
- package/dist-server/service/kpi-value/kpi-value-score.service.d.ts +26 -0
- package/dist-server/service/kpi-value/kpi-value-score.service.js +97 -0
- package/dist-server/service/kpi-value/kpi-value-score.service.js.map +1 -0
- package/dist-server/service/kpi-value/kpi-value-type.d.ts +2 -0
- package/dist-server/service/kpi-value/kpi-value-type.js +14 -0
- package/dist-server/service/kpi-value/kpi-value-type.js.map +1 -1
- package/dist-server/service/kpi-value/kpi-value.d.ts +1 -0
- package/dist-server/service/kpi-value/kpi-value.js +9 -1
- package/dist-server/service/kpi-value/kpi-value.js.map +1 -1
- package/dist-server/service/utils/value-date-util.d.ts +3 -0
- package/dist-server/service/utils/value-date-util.js +76 -0
- package/dist-server/service/utils/value-date-util.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/calculator/evaluator.ts +45 -0
- package/server/calculator/functions.ts +67 -0
- package/server/calculator/index.ts +4 -0
- package/server/calculator/parser.ts +128 -0
- package/server/calculator/provider.ts +10 -0
- package/server/controllers/kpi-metric-value-provider.ts +66 -0
- package/server/controllers/kpi-value-provider.ts +51 -0
- package/server/service/kpi/aggregate-kpi.ts +4 -4
- package/server/service/kpi/kpi-grade.types.ts +11 -10
- package/server/service/kpi/kpi-history.ts +2 -2
- package/server/service/kpi/kpi-mutation.ts +128 -4
- package/server/service/kpi/kpi-type.ts +21 -9
- package/server/service/kpi/kpi.ts +32 -10
- package/server/service/kpi-category/kpi-category-mutation.ts +3 -3
- package/server/service/kpi-category/kpi-category-query.ts +175 -1
- package/server/service/kpi-category/kpi-category-type.ts +17 -6
- package/server/service/kpi-category/kpi-category.ts +10 -1
- package/server/service/kpi-metric/kpi-metric-type.ts +7 -5
- package/server/service/kpi-metric/kpi-metric.ts +3 -15
- package/server/service/kpi-metric-value/kpi-metric-value-mutation.ts +67 -47
- package/server/service/kpi-metric-value/kpi-metric-value.ts +4 -2
- package/server/service/kpi-value/kpi-value-mutation.ts +110 -6
- package/server/service/kpi-value/kpi-value-query.ts +2 -8
- package/server/service/kpi-value/kpi-value-score.service.ts +112 -0
- package/server/service/kpi-value/kpi-value-type.ts +12 -0
- package/server/service/kpi-value/kpi-value.ts +8 -1
- package/server/service/utils/value-date-util.ts +72 -0
- package/dist-server/service/kpi-value/kpi-value-grade.service.d.ts +0 -34
- package/dist-server/service/kpi-value/kpi-value-grade.service.js +0 -117
- package/dist-server/service/kpi-value/kpi-value-grade.service.js.map +0 -1
- package/server/service/kpi-value/kpi-value-grade.service.ts +0 -127
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/kpi",
|
|
3
|
-
"version": "9.0.
|
|
3
|
+
"version": "9.0.23",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"@things-factory/dataset": "^9.0.20",
|
|
45
45
|
"@things-factory/shell": "^9.0.20"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "3038f35d4e6f8d5e0f9384edfaca0c17104c6872"
|
|
48
48
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FormulaNode } from './parser'
|
|
2
|
+
import type { FormulaFunctionMap } from './functions'
|
|
3
|
+
import type { ValueProvider } from './provider'
|
|
4
|
+
|
|
5
|
+
export interface EvalContext {
|
|
6
|
+
functions: FormulaFunctionMap
|
|
7
|
+
provider: ValueProvider
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 비동기 evaluator만 유지, 함수명은 evaluateFormula로 단순화
|
|
11
|
+
export async function evaluateFormula(node: FormulaNode, ctx: EvalContext): Promise<any> {
|
|
12
|
+
switch (node.type) {
|
|
13
|
+
case 'number':
|
|
14
|
+
return node.value
|
|
15
|
+
case 'variable':
|
|
16
|
+
return await ctx.provider.get(node.name)
|
|
17
|
+
case 'function': {
|
|
18
|
+
const fn = ctx.functions[node.name]
|
|
19
|
+
if (!fn) throw new Error('Unknown function: ' + node.name)
|
|
20
|
+
const args = await Promise.all(node.args.map(arg => evaluateFormula(arg, ctx)))
|
|
21
|
+
return fn(...args)
|
|
22
|
+
}
|
|
23
|
+
case 'binary': {
|
|
24
|
+
const l = await evaluateFormula(node.left, ctx)
|
|
25
|
+
const r = await evaluateFormula(node.right, ctx)
|
|
26
|
+
switch (node.op) {
|
|
27
|
+
case '+':
|
|
28
|
+
return l + r
|
|
29
|
+
case '-':
|
|
30
|
+
return l - r
|
|
31
|
+
case '*':
|
|
32
|
+
return l * r
|
|
33
|
+
case '/':
|
|
34
|
+
return l / r
|
|
35
|
+
default:
|
|
36
|
+
throw new Error('Unknown operator: ' + node.op)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
case 'unary':
|
|
40
|
+
if (node.op === '-') return -(await evaluateFormula(node.arg, ctx))
|
|
41
|
+
throw new Error('Unknown unary operator: ' + node.op)
|
|
42
|
+
default:
|
|
43
|
+
throw new Error('Unknown node type: ' + (node as any).type)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type FormulaFunction = (...args: any[]) => any
|
|
2
|
+
export type FormulaFunctionMap = Record<string, FormulaFunction>
|
|
3
|
+
|
|
4
|
+
export const builtinFunctions: FormulaFunctionMap = {
|
|
5
|
+
sum: (...args: any[]) => args.reduce((a, b) => a + b, 0),
|
|
6
|
+
avg: (...args: any[]) => (args.length ? args.reduce((a, b) => a + b, 0) / args.length : 0),
|
|
7
|
+
min: (...args: any[]) => Math.min(...args),
|
|
8
|
+
max: (...args: any[]) => Math.max(...args),
|
|
9
|
+
round: (v: number, d: number = 0) => Number(v.toFixed(d)),
|
|
10
|
+
if: (cond: any, t: any, f: any) => (cond ? t : f),
|
|
11
|
+
// 성과 지수 계산을 위한 수치 적분 함수들
|
|
12
|
+
integrate: (func: Function, a: number, b: number, n: number = 1000) => {
|
|
13
|
+
// 사다리꼴 적분법 (Trapezoidal Rule)
|
|
14
|
+
const h = (b - a) / n
|
|
15
|
+
let sum = (func(a) + func(b)) / 2
|
|
16
|
+
for (let i = 1; i < n; i++) {
|
|
17
|
+
sum += func(a + i * h)
|
|
18
|
+
}
|
|
19
|
+
return sum * h
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
beta_integrand: (t: number, alpha: number, beta: number) => {
|
|
23
|
+
// 베타 분포 피적분함수: t^(α-1) × (1-t)^(β-1)
|
|
24
|
+
return Math.pow(t, alpha - 1) * Math.pow(1 - t, beta - 1)
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
incomplete_beta: (x: number, alpha: number, beta: number) => {
|
|
28
|
+
// 불완전 베타 함수 계산
|
|
29
|
+
const func = (t: number) => Math.pow(t, alpha - 1) * Math.pow(1 - t, beta - 1)
|
|
30
|
+
const h = x / 1000
|
|
31
|
+
let sum = (func(0) + func(x)) / 2
|
|
32
|
+
for (let i = 1; i < 1000; i++) {
|
|
33
|
+
sum += func(i * h)
|
|
34
|
+
}
|
|
35
|
+
return sum * h
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
complete_beta: (alpha: number, beta: number) => {
|
|
39
|
+
// 완전 베타 함수 계산
|
|
40
|
+
const func = (t: number) => Math.pow(t, alpha - 1) * Math.pow(1 - t, beta - 1)
|
|
41
|
+
const h = 1 / 1000
|
|
42
|
+
let sum = (func(0) + func(1)) / 2
|
|
43
|
+
for (let i = 1; i < 1000; i++) {
|
|
44
|
+
sum += func(i * h)
|
|
45
|
+
}
|
|
46
|
+
return sum * h
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
performance_index: (x: number, alpha1: number, beta1: number, alpha2: number, beta2: number) => {
|
|
50
|
+
// 성과 지수 계산: 1 - (불완전 베타 / 완전 베타)
|
|
51
|
+
const numerator = builtinFunctions.incomplete_beta(x, alpha1, beta1)
|
|
52
|
+
const denominator = builtinFunctions.complete_beta(alpha2, beta2)
|
|
53
|
+
return 1 - numerator / denominator
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// 지수 함수들
|
|
57
|
+
exp: (x: number) => Math.exp(x),
|
|
58
|
+
log: (x: number) => Math.log(x),
|
|
59
|
+
pow: (x: number, y: number) => Math.pow(x, y),
|
|
60
|
+
|
|
61
|
+
// 지수 감쇠 성과 지수
|
|
62
|
+
exponential_decay: (value: number, scale: number, power: number) => {
|
|
63
|
+
// exp(-(value / scale)^power)
|
|
64
|
+
return Math.exp(-Math.pow(value / scale, power))
|
|
65
|
+
}
|
|
66
|
+
// 필요시 확장
|
|
67
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// KPI formula 파서: 수식 문자열을 AST로 변환
|
|
2
|
+
|
|
3
|
+
export type FormulaNode =
|
|
4
|
+
| { type: 'number'; value: number }
|
|
5
|
+
| { type: 'variable'; name: string }
|
|
6
|
+
| { type: 'function'; name: string; args: FormulaNode[] }
|
|
7
|
+
| { type: 'binary'; op: string; left: FormulaNode; right: FormulaNode }
|
|
8
|
+
| { type: 'unary'; op: string; arg: FormulaNode }
|
|
9
|
+
|
|
10
|
+
// 토큰 타입 정의
|
|
11
|
+
const TOKEN_REGEX = /\s*(\d+\.\d+|\d+|\[.*?\]|[A-Za-z_][A-Za-z0-9_]*|[+\-*/(),])/y
|
|
12
|
+
|
|
13
|
+
interface Token {
|
|
14
|
+
type: 'number' | 'variable' | 'function' | 'operator' | 'paren' | 'comma'
|
|
15
|
+
value: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tokenize(formula: string): Token[] {
|
|
19
|
+
const tokens: Token[] = []
|
|
20
|
+
let m: RegExpExecArray | null
|
|
21
|
+
let lastIndex = 0
|
|
22
|
+
while ((m = TOKEN_REGEX.exec(formula))) {
|
|
23
|
+
lastIndex = TOKEN_REGEX.lastIndex
|
|
24
|
+
const [raw] = m
|
|
25
|
+
if (/^\d/.test(raw)) {
|
|
26
|
+
tokens.push({ type: 'number', value: raw })
|
|
27
|
+
} else if (/^\[.*\]$/.test(raw)) {
|
|
28
|
+
tokens.push({ type: 'variable', value: raw.slice(1, -1) })
|
|
29
|
+
} else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) {
|
|
30
|
+
tokens.push({ type: 'function', value: raw })
|
|
31
|
+
} else if (/^[+\-*/]$/.test(raw)) {
|
|
32
|
+
tokens.push({ type: 'operator', value: raw })
|
|
33
|
+
} else if (/^[(),]$/.test(raw)) {
|
|
34
|
+
if (raw === ',') tokens.push({ type: 'comma', value: raw })
|
|
35
|
+
else tokens.push({ type: 'paren', value: raw })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (lastIndex < formula.length) {
|
|
39
|
+
throw new Error('Unexpected token at: ' + formula.slice(lastIndex))
|
|
40
|
+
}
|
|
41
|
+
return tokens
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 파서 구현 (재귀 하향, 연산자 우선순위, 함수/변수/숫자/괄호 지원)
|
|
45
|
+
export function parseFormula(formula: string): FormulaNode {
|
|
46
|
+
const tokens = tokenize(formula)
|
|
47
|
+
let pos = 0
|
|
48
|
+
|
|
49
|
+
function peek() {
|
|
50
|
+
return tokens[pos]
|
|
51
|
+
}
|
|
52
|
+
function next() {
|
|
53
|
+
return tokens[pos++]
|
|
54
|
+
}
|
|
55
|
+
function expect(type: string, value?: string) {
|
|
56
|
+
const t = next()
|
|
57
|
+
if (!t || t.type !== type || (value && t.value !== value))
|
|
58
|
+
throw new Error('Expected ' + type + (value ? ':' + value : ''))
|
|
59
|
+
return t
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parsePrimary(): FormulaNode {
|
|
63
|
+
const t = peek()
|
|
64
|
+
if (!t) throw new Error('Unexpected end')
|
|
65
|
+
if (t.type === 'number') {
|
|
66
|
+
next()
|
|
67
|
+
return { type: 'number', value: parseFloat(t.value) }
|
|
68
|
+
}
|
|
69
|
+
if (t.type === 'variable') {
|
|
70
|
+
next()
|
|
71
|
+
return { type: 'variable', name: t.value }
|
|
72
|
+
}
|
|
73
|
+
if (t.type === 'function') {
|
|
74
|
+
// 함수 호출: func(expr, ...)
|
|
75
|
+
const name = t.value
|
|
76
|
+
next()
|
|
77
|
+
expect('paren', '(')
|
|
78
|
+
const args: FormulaNode[] = []
|
|
79
|
+
if ((peek() && peek().type !== 'paren') || (peek() && peek().value !== ')')) {
|
|
80
|
+
while (true) {
|
|
81
|
+
args.push(parseExpr())
|
|
82
|
+
if (peek() && peek().type === 'comma') {
|
|
83
|
+
next()
|
|
84
|
+
} else break
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
expect('paren', ')')
|
|
88
|
+
return { type: 'function', name, args }
|
|
89
|
+
}
|
|
90
|
+
if (t.type === 'operator' && t.value === '-') {
|
|
91
|
+
next()
|
|
92
|
+
return { type: 'unary', op: '-', arg: parsePrimary() }
|
|
93
|
+
}
|
|
94
|
+
if (t.type === 'paren' && t.value === '(') {
|
|
95
|
+
next()
|
|
96
|
+
const node = parseExpr()
|
|
97
|
+
expect('paren', ')')
|
|
98
|
+
return node
|
|
99
|
+
}
|
|
100
|
+
throw new Error('Unexpected token: ' + t.type + ':' + t.value)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseMulDiv(): FormulaNode {
|
|
104
|
+
let node = parsePrimary()
|
|
105
|
+
while (peek() && peek().type === 'operator' && (peek().value === '*' || peek().value === '/')) {
|
|
106
|
+
const op = next().value
|
|
107
|
+
node = { type: 'binary', op, left: node, right: parsePrimary() }
|
|
108
|
+
}
|
|
109
|
+
return node
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseAddSub(): FormulaNode {
|
|
113
|
+
let node = parseMulDiv()
|
|
114
|
+
while (peek() && peek().type === 'operator' && (peek().value === '+' || peek().value === '-')) {
|
|
115
|
+
const op = next().value
|
|
116
|
+
node = { type: 'binary', op, left: node, right: parseMulDiv() }
|
|
117
|
+
}
|
|
118
|
+
return node
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseExpr(): FormulaNode {
|
|
122
|
+
return parseAddSub()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ast = parseExpr()
|
|
126
|
+
if (pos < tokens.length) throw new Error('Unexpected token at end: ' + JSON.stringify(tokens[pos]))
|
|
127
|
+
return ast
|
|
128
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getRepository } from '@things-factory/shell'
|
|
2
|
+
import { KpiMetric } from '../service/kpi-metric/kpi-metric'
|
|
3
|
+
import { KpiMetricValue } from '../service/kpi-metric-value/kpi-metric-value'
|
|
4
|
+
import { ValueProvider } from '../calculator/provider'
|
|
5
|
+
import { getDefaultValueDate } from '../service/utils/value-date-util'
|
|
6
|
+
|
|
7
|
+
export class KpiMetricValueProvider implements ValueProvider {
|
|
8
|
+
constructor(
|
|
9
|
+
private options: {
|
|
10
|
+
valueDate: string
|
|
11
|
+
group?: string
|
|
12
|
+
domainId: string
|
|
13
|
+
tx?: any
|
|
14
|
+
}
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async get(name: string) {
|
|
18
|
+
const metricRepo = getRepository(KpiMetric, this.options.tx)
|
|
19
|
+
const metric = await metricRepo.findOne({ where: { name, domain: { id: this.options.domainId } } })
|
|
20
|
+
if (!metric) throw new Error(`Metric not found: ${name}`)
|
|
21
|
+
|
|
22
|
+
// ALLTIME 타입인 경우 valueDate 무시하고 최신 값 조회
|
|
23
|
+
if (metric.periodType === 'ALLTIME') {
|
|
24
|
+
const valueRepo = getRepository(KpiMetricValue, this.options.tx)
|
|
25
|
+
const value = await valueRepo.findOne({
|
|
26
|
+
where: {
|
|
27
|
+
metric: { id: metric.id },
|
|
28
|
+
group: this.options.group ?? '',
|
|
29
|
+
domain: { id: this.options.domainId }
|
|
30
|
+
},
|
|
31
|
+
order: { createdAt: 'DESC' }
|
|
32
|
+
})
|
|
33
|
+
if (!value) {
|
|
34
|
+
throw new Error(`Metric value not found: metric='${name}', group='${this.options.group ?? ''}' (ALLTIME type)`)
|
|
35
|
+
}
|
|
36
|
+
return value.value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// metric의 periodType에 맞게 valueDate 보정
|
|
40
|
+
let valueDate = this.options.valueDate
|
|
41
|
+
if (!valueDate) {
|
|
42
|
+
valueDate = getDefaultValueDate(metric.periodType, 'current')
|
|
43
|
+
} else {
|
|
44
|
+
// valueDate가 metric.periodType에 맞는 포맷인지 검사, 아니면 보정
|
|
45
|
+
// (간단히: 길이로 구분, 실제는 정규식 등으로 더 정교하게 가능)
|
|
46
|
+
if (metric.periodType === 'MONTH' && valueDate.length > 7) valueDate = valueDate.slice(0, 7)
|
|
47
|
+
if (metric.periodType === 'DAY' && valueDate.length > 10) valueDate = valueDate.slice(0, 10)
|
|
48
|
+
// 기타 periodType별 보정 추가 가능
|
|
49
|
+
}
|
|
50
|
+
const valueRepo = getRepository(KpiMetricValue, this.options.tx)
|
|
51
|
+
const value = await valueRepo.findOne({
|
|
52
|
+
where: {
|
|
53
|
+
metric: { id: metric.id },
|
|
54
|
+
valueDate,
|
|
55
|
+
group: this.options.group ?? '',
|
|
56
|
+
domain: { id: this.options.domainId }
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
if (!value) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Metric value not found: metric='${name}', group='${this.options.group ?? ''}', valueDate='${valueDate}', periodType='${metric.periodType}'`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
return value.value
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getRepository } from '@things-factory/shell'
|
|
2
|
+
import { Kpi } from '../service/kpi/kpi'
|
|
3
|
+
import { KpiValue } from '../service/kpi-value/kpi-value'
|
|
4
|
+
import { ValueProvider } from '../calculator/provider'
|
|
5
|
+
|
|
6
|
+
export class KpiValueProvider implements ValueProvider {
|
|
7
|
+
constructor(
|
|
8
|
+
private options: {
|
|
9
|
+
valueDate: string
|
|
10
|
+
group?: string
|
|
11
|
+
domainId: string
|
|
12
|
+
tx?: any
|
|
13
|
+
}
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async get(name: string) {
|
|
17
|
+
const kpiRepo = getRepository(Kpi, this.options.tx)
|
|
18
|
+
const kpi = await kpiRepo.findOne({ where: { name, domain: { id: this.options.domainId } } })
|
|
19
|
+
if (!kpi) throw new Error(`KPI not found: ${name}`)
|
|
20
|
+
const valueRepo = getRepository(KpiValue, this.options.tx)
|
|
21
|
+
|
|
22
|
+
// SINGLE 타입인 경우 valueDate 무시하고 최신 값 조회
|
|
23
|
+
const whereCondition: any = {
|
|
24
|
+
kpi: { id: kpi.id },
|
|
25
|
+
group: this.options.group ?? '',
|
|
26
|
+
domain: { id: this.options.domainId }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (kpi.periodType === 'ALLTIME') {
|
|
30
|
+
// ALLTIME 타입은 valueDate 무시하고 최신 값 사용
|
|
31
|
+
const value = await valueRepo.findOne({
|
|
32
|
+
where: whereCondition,
|
|
33
|
+
order: { createdAt: 'DESC' }
|
|
34
|
+
})
|
|
35
|
+
if (!value) {
|
|
36
|
+
throw new Error(`KPI value not found: kpi='${name}', group='${this.options.group ?? ''}' (ALLTIME type)`)
|
|
37
|
+
}
|
|
38
|
+
return value.value
|
|
39
|
+
} else {
|
|
40
|
+
// 기존 로직: 특정 날짜의 값 조회
|
|
41
|
+
whereCondition.valueDate = this.options.valueDate
|
|
42
|
+
const value = await valueRepo.findOne({ where: whereCondition })
|
|
43
|
+
if (!value) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`KPI value not found: kpi='${name}', group='${this.options.group ?? ''}', valueDate='${this.options.valueDate}'`
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return value.value
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -4,7 +4,7 @@ import { KpiMetric } from '../kpi-metric/kpi-metric'
|
|
|
4
4
|
import { KpiValue, KpiValueInputType } from '../kpi-value/kpi-value'
|
|
5
5
|
import { KpiFormulaService } from './kpi-formula.service'
|
|
6
6
|
import { aggregateKpiMetricValue } from '../kpi-metric/aggregate-kpi-metric'
|
|
7
|
-
import {
|
|
7
|
+
import { KpiValueScoreService } from '../kpi-value/kpi-value-score.service'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* KPI 단위 집계/산식 자동화 함수
|
|
@@ -15,7 +15,7 @@ import { KpiValueGradeService } from '../kpi-value/kpi-value-grade.service'
|
|
|
15
15
|
*/
|
|
16
16
|
export async function aggregateKpiValue(kpiId: string, domainId: string, context: ResolverContext) {
|
|
17
17
|
const tx = context.state?.tx || getRepository(Kpi).manager
|
|
18
|
-
const
|
|
18
|
+
const scoreService = new KpiValueScoreService()
|
|
19
19
|
|
|
20
20
|
// 1. KPI 정보 조회
|
|
21
21
|
const kpi = await getRepository(Kpi).findOne({ where: { id: kpiId, domain: { id: domainId } } })
|
|
@@ -75,8 +75,8 @@ export async function aggregateKpiValue(kpiId: string, domainId: string, context
|
|
|
75
75
|
entity.creator = context.state?.user
|
|
76
76
|
entity.updater = context.state?.user
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
await
|
|
78
|
+
// 성과 점수 자동 계산 및 저장
|
|
79
|
+
await scoreService.calculateAndSaveScore(entity, kpi)
|
|
80
80
|
|
|
81
81
|
entity = await repo.save(entity)
|
|
82
82
|
savedValues.push(entity)
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* KPI
|
|
2
|
+
* KPI 성과 점수 lookup table 타입 정의
|
|
3
|
+
* 복잡한 성과 점수 변환을 수식으로 표현하기 어려운 경우 사용
|
|
3
4
|
*/
|
|
4
|
-
export interface
|
|
5
|
-
/**
|
|
5
|
+
export interface KpiScore {
|
|
6
|
+
/** 성과 지수명 (예: A, B, C, 우수, 양호 등) */
|
|
6
7
|
name: string
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/** 성과 점수 변환 기준 최소값 */
|
|
9
10
|
minValue: number
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/** 성과 점수 변환 기준 최대값 */
|
|
12
13
|
maxValue: number
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
+
/** 성과 지수 점수 (선택사항) */
|
|
15
16
|
score?: number
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
+
/** 성과 지수 색상 (선택사항) */
|
|
18
19
|
color?: string
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
+
/** 성과 지수 설명 (선택사항) */
|
|
21
22
|
description?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
* KPI
|
|
26
|
+
* KPI 성과 점수 lookup table 배열 타입
|
|
26
27
|
*/
|
|
27
|
-
export type
|
|
28
|
+
export type KpiScores = KpiScore[]
|
|
@@ -13,7 +13,7 @@ import { Domain, ScalarObject } from '@things-factory/shell'
|
|
|
13
13
|
|
|
14
14
|
import { Kpi, KpiStatus, KpiPeriodType } from './kpi'
|
|
15
15
|
import { KpiCategory } from '../kpi-category/kpi-category'
|
|
16
|
-
import {
|
|
16
|
+
import { KpiScores } from './kpi-grade.types'
|
|
17
17
|
|
|
18
18
|
const ORMCONFIG = config.get('ormconfig', {})
|
|
19
19
|
const DATABASE_TYPE = ORMCONFIG.type
|
|
@@ -75,7 +75,7 @@ export class KpiHistory implements HistoryEntityInterface<Kpi> {
|
|
|
75
75
|
nullable: true,
|
|
76
76
|
description: 'Grade configuration for this KPI version'
|
|
77
77
|
})
|
|
78
|
-
grades?:
|
|
78
|
+
grades?: KpiScores
|
|
79
79
|
|
|
80
80
|
@Column({ type: 'float', nullable: true, default: 1 })
|
|
81
81
|
@Field({ nullable: true, description: 'Weight for aggregation in parent category.' })
|
|
@@ -9,6 +9,14 @@ import { KpiCategory } from '../kpi-category/kpi-category'
|
|
|
9
9
|
import { KpiHistory } from './kpi-history'
|
|
10
10
|
import { KpiFormulaService } from './kpi-formula.service'
|
|
11
11
|
import { Application, CallbackBase, registerSchedule, unregisterSchedule } from '@things-factory/scheduler-client'
|
|
12
|
+
import { KpiValue } from '../kpi-value/kpi-value'
|
|
13
|
+
import { Between } from 'typeorm'
|
|
14
|
+
import { KpiMetric } from '../kpi-metric/kpi-metric'
|
|
15
|
+
import { KpiMetricValue } from '../kpi-metric-value/kpi-metric-value'
|
|
16
|
+
import { parseFormula } from '../../calculator/parser'
|
|
17
|
+
import { evaluateFormula } from '../../calculator/evaluator'
|
|
18
|
+
import { builtinFunctions } from '../../calculator/functions'
|
|
19
|
+
import { KpiMetricValueProvider } from '../../controllers/kpi-metric-value-provider'
|
|
12
20
|
|
|
13
21
|
@Resolver(Kpi)
|
|
14
22
|
export class KpiMutation {
|
|
@@ -20,8 +28,8 @@ export class KpiMutation {
|
|
|
20
28
|
): Promise<Kpi> {
|
|
21
29
|
const { domain, user, tx } = context.state
|
|
22
30
|
|
|
23
|
-
let category = kpi.
|
|
24
|
-
? await getRepository(KpiCategory).findOne({ where: { id: kpi.
|
|
31
|
+
let category = kpi.category
|
|
32
|
+
? await getRepository(KpiCategory).findOne({ where: { id: kpi.category.id } })
|
|
25
33
|
: undefined
|
|
26
34
|
|
|
27
35
|
const result = await getRepository(Kpi, tx).save({
|
|
@@ -104,8 +112,8 @@ export class KpiMutation {
|
|
|
104
112
|
where: { domain: { id: domain.id }, id }
|
|
105
113
|
})
|
|
106
114
|
|
|
107
|
-
let category = patch.
|
|
108
|
-
? await getRepository(KpiCategory).findOne({ where: { id: patch.
|
|
115
|
+
let category = patch.category
|
|
116
|
+
? await getRepository(KpiCategory).findOne({ where: { id: patch.category.id } })
|
|
109
117
|
: kpi.category
|
|
110
118
|
|
|
111
119
|
const result = await repository.save({
|
|
@@ -377,4 +385,120 @@ export class KpiMutation {
|
|
|
377
385
|
} as any)
|
|
378
386
|
return updated
|
|
379
387
|
}
|
|
388
|
+
|
|
389
|
+
@Directive('@transaction')
|
|
390
|
+
@Mutation(returns => KpiValue, { description: 'KPI 기준으로 formula 계산 및 KPI Value upsert' })
|
|
391
|
+
async calculateKpiValue(
|
|
392
|
+
@Arg('kpiId') kpiId: string,
|
|
393
|
+
@Arg('valueDate', { nullable: true }) valueDate: string,
|
|
394
|
+
@Arg('group', { nullable: true }) group: string,
|
|
395
|
+
@Ctx() context: ResolverContext
|
|
396
|
+
): Promise<KpiValue> {
|
|
397
|
+
const { domain, user, tx } = context.state
|
|
398
|
+
const kpiRepo = getRepository(Kpi, tx)
|
|
399
|
+
const metricRepo = getRepository<KpiMetric>(require('../kpi-metric/kpi-metric').KpiMetric, tx)
|
|
400
|
+
const metricValueRepo = getRepository<KpiMetricValue>(
|
|
401
|
+
require('../kpi-metric-value/kpi-metric-value').KpiMetricValue,
|
|
402
|
+
tx
|
|
403
|
+
)
|
|
404
|
+
const kpiValueRepo = getRepository<KpiValue>(KpiValue, tx)
|
|
405
|
+
const { KpiPeriodType } = require('./kpi')
|
|
406
|
+
const { getDefaultValueDate } = require('../utils/value-date-util')
|
|
407
|
+
|
|
408
|
+
// 1. KPI 정보 조회
|
|
409
|
+
const kpi = await kpiRepo.findOne({ where: { id: kpiId, domain: { id: domain.id } } })
|
|
410
|
+
if (!kpi) throw new Error('KPI 정보 없음')
|
|
411
|
+
if (!kpi.formula) throw new Error('KPI formula 없음')
|
|
412
|
+
const periodType = kpi.periodType || KpiPeriodType.DAY
|
|
413
|
+
const valueDateToUse = valueDate || getDefaultValueDate(periodType, 'last')
|
|
414
|
+
|
|
415
|
+
// 2. formula에서 metric code 추출
|
|
416
|
+
const metricCodes = (kpi.formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || []).filter(
|
|
417
|
+
code => code !== 'null' && code !== 'undefined'
|
|
418
|
+
)
|
|
419
|
+
const metricMap: Record<string, any> = {}
|
|
420
|
+
for (const code of metricCodes) {
|
|
421
|
+
const metric = await metricRepo.findOne({ where: { name: code, domain: { id: domain.id } } })
|
|
422
|
+
if (!metric) throw new Error(`KPI formula metric '${code}' not found`)
|
|
423
|
+
metricMap[code] = metric
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 3. metric 값 집계 (periodType 변환/집계)
|
|
427
|
+
const metricValues: Record<string, number> = {}
|
|
428
|
+
for (const code of metricCodes) {
|
|
429
|
+
const metric = metricMap[code]
|
|
430
|
+
let value = null
|
|
431
|
+
if (metric.periodType === periodType) {
|
|
432
|
+
const mv = await metricValueRepo.findOne({
|
|
433
|
+
where: {
|
|
434
|
+
metric: { id: metric.id },
|
|
435
|
+
valueDate: valueDateToUse,
|
|
436
|
+
periodType,
|
|
437
|
+
group: group ?? '',
|
|
438
|
+
domain: { id: domain.id }
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
value = mv?.value ?? null
|
|
442
|
+
} else {
|
|
443
|
+
let startDate: string, endDate: string
|
|
444
|
+
if (periodType === KpiPeriodType.MONTH && metric.periodType === KpiPeriodType.DAY) {
|
|
445
|
+
startDate = valueDateToUse + '-01'
|
|
446
|
+
endDate = valueDateToUse + '-31'
|
|
447
|
+
const mvs = await metricValueRepo.find({
|
|
448
|
+
where: {
|
|
449
|
+
metric: { id: metric.id },
|
|
450
|
+
valueDate: Between(startDate, endDate),
|
|
451
|
+
periodType: metric.periodType,
|
|
452
|
+
group: group ?? '',
|
|
453
|
+
domain: { id: domain.id }
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
value = mvs.reduce((sum, mv) => sum + (mv.value ?? 0), 0)
|
|
457
|
+
} else {
|
|
458
|
+
throw new Error('KPI/Metric periodType 조합 집계 미지원')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (value == null) throw new Error(`Metric '${code}' 값 없음`)
|
|
462
|
+
metricValues[code] = value
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 4. formula 계산 (calculator 기반)
|
|
466
|
+
const ast = parseFormula(kpi.formula)
|
|
467
|
+
const provider = new KpiMetricValueProvider({
|
|
468
|
+
valueDate: valueDateToUse,
|
|
469
|
+
group,
|
|
470
|
+
domainId: domain.id,
|
|
471
|
+
tx
|
|
472
|
+
})
|
|
473
|
+
const evalContext = { functions: builtinFunctions, provider }
|
|
474
|
+
const kpiValueResult = await evaluateFormula(ast, evalContext)
|
|
475
|
+
|
|
476
|
+
// 5. KPI Value upsert
|
|
477
|
+
let kpiValue = await kpiValueRepo.findOne({
|
|
478
|
+
where: {
|
|
479
|
+
kpi: { id: kpi.id },
|
|
480
|
+
valueDate: valueDateToUse,
|
|
481
|
+
group: group ?? '',
|
|
482
|
+
version: kpi.version,
|
|
483
|
+
domain: { id: domain.id }
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
if (kpiValue) {
|
|
487
|
+
kpiValue.value = kpiValueResult
|
|
488
|
+
kpiValue.updater = user
|
|
489
|
+
} else {
|
|
490
|
+
kpiValue = kpiValueRepo.create({
|
|
491
|
+
kpi,
|
|
492
|
+
kpiId: kpi.id,
|
|
493
|
+
version: kpi.version,
|
|
494
|
+
valueDate: valueDateToUse,
|
|
495
|
+
value: kpiValueResult,
|
|
496
|
+
group: group ?? '',
|
|
497
|
+
domain,
|
|
498
|
+
creator: user,
|
|
499
|
+
updater: user
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
return await kpiValueRepo.save(kpiValue)
|
|
503
|
+
}
|
|
380
504
|
}
|