@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.
Files changed (152) hide show
  1. package/client/charts/kpi-boxplot-chart.ts +163 -0
  2. package/client/charts/kpi-radar-chart.ts +128 -0
  3. package/client/pages/kpi/kpi-list-page.ts +180 -22
  4. package/client/pages/kpi-category/kpi-category-list-page.ts +76 -3
  5. package/client/pages/kpi-category/kpi-category-value-calculator.ts +233 -0
  6. package/client/pages/kpi-dashboard/kpi-dashboard.ts +188 -0
  7. package/client/pages/kpi-metric/kpi-metric-list-page.ts +13 -1
  8. package/client/pages/kpi-metric-value/kpi-metric-value-list-page.ts +43 -1
  9. package/client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.ts +3 -13
  10. package/client/pages/kpi-metric-value/kpi-metric-value-manual-entry-page.ts +13 -1
  11. package/client/pages/kpi-value/kpi-value-list-page.ts +45 -1
  12. package/dist-client/charts/kpi-boxplot-chart.d.ts +22 -0
  13. package/dist-client/charts/kpi-boxplot-chart.js +198 -0
  14. package/dist-client/charts/kpi-boxplot-chart.js.map +1 -0
  15. package/dist-client/charts/kpi-radar-chart.d.ts +16 -0
  16. package/dist-client/charts/kpi-radar-chart.js +138 -0
  17. package/dist-client/charts/kpi-radar-chart.js.map +1 -0
  18. package/dist-client/pages/kpi/kpi-list-page.d.ts +2 -1
  19. package/dist-client/pages/kpi/kpi-list-page.js +180 -22
  20. package/dist-client/pages/kpi/kpi-list-page.js.map +1 -1
  21. package/dist-client/pages/kpi-category/kpi-category-list-page.d.ts +3 -0
  22. package/dist-client/pages/kpi-category/kpi-category-list-page.js +71 -3
  23. package/dist-client/pages/kpi-category/kpi-category-list-page.js.map +1 -1
  24. package/dist-client/pages/kpi-category/kpi-category-value-calculator.d.ts +13 -0
  25. package/dist-client/pages/kpi-category/kpi-category-value-calculator.js +256 -0
  26. package/dist-client/pages/kpi-category/kpi-category-value-calculator.js.map +1 -0
  27. package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +11 -0
  28. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +185 -0
  29. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
  30. package/dist-client/pages/kpi-metric/kpi-metric-list-page.js +13 -1
  31. package/dist-client/pages/kpi-metric/kpi-metric-list-page.js.map +1 -1
  32. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.d.ts +4 -1
  33. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js +39 -2
  34. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js.map +1 -1
  35. package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.js +3 -13
  36. package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.js.map +1 -1
  37. package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-page.js +13 -1
  38. package/dist-client/pages/kpi-metric-value/kpi-metric-value-manual-entry-page.js.map +1 -1
  39. package/dist-client/pages/kpi-value/kpi-value-list-page.d.ts +1 -0
  40. package/dist-client/pages/kpi-value/kpi-value-list-page.js +45 -1
  41. package/dist-client/pages/kpi-value/kpi-value-list-page.js.map +1 -1
  42. package/dist-client/tsconfig.tsbuildinfo +1 -1
  43. package/dist-server/calculator/evaluator.d.ts +8 -0
  44. package/dist-server/calculator/evaluator.js +42 -0
  45. package/dist-server/calculator/evaluator.js.map +1 -0
  46. package/dist-server/calculator/functions.d.ts +3 -0
  47. package/dist-server/calculator/functions.js +62 -0
  48. package/dist-server/calculator/functions.js.map +1 -0
  49. package/dist-server/calculator/index.d.ts +4 -0
  50. package/dist-server/calculator/index.js +8 -0
  51. package/dist-server/calculator/index.js.map +1 -0
  52. package/dist-server/calculator/parser.d.ts +21 -0
  53. package/dist-server/calculator/parser.js +121 -0
  54. package/dist-server/calculator/parser.js.map +1 -0
  55. package/dist-server/calculator/provider.d.ts +8 -0
  56. package/dist-server/calculator/provider.js +13 -0
  57. package/dist-server/calculator/provider.js.map +1 -0
  58. package/dist-server/controllers/kpi-metric-value-provider.d.ts +11 -0
  59. package/dist-server/controllers/kpi-metric-value-provider.js +63 -0
  60. package/dist-server/controllers/kpi-metric-value-provider.js.map +1 -0
  61. package/dist-server/controllers/kpi-value-provider.d.ts +11 -0
  62. package/dist-server/controllers/kpi-value-provider.js +46 -0
  63. package/dist-server/controllers/kpi-value-provider.js.map +1 -0
  64. package/dist-server/service/index.d.ts +2 -2
  65. package/dist-server/service/kpi/aggregate-kpi.js +4 -4
  66. package/dist-server/service/kpi/aggregate-kpi.js.map +1 -1
  67. package/dist-server/service/kpi/kpi-grade.types.d.ts +11 -10
  68. package/dist-server/service/kpi/kpi-grade.types.js.map +1 -1
  69. package/dist-server/service/kpi/kpi-history.d.ts +2 -2
  70. package/dist-server/service/kpi/kpi-history.js.map +1 -1
  71. package/dist-server/service/kpi/kpi-mutation.d.ts +2 -0
  72. package/dist-server/service/kpi/kpi-mutation.js +126 -4
  73. package/dist-server/service/kpi/kpi-mutation.js.map +1 -1
  74. package/dist-server/service/kpi/kpi-type.d.ts +8 -5
  75. package/dist-server/service/kpi/kpi-type.js +22 -8
  76. package/dist-server/service/kpi/kpi-type.js.map +1 -1
  77. package/dist-server/service/kpi/kpi.d.ts +6 -3
  78. package/dist-server/service/kpi/kpi.js +29 -9
  79. package/dist-server/service/kpi/kpi.js.map +1 -1
  80. package/dist-server/service/kpi-category/kpi-category-mutation.d.ts +1 -1
  81. package/dist-server/service/kpi-category/kpi-category-mutation.js +3 -3
  82. package/dist-server/service/kpi-category/kpi-category-mutation.js.map +1 -1
  83. package/dist-server/service/kpi-category/kpi-category-query.d.ts +13 -0
  84. package/dist-server/service/kpi-category/kpi-category-query.js +180 -0
  85. package/dist-server/service/kpi-category/kpi-category-query.js.map +1 -1
  86. package/dist-server/service/kpi-category/kpi-category-type.d.ts +3 -0
  87. package/dist-server/service/kpi-category/kpi-category-type.js +16 -1
  88. package/dist-server/service/kpi-category/kpi-category-type.js.map +1 -1
  89. package/dist-server/service/kpi-category/kpi-category.d.ts +2 -0
  90. package/dist-server/service/kpi-category/kpi-category.js +10 -1
  91. package/dist-server/service/kpi-category/kpi-category.js.map +1 -1
  92. package/dist-server/service/kpi-metric/kpi-metric-type.d.ts +5 -3
  93. package/dist-server/service/kpi-metric/kpi-metric-type.js +5 -3
  94. package/dist-server/service/kpi-metric/kpi-metric-type.js.map +1 -1
  95. package/dist-server/service/kpi-metric/kpi-metric.d.ts +2 -8
  96. package/dist-server/service/kpi-metric/kpi-metric.js +3 -14
  97. package/dist-server/service/kpi-metric/kpi-metric.js.map +1 -1
  98. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +67 -45
  99. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -1
  100. package/dist-server/service/kpi-metric-value/kpi-metric-value.js +3 -2
  101. package/dist-server/service/kpi-metric-value/kpi-metric-value.js.map +1 -1
  102. package/dist-server/service/kpi-value/kpi-value-mutation.d.ts +2 -1
  103. package/dist-server/service/kpi-value/kpi-value-mutation.js +114 -6
  104. package/dist-server/service/kpi-value/kpi-value-mutation.js.map +1 -1
  105. package/dist-server/service/kpi-value/kpi-value-query.d.ts +0 -2
  106. package/dist-server/service/kpi-value/kpi-value-query.js +0 -12
  107. package/dist-server/service/kpi-value/kpi-value-query.js.map +1 -1
  108. package/dist-server/service/kpi-value/kpi-value-score.service.d.ts +26 -0
  109. package/dist-server/service/kpi-value/kpi-value-score.service.js +97 -0
  110. package/dist-server/service/kpi-value/kpi-value-score.service.js.map +1 -0
  111. package/dist-server/service/kpi-value/kpi-value-type.d.ts +2 -0
  112. package/dist-server/service/kpi-value/kpi-value-type.js +14 -0
  113. package/dist-server/service/kpi-value/kpi-value-type.js.map +1 -1
  114. package/dist-server/service/kpi-value/kpi-value.d.ts +1 -0
  115. package/dist-server/service/kpi-value/kpi-value.js +9 -1
  116. package/dist-server/service/kpi-value/kpi-value.js.map +1 -1
  117. package/dist-server/service/utils/value-date-util.d.ts +3 -0
  118. package/dist-server/service/utils/value-date-util.js +76 -0
  119. package/dist-server/service/utils/value-date-util.js.map +1 -0
  120. package/dist-server/tsconfig.tsbuildinfo +1 -1
  121. package/package.json +2 -2
  122. package/server/calculator/evaluator.ts +45 -0
  123. package/server/calculator/functions.ts +67 -0
  124. package/server/calculator/index.ts +4 -0
  125. package/server/calculator/parser.ts +128 -0
  126. package/server/calculator/provider.ts +10 -0
  127. package/server/controllers/kpi-metric-value-provider.ts +66 -0
  128. package/server/controllers/kpi-value-provider.ts +51 -0
  129. package/server/service/kpi/aggregate-kpi.ts +4 -4
  130. package/server/service/kpi/kpi-grade.types.ts +11 -10
  131. package/server/service/kpi/kpi-history.ts +2 -2
  132. package/server/service/kpi/kpi-mutation.ts +128 -4
  133. package/server/service/kpi/kpi-type.ts +21 -9
  134. package/server/service/kpi/kpi.ts +32 -10
  135. package/server/service/kpi-category/kpi-category-mutation.ts +3 -3
  136. package/server/service/kpi-category/kpi-category-query.ts +175 -1
  137. package/server/service/kpi-category/kpi-category-type.ts +17 -6
  138. package/server/service/kpi-category/kpi-category.ts +10 -1
  139. package/server/service/kpi-metric/kpi-metric-type.ts +7 -5
  140. package/server/service/kpi-metric/kpi-metric.ts +3 -15
  141. package/server/service/kpi-metric-value/kpi-metric-value-mutation.ts +67 -47
  142. package/server/service/kpi-metric-value/kpi-metric-value.ts +4 -2
  143. package/server/service/kpi-value/kpi-value-mutation.ts +110 -6
  144. package/server/service/kpi-value/kpi-value-query.ts +2 -8
  145. package/server/service/kpi-value/kpi-value-score.service.ts +112 -0
  146. package/server/service/kpi-value/kpi-value-type.ts +12 -0
  147. package/server/service/kpi-value/kpi-value.ts +8 -1
  148. package/server/service/utils/value-date-util.ts +72 -0
  149. package/dist-server/service/kpi-value/kpi-value-grade.service.d.ts +0 -34
  150. package/dist-server/service/kpi-value/kpi-value-grade.service.js +0 -117
  151. package/dist-server/service/kpi-value/kpi-value-grade.service.js.map +0 -1
  152. 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.21",
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": "0b142308f7695abe45d6e144d22e075bef5eda3d"
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,4 @@
1
+ export * from './parser'
2
+ export * from './evaluator'
3
+ export * from './functions'
4
+ export * from './provider'
@@ -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,10 @@
1
+ export interface ValueProvider {
2
+ get(name: string): any | Promise<any>
3
+ }
4
+
5
+ export class ObjectValueProvider implements ValueProvider {
6
+ constructor(private values: Record<string, any>) {}
7
+ get(name: string) {
8
+ return this.values[name]
9
+ }
10
+ }
@@ -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 { KpiValueGradeService } from '../kpi-value/kpi-value-grade.service'
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 gradeService = new KpiValueGradeService()
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 gradeService.calculateAndSaveGrade(entity, kpi)
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 KpiGrade {
5
- /** 등급명 (예: A, B, C, 우수, 양호 등) */
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 KpiGrades = KpiGrade[]
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 { KpiGrades } from './kpi-grade.types'
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?: KpiGrades
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.categoryId
24
- ? await getRepository(KpiCategory).findOne({ where: { id: kpi.categoryId } })
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.categoryId
108
- ? await getRepository(KpiCategory).findOne({ where: { id: patch.categoryId } })
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
  }