@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
@@ -5,7 +5,7 @@ import { ObjectType, Field, InputType, Int, ID, registerEnumType } from 'type-gr
5
5
  import { ObjectRef, ScalarObject } from '@things-factory/shell'
6
6
 
7
7
  import { Kpi, KpiStatus, KpiVizType } from './kpi'
8
- import { KpiGrades } from './kpi-grade.types'
8
+ import { KpiScores } from './kpi-grade.types'
9
9
 
10
10
  @InputType({ description: 'Input type for creating a new KPI. Used in mutations to provide KPI details.' })
11
11
  export class NewKpi {
@@ -15,8 +15,8 @@ export class NewKpi {
15
15
  @Field({ nullable: true, description: 'Detailed description of the KPI.' })
16
16
  description?: string
17
17
 
18
- @Field(type => ID, { nullable: true, description: 'ID of the category to which this KPI belongs.' })
19
- categoryId?: string
18
+ @Field(type => ObjectRef, { nullable: true, description: 'Reference to the category to which this KPI belongs.' })
19
+ category?: ObjectRef
20
20
 
21
21
  @Field({ nullable: true, description: 'Calculation formula for the KPI, using metric codes and operators.' })
22
22
  formula?: string
@@ -57,9 +57,15 @@ export class NewKpi {
57
57
 
58
58
  @Field(type => ScalarObject, {
59
59
  nullable: true,
60
- description: 'Grade configuration for this KPI version'
60
+ description: 'Score lookup table for this KPI version'
61
61
  })
62
- grades?: KpiGrades
62
+ grades?: KpiScores
63
+
64
+ @Field({
65
+ nullable: true,
66
+ description: 'Score calculation formula for this KPI. Converts KPI value to performance score (0-1).'
67
+ })
68
+ scoreFormula?: string
63
69
  }
64
70
 
65
71
  @InputType({ description: 'Input type for updating an existing KPI. Used in mutations to patch KPI details.' })
@@ -73,8 +79,8 @@ export class KpiPatch {
73
79
  @Field({ nullable: true, description: 'Detailed description of the KPI.' })
74
80
  description?: string
75
81
 
76
- @Field(type => ID, { nullable: true, description: 'ID of the category to which this KPI belongs.' })
77
- categoryId?: string
82
+ @Field(type => ObjectRef, { nullable: true, description: 'Reference to the category to which this KPI belongs.' })
83
+ category?: ObjectRef
78
84
 
79
85
  @Field({ nullable: true, description: 'Calculation formula for the KPI, using metric codes and operators.' })
80
86
  formula?: string
@@ -118,9 +124,15 @@ export class KpiPatch {
118
124
 
119
125
  @Field(type => ScalarObject, {
120
126
  nullable: true,
121
- description: 'Grade configuration for this KPI version'
127
+ description: 'Score lookup table for this KPI version'
128
+ })
129
+ grades?: KpiScores
130
+
131
+ @Field({
132
+ nullable: true,
133
+ description: 'Score calculation formula for this KPI. Converts KPI value to performance score (0-1).'
122
134
  })
123
- grades?: KpiGrades
135
+ scoreFormula?: string
124
136
  }
125
137
 
126
138
  @ObjectType()
@@ -19,7 +19,7 @@ import { KpiCategory } from '../kpi-category/kpi-category'
19
19
  import { config } from '@things-factory/env'
20
20
  import { ScalarObject } from '@things-factory/shell'
21
21
 
22
- import { KpiGrades } from './kpi-grade.types'
22
+ import { KpiScores } from './kpi-grade.types'
23
23
 
24
24
  const ORMCONFIG = config.get('ormconfig', {})
25
25
  const DATABASE_TYPE = ORMCONFIG.type
@@ -53,7 +53,9 @@ export enum KpiPeriodType {
53
53
  WEEK = 'WEEK',
54
54
  MONTH = 'MONTH',
55
55
  QUARTER = 'QUARTER',
56
- RANGE = 'RANGE'
56
+ YEAR = 'YEAR',
57
+ RANGE = 'RANGE',
58
+ ALLTIME = 'ALLTIME'
57
59
  }
58
60
 
59
61
  registerEnumType(KpiStatus, {
@@ -68,7 +70,7 @@ registerEnumType(KpiVizType, {
68
70
 
69
71
  registerEnumType(KpiPeriodType, {
70
72
  name: 'KpiPeriodType',
71
- description: 'Aggregation period type for KPI (DAY, WEEK, MONTH, QUARTER, RANGE)'
73
+ description: 'Aggregation period type for KPI (DAY, WEEK, MONTH, QUARTER, RANGE, ALLTIME)'
72
74
  })
73
75
 
74
76
  @Entity()
@@ -182,6 +184,33 @@ export class Kpi {
182
184
  @Field({ nullable: true, description: 'Weight for aggregation in parent category.' })
183
185
  weight?: number
184
186
 
187
+ @Column({ type: 'simple-json', nullable: true })
188
+ @Field(type => ScalarObject, {
189
+ nullable: true,
190
+ description:
191
+ 'Performance index lookup table for complex transformations. @deprecated 향후 제거 예정. performanceFormula 사용 권장.'
192
+ })
193
+ grades?: KpiScores
194
+
195
+ @Column({
196
+ nullable: true,
197
+ type:
198
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
199
+ ? 'longtext'
200
+ : DATABASE_TYPE == 'oracle'
201
+ ? 'clob'
202
+ : DATABASE_TYPE == 'mssql'
203
+ ? 'nvarchar'
204
+ : 'varchar',
205
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
206
+ })
207
+ @Field({
208
+ nullable: true,
209
+ description:
210
+ 'Score calculation formula for this KPI. Converts KPI value to performance score (0-100). For complex mappings, use grades lookup table instead. Example: "if(value >= 90, 100, if(value >= 80, 85, 70))" or "value * 0.8 + 20"'
211
+ })
212
+ scoreFormula?: string
213
+
185
214
  @CreateDateColumn()
186
215
  @Field({ nullable: true, description: 'Timestamp when this KPI was created.' })
187
216
  createdAt?: Date
@@ -212,11 +241,4 @@ export class Kpi {
212
241
 
213
242
  @Field(type => String, { nullable: true, description: 'Thumbnail image or file path for this KPI.' })
214
243
  thumbnail?: string
215
-
216
- @Column({ type: 'simple-json', nullable: true })
217
- @Field(type => ScalarObject, {
218
- nullable: true,
219
- description: 'Grade configuration for this KPI version'
220
- })
221
- grades?: KpiGrades
222
244
  }
@@ -10,14 +10,14 @@ export class KpiCategoryMutation {
10
10
  @Directive('@transaction')
11
11
  @Mutation(returns => KpiCategory, { description: 'Create a new KPI category with the provided details.' })
12
12
  async createKpiCategory(
13
- @Arg('category', { description: 'Input object containing details for the new KPI category.' })
14
- category: NewKpiCategory,
13
+ @Arg('kpiCategory', { description: 'Input object containing details for the new KPI category.' })
14
+ kpiCategory: NewKpiCategory,
15
15
  @Ctx() context: ResolverContext
16
16
  ): Promise<KpiCategory> {
17
17
  const { domain, user, tx } = context.state
18
18
 
19
19
  const result = await getRepository(KpiCategory, tx).save({
20
- ...category,
20
+ ...kpiCategory,
21
21
  domain,
22
22
  creator: user,
23
23
  updater: user
@@ -1,9 +1,44 @@
1
- import { Resolver, Query, FieldResolver, Root, Args, Arg, Ctx, Directive } from 'type-graphql'
1
+ import { Resolver, Query, FieldResolver, Float, Root, Args, Arg, Ctx, Directive, ObjectType, Field } from 'type-graphql'
2
2
  import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
3
3
  import { User } from '@things-factory/auth-base'
4
4
  import { KpiCategory } from './kpi-category'
5
5
  import { KpiCategoryList } from './kpi-category-type'
6
6
  import { Kpi } from '../kpi/kpi'
7
+ import { KpiValue } from '../kpi-value/kpi-value'
8
+ import { parseFormula } from '../../calculator/parser'
9
+ import { evaluateFormula } from '../../calculator/evaluator'
10
+ import { builtinFunctions } from '../../calculator/functions'
11
+ import { KpiValueProvider } from '../../controllers/kpi-value-provider'
12
+ import { getDefaultValueDate } from '../utils/value-date-util'
13
+ import { KpiPeriodType } from '../kpi/kpi'
14
+ import { KpiValueScoreService } from '../kpi-value/kpi-value-score.service'
15
+
16
+ @ObjectType()
17
+ class KpiValueItem {
18
+ @Field()
19
+ kpiId: string
20
+
21
+ @Field(type => Float, { nullable: true })
22
+ value: number
23
+ }
24
+
25
+ @ObjectType()
26
+ class KpiCategoryValueResult {
27
+ @Field(type => Float, { nullable: true })
28
+ value?: number
29
+
30
+ @Field(type => Float, { nullable: true, description: 'Performance score for this category (0-1 range)' })
31
+ score?: number
32
+
33
+ @Field({ nullable: true })
34
+ valueDate?: string
35
+
36
+ @Field({ nullable: true })
37
+ group?: string
38
+
39
+ @Field(type => [KpiValueItem])
40
+ kpiValues: KpiValueItem[]
41
+ }
7
42
 
8
43
  @Resolver(KpiCategory)
9
44
  export class KpiCategoryQuery {
@@ -41,6 +76,145 @@ export class KpiCategoryQuery {
41
76
  return { items, total }
42
77
  }
43
78
 
79
+ @Query(returns => KpiCategoryValueResult, { description: '카테고리 단위 KPI 실적값 집계/조회' })
80
+ async calculateKpiValue(
81
+ @Arg('categoryId') categoryId: string,
82
+ @Arg('valueDate', { nullable: true }) valueDate: string,
83
+ @Arg('group', { nullable: true }) group: string,
84
+ @Ctx() context: ResolverContext
85
+ ): Promise<KpiCategoryValueResult> {
86
+ const { domain, tx } = context.state
87
+ // 1. 카테고리 정보 조회 (formula 포함)
88
+ const category = await getRepository(KpiCategory).findOne({
89
+ where: { domain: { id: domain.id }, id: categoryId }
90
+ })
91
+ if (!category) return { value: null, score: null, kpiValues: [] }
92
+
93
+ // 기본 계산 기준일 설정 (카테고리 periodType 사용)
94
+ const defaultDate = valueDate || getDefaultValueDate(category.periodType || KpiPeriodType.DAY, 'last')
95
+
96
+ // 2. 카테고리 formula가 있으면 formula로 계산
97
+ if (category.formula) {
98
+ const ast = parseFormula(category.formula)
99
+ const provider = new KpiValueProvider({
100
+ valueDate: defaultDate,
101
+ group,
102
+ domainId: domain.id,
103
+ tx
104
+ })
105
+ const evalContext = { functions: builtinFunctions, provider }
106
+ const value = await evaluateFormula(ast, evalContext)
107
+ // 카테고리 formula 결과를 score로 변환 (0-1 범위)
108
+ const score = value !== null && value !== undefined && !isNaN(value) ? Math.max(0, Math.min(1, value)) : null
109
+
110
+ return {
111
+ value,
112
+ score,
113
+ valueDate: defaultDate,
114
+ group,
115
+ kpiValues: [] // 카테고리 formula 기반이므로 개별 KPI 값은 제공하지 않음
116
+ }
117
+ }
118
+
119
+ // 3. formula가 없으면 KPI score들의 가중 평균으로 category score 계산
120
+ const kpis = await getRepository(Kpi).find({
121
+ where: { domain: { id: domain.id }, category: { id: categoryId } }
122
+ })
123
+ if (!kpis.length) return { value: null, score: null, kpiValues: [] }
124
+
125
+ let totalWeight = 0
126
+ let weightedScoreSum = 0
127
+ const kpiValues: KpiValueItem[] = []
128
+
129
+ for (const kpi of kpis) {
130
+ // KPI value 계산
131
+ let value = null
132
+ if (kpi.formula) {
133
+ const ast = parseFormula(kpi.formula)
134
+ const provider = new KpiValueProvider({
135
+ valueDate: defaultDate,
136
+ group,
137
+ domainId: domain.id,
138
+ tx
139
+ })
140
+ const evalContext = { functions: builtinFunctions, provider }
141
+ value = await evaluateFormula(ast, evalContext)
142
+ } else {
143
+ const kpiValue = await getRepository(KpiValue).findOne({
144
+ where: {
145
+ kpi: { id: kpi.id },
146
+ valueDate: defaultDate,
147
+ group: group ?? '',
148
+ domain: { id: domain.id }
149
+ }
150
+ })
151
+ value = kpiValue?.value ?? 0
152
+ }
153
+
154
+ // KPI score 계산
155
+ const scoreService = new KpiValueScoreService()
156
+ let kpiScore = null
157
+ if (value !== null && value !== undefined) {
158
+ let scoreResult = await scoreService.calculateScoreFromFormula(kpi, value)
159
+ if (!scoreResult) {
160
+ scoreResult = scoreService.calculateScoreFromLookup(kpi, value)
161
+ }
162
+ kpiScore = scoreResult?.score ?? null
163
+ }
164
+
165
+ const weight = kpi.weight ?? 1
166
+ if (kpiScore !== null) {
167
+ weightedScoreSum += kpiScore * weight
168
+ totalWeight += weight
169
+ }
170
+
171
+ kpiValues.push({ kpiId: kpi.id, value })
172
+ }
173
+
174
+ // category score 계산 (KPI score들의 가중 평균)
175
+ const categoryScore = totalWeight ? weightedScoreSum / totalWeight : null
176
+
177
+ // category value는 기존 방식대로 계산 (호환성 유지)
178
+ let totalValueWeight = 0
179
+ let weightedValueSum = 0
180
+ for (const kpi of kpis) {
181
+ let value = null
182
+ if (kpi.formula) {
183
+ const ast = parseFormula(kpi.formula)
184
+ const provider = new KpiValueProvider({
185
+ valueDate: defaultDate,
186
+ group,
187
+ domainId: domain.id,
188
+ tx
189
+ })
190
+ const evalContext = { functions: builtinFunctions, provider }
191
+ value = await evaluateFormula(ast, evalContext)
192
+ } else {
193
+ const kpiValue = await getRepository(KpiValue).findOne({
194
+ where: {
195
+ kpi: { id: kpi.id },
196
+ valueDate: defaultDate,
197
+ group: group ?? '',
198
+ domain: { id: domain.id }
199
+ }
200
+ })
201
+ value = kpiValue?.value ?? 0
202
+ }
203
+ const weight = kpi.weight ?? 1
204
+ weightedValueSum += (value ?? 0) * weight
205
+ totalValueWeight += weight
206
+ }
207
+ const resultValue = totalValueWeight ? weightedValueSum / totalValueWeight : null
208
+
209
+ return {
210
+ value: resultValue,
211
+ score: categoryScore,
212
+ valueDate: defaultDate,
213
+ group,
214
+ kpiValues
215
+ }
216
+ }
217
+
44
218
  @FieldResolver(type => [Kpi])
45
219
  async kpis(@Root() kpiCategory: KpiCategory): Promise<Kpi[]> {
46
220
  return await getRepository(Kpi).find({
@@ -1,10 +1,7 @@
1
- import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
2
- import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
3
- import { ObjectType, Field, InputType, Int, ID, registerEnumType } from 'type-graphql'
4
-
5
- import { ObjectRef, ScalarObject } from '@things-factory/shell'
1
+ import { ObjectType, Field, InputType, Int, ID } from 'type-graphql'
6
2
 
7
3
  import { KpiCategory } from './kpi-category'
4
+ import { KpiPeriodType } from '../kpi/kpi'
8
5
 
9
6
  @InputType({
10
7
  description: 'Input type for creating a new KPI category. Used in mutations to provide category details.'
@@ -24,6 +21,13 @@ export class NewKpiCategory {
24
21
 
25
22
  @Field({ nullable: true, description: 'Weight for aggregation in higher-level summary.' })
26
23
  weight?: number
24
+
25
+ @Field(type => KpiPeriodType, {
26
+ nullable: true,
27
+ description:
28
+ 'Calculation formula for category score using KPI score variables. Example: "avg(sales_score, profit_score)" or "sales_score * 0.6 + profit_score * 0.4". If not provided, weighted average of child KPI scores will be used.'
29
+ })
30
+ periodType?: KpiPeriodType
27
31
  }
28
32
 
29
33
  @InputType({
@@ -42,12 +46,19 @@ export class KpiCategoryPatch {
42
46
  @Field({ nullable: true, description: 'Whether this category is active (usable) or not.' })
43
47
  active?: boolean
44
48
 
45
- @Field({ nullable: true, description: 'Aggregation formula using child KPI codes.' })
49
+ @Field({
50
+ nullable: true,
51
+ description:
52
+ 'Calculation formula for category score using KPI score variables. Example: "avg(sales_score, profit_score)" or "sales_score * 0.6 + profit_score * 0.4". If not provided, weighted average of child KPI scores will be used.'
53
+ })
46
54
  formula?: string
47
55
 
48
56
  @Field({ nullable: true, description: 'Weight for aggregation in higher-level summary.' })
49
57
  weight?: number
50
58
 
59
+ @Field(type => KpiPeriodType, { nullable: true, description: 'Aggregation period type for this category.' })
60
+ periodType?: KpiPeriodType
61
+
51
62
  @Field({ nullable: true, description: 'Custom flag for update operations (internal use).' })
52
63
  cuFlag?: string
53
64
  }
@@ -14,6 +14,7 @@ import { ObjectType, Field, Int, ID } from 'type-graphql'
14
14
  import { Domain } from '@things-factory/shell'
15
15
  import { User } from '@things-factory/auth-base'
16
16
  import { Kpi } from '../kpi/kpi'
17
+ import { KpiPeriodType } from '../kpi/kpi'
17
18
 
18
19
  @Entity()
19
20
  @Index('ix_kpi_category_0', (kpiCategory: KpiCategory) => [kpiCategory.domain, kpiCategory.name], {
@@ -49,13 +50,21 @@ export class KpiCategory {
49
50
  active?: boolean
50
51
 
51
52
  @Column({ nullable: true })
52
- @Field({ nullable: true, description: 'Aggregation formula using child KPI codes.' })
53
+ @Field({
54
+ nullable: true,
55
+ description:
56
+ 'Calculation formula for category score using KPI score variables. Example: "avg(sales_score, profit_score)" or "sales_score * 0.6 + profit_score * 0.4". If not provided, weighted average of child KPI scores will be used.'
57
+ })
53
58
  formula?: string
54
59
 
55
60
  @Column({ type: 'float', nullable: true, default: 1 })
56
61
  @Field({ nullable: true, description: 'Weight for aggregation in higher-level summary.' })
57
62
  weight?: number
58
63
 
64
+ @Column({ default: 'DAY' })
65
+ @Field(type => KpiPeriodType, { description: 'Aggregation period type for this category.' })
66
+ periodType: KpiPeriodType
67
+
59
68
  @ManyToOne(type => User, { nullable: true })
60
69
  @Field(type => User, { nullable: true, description: 'User who created this KPI category.' })
61
70
  creator?: User
@@ -2,7 +2,9 @@ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
2
2
  import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
3
3
  import { ObjectType, Field, InputType, Int, ID, registerEnumType } from 'type-graphql'
4
4
 
5
- import { KpiMetric, KpiMetricPeriodType, KpiMetricCollectType } from './kpi-metric'
5
+ import { KpiPeriodType } from '../kpi/kpi'
6
+ import { KpiMetricCollectType } from './kpi-metric'
7
+ import { KpiMetric } from './kpi-metric'
6
8
 
7
9
  @InputType({ description: 'Input type for creating a new KPI metric. Used in mutations to provide metric details.' })
8
10
  export class NewKpiMetric {
@@ -41,8 +43,8 @@ export class NewKpiMetric {
41
43
  @Field({ nullable: true, description: 'Schedule ID for the registered cron job.' })
42
44
  scheduleId?: string
43
45
 
44
- @Field(type => KpiMetricPeriodType, { nullable: true, description: 'Aggregation period type for this metric.' })
45
- periodType?: KpiMetricPeriodType
46
+ @Field(type => KpiPeriodType, { nullable: true, description: 'Aggregation period type for this metric.' })
47
+ periodType?: KpiPeriodType
46
48
 
47
49
  @Field(type => KpiMetricCollectType, { nullable: true, description: '데이터 수집 방식' })
48
50
  collectType?: KpiMetricCollectType
@@ -89,8 +91,8 @@ export class KpiMetricPatch {
89
91
  @Field({ nullable: true, description: 'Schedule ID for the registered cron job.' })
90
92
  scheduleId?: string
91
93
 
92
- @Field(type => KpiMetricPeriodType, { nullable: true, description: 'Aggregation period type for this metric.' })
93
- periodType?: KpiMetricPeriodType
94
+ @Field(type => KpiPeriodType, { nullable: true, description: 'Aggregation period type for this metric.' })
95
+ periodType?: KpiPeriodType
94
96
 
95
97
  @Field({ nullable: true })
96
98
  cuFlag?: string
@@ -14,19 +14,7 @@ import { ObjectType, Field, Int, ID, registerEnumType } from 'type-graphql'
14
14
  import { Domain } from '@things-factory/shell'
15
15
  import { User } from '@things-factory/auth-base'
16
16
  import { DataSet } from '@things-factory/dataset'
17
-
18
- export enum KpiMetricPeriodType {
19
- DAY = 'DAY',
20
- WEEK = 'WEEK',
21
- MONTH = 'MONTH',
22
- QUARTER = 'QUARTER',
23
- RANGE = 'RANGE'
24
- }
25
-
26
- registerEnumType(KpiMetricPeriodType, {
27
- name: 'KpiMetricPeriodType',
28
- description: 'Aggregation period type for metric (DAY, WEEK, MONTH, QUARTER, RANGE)'
29
- })
17
+ import { KpiPeriodType } from '../kpi/kpi'
30
18
 
31
19
  export enum KpiMetricCollectType {
32
20
  AUTO = 'AUTO', // 데이터셋 등 자동 수집
@@ -110,8 +98,8 @@ export class KpiMetric {
110
98
  collectType: KpiMetricCollectType
111
99
 
112
100
  @Column({ default: 'DAY' })
113
- @Field(type => KpiMetricPeriodType, { description: 'Aggregation period type for this metric.' })
114
- periodType: KpiMetricPeriodType
101
+ @Field(type => KpiPeriodType, { description: 'Aggregation period type for this metric.' })
102
+ periodType: KpiPeriodType
115
103
 
116
104
  @CreateDateColumn()
117
105
  @Field({ nullable: true, description: 'Timestamp when this KPI metric was created.' })
@@ -8,37 +8,7 @@ import { KpiMetricValue } from './kpi-metric-value'
8
8
  import { NewKpiMetricValue, KpiMetricValuePatch } from './kpi-metric-value-type'
9
9
  import { KpiMetric } from '../kpi-metric/kpi-metric'
10
10
  import { ScalarObject } from '@things-factory/shell'
11
-
12
- function getISOWeek(date: Date): number {
13
- const tmp = new Date(date.getTime())
14
- tmp.setHours(0, 0, 0, 0)
15
- tmp.setDate(tmp.getDate() + 4 - (tmp.getDay() || 7))
16
- const yearStart = new Date(tmp.getFullYear(), 0, 1)
17
- const weekNo = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
18
- return weekNo
19
- }
20
-
21
- function getDefaultValueDate(periodType: KpiPeriodType): string {
22
- const now = new Date()
23
- switch (periodType) {
24
- case KpiPeriodType.DAY:
25
- return now.toISOString().slice(0, 10)
26
- case KpiPeriodType.MONTH:
27
- return now.toISOString().slice(0, 7)
28
- case KpiPeriodType.QUARTER: {
29
- const year = now.getFullYear()
30
- const quarter = Math.floor(now.getMonth() / 3) + 1
31
- return `${year}-Q${quarter}`
32
- }
33
- case KpiPeriodType.WEEK: {
34
- const year = now.getFullYear()
35
- const week = getISOWeek(now)
36
- return `${year}-W${week}`
37
- }
38
- default:
39
- return now.toISOString().slice(0, 10)
40
- }
41
- }
11
+ import { getDefaultValueDate } from '../utils/value-date-util'
42
12
 
43
13
  @Resolver(KpiMetricValue)
44
14
  export class KpiMetricValueMutation {
@@ -55,11 +25,26 @@ export class KpiMetricValueMutation {
55
25
  ? await getRepository(KpiMetric).findOne({ where: { id: metricValue.metricId } })
56
26
  : undefined
57
27
 
28
+ // valueDate 자동 생성/보정 로직 추가
29
+ let periodType = metricValue.periodType
30
+ if (!periodType && metric) {
31
+ periodType = metric.periodType
32
+ }
33
+ if (!periodType) {
34
+ periodType = KpiPeriodType.DAY
35
+ }
36
+ // periodType을 string으로 변환 후 KpiPeriodType으로 강제 변환
37
+ periodType = KpiPeriodType[String(periodType) as keyof typeof KpiPeriodType]
38
+ let valueDate = metricValue.valueDate
39
+ if (!valueDate || !isValueDateValidForPeriodType(valueDate, periodType)) {
40
+ valueDate = getDefaultValueDate(periodType)
41
+ }
42
+
58
43
  const entity: Partial<KpiMetricValue> = {
59
44
  metric,
60
45
  metricId: metricValue.metricId,
61
- valueDate: metricValue.valueDate,
62
- periodType: metricValue.periodType,
46
+ valueDate,
47
+ periodType,
63
48
  value: metricValue.value,
64
49
  group: metricValue.group,
65
50
  unit: metricValue.unit,
@@ -86,24 +71,40 @@ export class KpiMetricValueMutation {
86
71
  const { domain, user, tx } = context.state
87
72
  const metric = await getRepository(KpiMetric).findOne({ where: { name: metricName, domain: { id: domain.id } } })
88
73
  if (!metric) throw new Error(`Metric not found: ${metricName}`)
89
- const periodType = (metric.periodType as unknown as KpiPeriodType) || KpiPeriodType.DAY
90
- const valueDateToUse = getDefaultValueDate(periodType)
74
+ const periodType = metric.periodType || KpiPeriodType.DAY
75
+ const valueDate = getDefaultValueDate(periodType)
91
76
  if (value == null && meta == null) {
92
77
  throw new Error('Either value or meta must be provided.')
93
78
  }
94
- const entity: Partial<KpiMetricValue> = {
95
- metric,
96
- metricId: metric.id,
97
- value,
98
- meta,
99
- valueDate: valueDateToUse,
100
- periodType,
101
- group,
102
- domain,
103
- creator: user,
104
- updater: user
79
+ const repo = getRepository(KpiMetricValue, tx)
80
+ let entity = await repo.findOne({
81
+ where: {
82
+ metric: { id: metric.id },
83
+ valueDate,
84
+ periodType,
85
+ group,
86
+ domain: { id: domain.id }
87
+ }
88
+ })
89
+ if (entity) {
90
+ entity.value = value
91
+ entity.meta = meta
92
+ entity.updater = user
93
+ } else {
94
+ entity = repo.create({
95
+ metric,
96
+ metricId: metric.id,
97
+ value,
98
+ meta,
99
+ valueDate,
100
+ periodType,
101
+ group,
102
+ domain,
103
+ creator: user,
104
+ updater: user
105
+ })
105
106
  }
106
- return await getRepository(KpiMetricValue, tx).save(entity)
107
+ return await repo.save(entity)
107
108
  }
108
109
 
109
110
  @Directive('@transaction')
@@ -213,3 +214,22 @@ export class KpiMetricValueMutation {
213
214
  return true
214
215
  }
215
216
  }
217
+
218
+ // valueDate와 periodType의 일치 여부를 검사하는 유틸리티 함수(임시, 아래에 추가)
219
+ function isValueDateValidForPeriodType(valueDate: string, periodType: KpiPeriodType): boolean {
220
+ if (!valueDate) return false
221
+ switch (periodType) {
222
+ case KpiPeriodType.DAY:
223
+ return /^\d{4}-\d{2}-\d{2}$/.test(valueDate)
224
+ case KpiPeriodType.MONTH:
225
+ return /^\d{4}-\d{2}$/.test(valueDate)
226
+ case KpiPeriodType.QUARTER:
227
+ return /^\d{4}-Q[1-4]$/.test(valueDate)
228
+ case KpiPeriodType.WEEK:
229
+ return /^\d{4}-W\d{1,2}$/.test(valueDate)
230
+ case KpiPeriodType.ALLTIME:
231
+ return valueDate === 'ALLTIME'
232
+ default:
233
+ return true
234
+ }
235
+ }
@@ -3,6 +3,7 @@ import {
3
3
  PrimaryGeneratedColumn,
4
4
  Column,
5
5
  ManyToOne,
6
+ Index,
6
7
  JoinColumn,
7
8
  CreateDateColumn,
8
9
  UpdateDateColumn,
@@ -15,6 +16,7 @@ import { KpiPeriodType } from '../kpi/kpi'
15
16
  import { User } from '@things-factory/auth-base'
16
17
 
17
18
  @Entity()
19
+ @Index('ix_kpi_metric_value_latest', ['domain', 'metric', 'valueDate', 'group'], { unique: true })
18
20
  @ObjectType({ description: 'Current value for each KPI metric (can be used for both state and history).' })
19
21
  export class KpiMetricValue {
20
22
  @PrimaryGeneratedColumn('uuid')
@@ -46,7 +48,7 @@ export class KpiMetricValue {
46
48
  @Field({ nullable: true })
47
49
  unit?: string
48
50
 
49
- @Column({ type: 'varchar', length: 20 })
51
+ @Column()
50
52
  @Field({
51
53
  description:
52
54
  'Date or period for which this metric value is recorded (e.g., day: YYYY-MM-DD, month: YYYY-MM, range: YYYY-MM-DD~YYYY-MM-DD).'
@@ -57,7 +59,7 @@ export class KpiMetricValue {
57
59
  @Field(type => KpiPeriodType)
58
60
  periodType: KpiPeriodType
59
61
 
60
- @Column({ nullable: true })
62
+ @Column({ default: '' })
61
63
  @Field({ nullable: true, description: 'Group key for this value (organization, line, user, etc.)' })
62
64
  group?: string
63
65