@theromans/convex-dobty 0.1.0

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 (133) hide show
  1. package/README.md +24 -0
  2. package/dist/client/index.d.ts +287 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +215 -0
  5. package/dist/client/index.js.map +1 -0
  6. package/dist/component/_generated/api.d.ts +23 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -0
  8. package/dist/component/_generated/api.js +15 -0
  9. package/dist/component/_generated/api.js.map +1 -0
  10. package/dist/component/_generated/dataModel.d.ts +28 -0
  11. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  12. package/dist/component/_generated/dataModel.js +11 -0
  13. package/dist/component/_generated/dataModel.js.map +1 -0
  14. package/dist/component/_generated/server.d.ts +23 -0
  15. package/dist/component/_generated/server.d.ts.map +1 -0
  16. package/dist/component/_generated/server.js +18 -0
  17. package/dist/component/_generated/server.js.map +1 -0
  18. package/dist/component/assessment.d.ts +141 -0
  19. package/dist/component/assessment.d.ts.map +1 -0
  20. package/dist/component/assessment.js +498 -0
  21. package/dist/component/assessment.js.map +1 -0
  22. package/dist/component/convex.config.d.ts +3 -0
  23. package/dist/component/convex.config.d.ts.map +1 -0
  24. package/dist/component/convex.config.js +3 -0
  25. package/dist/component/convex.config.js.map +1 -0
  26. package/dist/component/metrics.d.ts +145 -0
  27. package/dist/component/metrics.d.ts.map +1 -0
  28. package/dist/component/metrics.js +425 -0
  29. package/dist/component/metrics.js.map +1 -0
  30. package/dist/component/potentialAnalysis.d.ts +510 -0
  31. package/dist/component/potentialAnalysis.d.ts.map +1 -0
  32. package/dist/component/potentialAnalysis.js +1035 -0
  33. package/dist/component/potentialAnalysis.js.map +1 -0
  34. package/dist/component/schema.d.ts +342 -0
  35. package/dist/component/schema.d.ts.map +1 -0
  36. package/dist/component/schema.js +194 -0
  37. package/dist/component/schema.js.map +1 -0
  38. package/dist/component/scoring.d.ts +6 -0
  39. package/dist/component/scoring.d.ts.map +1 -0
  40. package/dist/component/scoring.js +394 -0
  41. package/dist/component/scoring.js.map +1 -0
  42. package/dist/data/dm/cvr.d.ts +12 -0
  43. package/dist/data/dm/cvr.d.ts.map +1 -0
  44. package/dist/data/dm/cvr.js +71 -0
  45. package/dist/data/dm/cvr.js.map +1 -0
  46. package/dist/data/dm/guidelines.d.ts +9 -0
  47. package/dist/data/dm/guidelines.d.ts.map +1 -0
  48. package/dist/data/dm/guidelines.js +22 -0
  49. package/dist/data/dm/guidelines.js.map +1 -0
  50. package/dist/data/dm/methods.d.ts +10 -0
  51. package/dist/data/dm/methods.d.ts.map +1 -0
  52. package/dist/data/dm/methods.js +50 -0
  53. package/dist/data/dm/methods.js.map +1 -0
  54. package/dist/data/dm/systems.d.ts +10 -0
  55. package/dist/data/dm/systems.d.ts.map +1 -0
  56. package/dist/data/dm/systems.js +52 -0
  57. package/dist/data/dm/systems.js.map +1 -0
  58. package/dist/data/domains.d.ts +20 -0
  59. package/dist/data/domains.d.ts.map +1 -0
  60. package/dist/data/domains.js +264 -0
  61. package/dist/data/domains.js.map +1 -0
  62. package/dist/data/ek/cvr.d.ts +12 -0
  63. package/dist/data/ek/cvr.d.ts.map +1 -0
  64. package/dist/data/ek/cvr.js +58 -0
  65. package/dist/data/ek/cvr.js.map +1 -0
  66. package/dist/data/ek/guidelines.d.ts +9 -0
  67. package/dist/data/ek/guidelines.d.ts.map +1 -0
  68. package/dist/data/ek/guidelines.js +26 -0
  69. package/dist/data/ek/guidelines.js.map +1 -0
  70. package/dist/data/ek/methods.d.ts +9 -0
  71. package/dist/data/ek/methods.d.ts.map +1 -0
  72. package/dist/data/ek/methods.js +35 -0
  73. package/dist/data/ek/methods.js.map +1 -0
  74. package/dist/data/ek/systems.d.ts +9 -0
  75. package/dist/data/ek/systems.d.ts.map +1 -0
  76. package/dist/data/ek/systems.js +23 -0
  77. package/dist/data/ek/systems.js.map +1 -0
  78. package/dist/data/influence-areas.d.ts +26 -0
  79. package/dist/data/influence-areas.d.ts.map +1 -0
  80. package/dist/data/influence-areas.js +143 -0
  81. package/dist/data/influence-areas.js.map +1 -0
  82. package/dist/data/km/cvr.d.ts +13 -0
  83. package/dist/data/km/cvr.d.ts.map +1 -0
  84. package/dist/data/km/cvr.js +65 -0
  85. package/dist/data/km/cvr.js.map +1 -0
  86. package/dist/data/km/guidelines.d.ts +9 -0
  87. package/dist/data/km/guidelines.d.ts.map +1 -0
  88. package/dist/data/km/guidelines.js +20 -0
  89. package/dist/data/km/guidelines.js.map +1 -0
  90. package/dist/data/km/methods.d.ts +9 -0
  91. package/dist/data/km/methods.d.ts.map +1 -0
  92. package/dist/data/km/methods.js +28 -0
  93. package/dist/data/km/methods.js.map +1 -0
  94. package/dist/data/km/systems.d.ts +9 -0
  95. package/dist/data/km/systems.d.ts.map +1 -0
  96. package/dist/data/km/systems.js +18 -0
  97. package/dist/data/km/systems.js.map +1 -0
  98. package/dist/data/stammdaten.d.ts +7 -0
  99. package/dist/data/stammdaten.d.ts.map +1 -0
  100. package/dist/data/stammdaten.js +94 -0
  101. package/dist/data/stammdaten.js.map +1 -0
  102. package/dist/data/types.d.ts +113 -0
  103. package/dist/data/types.d.ts.map +1 -0
  104. package/dist/data/types.js +23 -0
  105. package/dist/data/types.js.map +1 -0
  106. package/package.json +38 -0
  107. package/src/client/index.ts +442 -0
  108. package/src/component/_generated/api.ts +35 -0
  109. package/src/component/_generated/component.ts +50 -0
  110. package/src/component/_generated/dataModel.ts +42 -0
  111. package/src/component/_generated/server.ts +48 -0
  112. package/src/component/assessment.ts +536 -0
  113. package/src/component/convex.config.ts +3 -0
  114. package/src/component/metrics.ts +479 -0
  115. package/src/component/potentialAnalysis.ts +1118 -0
  116. package/src/component/schema.ts +209 -0
  117. package/src/component/scoring.ts +485 -0
  118. package/src/data/dm/cvr.ts +75 -0
  119. package/src/data/dm/guidelines.ts +24 -0
  120. package/src/data/dm/methods.ts +53 -0
  121. package/src/data/dm/systems.ts +55 -0
  122. package/src/data/domains.ts +275 -0
  123. package/src/data/ek/cvr.ts +62 -0
  124. package/src/data/ek/guidelines.ts +28 -0
  125. package/src/data/ek/methods.ts +39 -0
  126. package/src/data/ek/systems.ts +25 -0
  127. package/src/data/influence-areas.ts +162 -0
  128. package/src/data/km/cvr.ts +69 -0
  129. package/src/data/km/guidelines.ts +22 -0
  130. package/src/data/km/methods.ts +30 -0
  131. package/src/data/km/systems.ts +20 -0
  132. package/src/data/stammdaten.ts +96 -0
  133. package/src/data/types.ts +183 -0
@@ -0,0 +1,209 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ /** Static question catalog — seeded at init from static data */
6
+ questions: defineTable({
7
+ questionKey: v.string(),
8
+ domain: v.string(),
9
+ dimension: v.string(),
10
+ category: v.string(),
11
+ itemKey: v.string(),
12
+ label: v.string(),
13
+ labelEn: v.optional(v.string()),
14
+ answerSchema: v.string(),
15
+ options: v.optional(v.any()),
16
+ pmNpm: v.optional(v.string()),
17
+ sortOrder: v.number(),
18
+ })
19
+ .index("by_domain", ["domain"])
20
+ .index("by_domain_dimension", ["domain", "dimension"])
21
+ .index("by_questionKey", ["questionKey"]),
22
+
23
+ /** Per-workspace, per-question answers */
24
+ answers: defineTable({
25
+ workspaceId: v.string(),
26
+ questionKey: v.string(),
27
+ value: v.any(),
28
+ source: v.string(),
29
+ confidence: v.optional(v.number()),
30
+ extractedFrom: v.optional(v.string()),
31
+ answeredBy: v.optional(v.string()),
32
+ verifiedBy: v.optional(v.string()),
33
+ verifiedAt: v.optional(v.number()),
34
+ createdAt: v.number(),
35
+ updatedAt: v.number(),
36
+ })
37
+ .index("by_workspace", ["workspaceId"])
38
+ .index("by_workspace_question", ["workspaceId", "questionKey"]),
39
+
40
+ /** Current computed scores per workspace per domain */
41
+ metrics: defineTable({
42
+ workspaceId: v.string(),
43
+ domain: v.string(),
44
+ data: v.any(),
45
+ computedAt: v.number(),
46
+ })
47
+ .index("by_workspace", ["workspaceId"])
48
+ .index("by_workspace_domain", ["workspaceId", "domain"]),
49
+
50
+ /** Addon configuration — single record per key */
51
+ config: defineTable({
52
+ key: v.string(),
53
+ enabled: v.boolean(),
54
+ mode: v.string(),
55
+ financialInputs: v.optional(v.any()),
56
+ sensitivity: v.optional(v.any()),
57
+ lifetime: v.optional(v.any()),
58
+ workspaceTypes: v.optional(v.any()),
59
+ snapshotPolicy: v.optional(v.string()),
60
+ snapshotThreshold: v.optional(v.number()),
61
+ updatedAt: v.number(),
62
+ })
63
+ .index("by_key", ["key"]),
64
+
65
+ /** Historical snapshots — immutable after creation */
66
+ snapshots: defineTable({
67
+ workspaceId: v.string(),
68
+ domain: v.string(),
69
+ dimensionScores: v.any(),
70
+ overallMaturity: v.any(),
71
+ monetaryPotential: v.optional(v.any()),
72
+ financialInputs: v.optional(v.any()),
73
+ snapshotAt: v.number(),
74
+ snapshotType: v.string(),
75
+ })
76
+ .index("by_workspace", ["workspaceId"])
77
+ .index("by_workspace_domain", ["workspaceId", "domain"]),
78
+
79
+ // ── 5DoBT Potential Analysis Tables ──
80
+
81
+ /** Top-level analysis record */
82
+ potentialAnalyses: defineTable({
83
+ workspaceId: v.string(),
84
+ name: v.optional(v.string()),
85
+ currentStep: v.number(),
86
+ status: v.string(),
87
+ createdBy: v.string(),
88
+ createdAt: v.number(),
89
+ updatedAt: v.number(),
90
+ })
91
+ .index("by_workspace", ["workspaceId"]),
92
+
93
+ /** Step I: Project Charter */
94
+ paProjectCharters: defineTable({
95
+ analysisId: v.id("potentialAnalyses"),
96
+ name: v.string(),
97
+ date: v.string(),
98
+ business_case: v.string(),
99
+ problem_statement: v.string(),
100
+ goal: v.string(),
101
+ scope_in: v.string(),
102
+ scope_out: v.string(),
103
+ objectives: v.any(),
104
+ conditions: v.optional(v.string()),
105
+ risks: v.optional(v.any()),
106
+ costs: v.optional(v.string()),
107
+ milestones: v.optional(v.any()),
108
+ project_team: v.optional(v.any()),
109
+ })
110
+ .index("by_analysis", ["analysisId"]),
111
+
112
+ /** Step II: Definition of Done items */
113
+ paDefinitionsOfDone: defineTable({
114
+ analysisId: v.id("potentialAnalyses"),
115
+ number: v.number(),
116
+ definition: v.string(),
117
+ description: v.string(),
118
+ effect_type: v.string(),
119
+ })
120
+ .index("by_analysis", ["analysisId"]),
121
+
122
+ /** Step III: Influence Area selections */
123
+ paInfluenceAreas: defineTable({
124
+ analysisId: v.id("potentialAnalyses"),
125
+ dobt_dimension: v.string(),
126
+ sub_area_key: v.string(),
127
+ potential_description: v.string(),
128
+ })
129
+ .index("by_analysis", ["analysisId"])
130
+ .index("by_analysis_dimension", ["analysisId", "dobt_dimension"]),
131
+
132
+ /** Step IV: Points of Changes */
133
+ paPointsOfChanges: defineTable({
134
+ analysisId: v.id("potentialAnalyses"),
135
+ influence_area_ref: v.id("paInfluenceAreas"),
136
+ poc_description: v.string(),
137
+ definition_of_done_refs: v.any(),
138
+ })
139
+ .index("by_analysis", ["analysisId"]),
140
+
141
+ /** Step V: Fields of Action */
142
+ paFieldsOfAction: defineTable({
143
+ analysisId: v.id("potentialAnalyses"),
144
+ poc_ref: v.id("paPointsOfChanges"),
145
+ potential_description: v.string(),
146
+ dobt_dimension: v.string(),
147
+ influence_type: v.string(),
148
+ foa_description: v.string(),
149
+ kpi_influence: v.string(),
150
+ })
151
+ .index("by_analysis", ["analysisId"])
152
+ .index("by_analysis_dimension", ["analysisId", "dobt_dimension"]),
153
+
154
+ /** Step VI: Potential Status per FOA */
155
+ paPotentialStatuses: defineTable({
156
+ analysisId: v.id("potentialAnalyses"),
157
+ foa_ref: v.id("paFieldsOfAction"),
158
+ status: v.string(),
159
+ validation_level: v.optional(v.string()),
160
+ estimation_value: v.optional(v.number()),
161
+ estimation_risk: v.optional(v.number()),
162
+ estimation_explanation: v.optional(v.string()),
163
+ estimation_result: v.optional(v.number()),
164
+ calculation_ref: v.optional(v.id("paCalculations")),
165
+ calculation_result: v.optional(v.number()),
166
+ committed_value_result: v.optional(v.number()),
167
+ committed_responsible_business_owner: v.optional(v.string()),
168
+ measured_potential_value: v.optional(v.number()),
169
+ })
170
+ .index("by_analysis", ["analysisId"])
171
+ .index("by_foa", ["foa_ref"]),
172
+
173
+ /** Step VII: Facts & Figures */
174
+ paFactsFigures: defineTable({
175
+ analysisId: v.id("potentialAnalyses"),
176
+ name: v.string(),
177
+ unit: v.string(),
178
+ value: v.number(),
179
+ })
180
+ .index("by_analysis", ["analysisId"]),
181
+
182
+ /** Step VII: Calculations per dimension */
183
+ paCalculations: defineTable({
184
+ analysisId: v.id("potentialAnalyses"),
185
+ dobt_dimension: v.string(),
186
+ potential_description: v.string(),
187
+ assumptions: v.any(),
188
+ formula: v.string(),
189
+ result: v.number(),
190
+ })
191
+ .index("by_analysis", ["analysisId"]),
192
+
193
+ /** Step VII: Computed Calculation Summary (cached) */
194
+ paCalculationSummaries: defineTable({
195
+ analysisId: v.id("potentialAnalyses"),
196
+ dimension_totals: v.any(),
197
+ grand_total_sp: v.number(),
198
+ grand_total_nsp: v.number(),
199
+ grand_total: v.number(),
200
+ sensitivity: v.any(),
201
+ margin_effects: v.any(),
202
+ total_margin_effect: v.number(),
203
+ lifetime: v.any(),
204
+ projection: v.any(),
205
+ validated_potentials: v.any(),
206
+ calculatedAt: v.number(),
207
+ })
208
+ .index("by_analysis", ["analysisId"]),
209
+ });
@@ -0,0 +1,485 @@
1
+ import { internalMutation } from "./_generated/server.js";
2
+ import { internal } from "./_generated/api.js";
3
+ import { v } from "convex/values";
4
+ import { DOMAINS, getDomain, getMaturityLevel } from "../data/domains.js";
5
+ import { KM_METHODS } from "../data/km/methods.js";
6
+ import { KM_SYSTEMS } from "../data/km/systems.js";
7
+ import { KM_GUIDELINES } from "../data/km/guidelines.js";
8
+ import { KM_CVR_WEIGHTS, KM_CVR_BUILDUP, KM_IMPLEMENTATION_FACTORS } from "../data/km/cvr.js";
9
+ import { EK_METHODS } from "../data/ek/methods.js";
10
+ import { EK_SYSTEMS } from "../data/ek/systems.js";
11
+ import { EK_GUIDELINES } from "../data/ek/guidelines.js";
12
+ import { EK_CVR_WEIGHTS, EK_CVR_BUILDUP, EK_IMPLEMENTATION_FACTORS } from "../data/ek/cvr.js";
13
+ import { DM_METHODS } from "../data/dm/methods.js";
14
+ import { DM_SYSTEMS } from "../data/dm/systems.js";
15
+ import { DM_GUIDELINES } from "../data/dm/guidelines.js";
16
+ import { DM_CVR_WEIGHTS, DM_CVR_BUILDUP, DM_IMPLEMENTATION_FACTORS } from "../data/dm/cvr.js";
17
+ import {
18
+ VALUE_CATEGORIES,
19
+ DEFAULT_SENSITIVITY,
20
+ DEFAULT_LIFETIME,
21
+ } from "../data/types.js";
22
+ import type {
23
+ ItemDef,
24
+ DomainKey,
25
+ CvrWeightTable,
26
+ CvrBuildupTable,
27
+ ImplementationFactorTable,
28
+ ValueCategory,
29
+ FinancialInputs,
30
+ SensitivityConfig,
31
+ LifetimeConfig,
32
+ Sub5Value,
33
+ CvrWeightRow,
34
+ } from "../data/types.js";
35
+
36
+ // ── Static Data Lookup ──
37
+
38
+ function getItemsForDomain(domain: DomainKey): {
39
+ methods: ItemDef[];
40
+ systems: ItemDef[];
41
+ guidelines: ItemDef[];
42
+ } {
43
+ switch (domain) {
44
+ case "KM":
45
+ return { methods: KM_METHODS, systems: KM_SYSTEMS, guidelines: KM_GUIDELINES };
46
+ case "EK":
47
+ return { methods: EK_METHODS, systems: EK_SYSTEMS, guidelines: EK_GUIDELINES };
48
+ case "DM":
49
+ return { methods: DM_METHODS, systems: DM_SYSTEMS, guidelines: DM_GUIDELINES };
50
+ }
51
+ }
52
+
53
+ function getCvrData(domain: DomainKey): {
54
+ weights: CvrWeightTable;
55
+ buildup: CvrBuildupTable;
56
+ factors: ImplementationFactorTable;
57
+ } {
58
+ switch (domain) {
59
+ case "KM":
60
+ return { weights: KM_CVR_WEIGHTS, buildup: KM_CVR_BUILDUP, factors: KM_IMPLEMENTATION_FACTORS };
61
+ case "EK":
62
+ return { weights: EK_CVR_WEIGHTS, buildup: EK_CVR_BUILDUP, factors: EK_IMPLEMENTATION_FACTORS };
63
+ case "DM":
64
+ return { weights: DM_CVR_WEIGHTS, buildup: DM_CVR_BUILDUP, factors: DM_IMPLEMENTATION_FACTORS };
65
+ }
66
+ }
67
+
68
+ // ── Scoring Helpers ──
69
+
70
+ /**
71
+ * Derive an anchoring level (0-3) from sub5 answer.
72
+ * 0 = not known/used, 1 = basic, 2 = intermediate, 3 = fully anchored
73
+ */
74
+ function sub5ToLevel(val: Sub5Value | null): number {
75
+ if (!val) return 0;
76
+ if (!val.bekannt) return 0;
77
+ if (!val.in_anwendung) return 0;
78
+ let level = 1; // known + in use
79
+ if (val.definiert) level = 2;
80
+ if (val.verankert && val.verankert !== null) {
81
+ // System-anchored is highest
82
+ if (val.verankert === "system_anchored") level = 3;
83
+ else if (val.verankert === "process_anchored") level = Math.max(level, 2);
84
+ }
85
+ if (val.schulungen && level >= 2) level = 3;
86
+ return level;
87
+ }
88
+
89
+ /**
90
+ * Compute item score using the level-based formula.
91
+ * Returns { score: 0-4, maxPotential, methodShare }
92
+ */
93
+ function computeItemScore(
94
+ item: ItemDef,
95
+ level: number,
96
+ complexity: number,
97
+ ): { score: number; maxPotential: number } {
98
+ const [w1, w2, w3] = item.weights;
99
+ const complexityIdx = complexity - 1; // 0-3
100
+ const complexityInfluence = item.complexityRelevance[complexityIdx] ?? 1;
101
+ const maxPotential = (w1 + w2 + w3) * complexityInfluence;
102
+
103
+ if (maxPotential === 0) {
104
+ return { score: 4, maxPotential: 0 }; // not relevant for this complexity
105
+ }
106
+
107
+ let score: number;
108
+ if (level === 0) {
109
+ score = 0;
110
+ } else if (level === 1) {
111
+ score = (w1 * complexityInfluence) / maxPotential * 4;
112
+ } else if (level === 2) {
113
+ score = ((w1 + w2) * complexityInfluence) / maxPotential * 4;
114
+ } else {
115
+ score = 4; // level 3 = full potential
116
+ }
117
+
118
+ return { score, maxPotential };
119
+ }
120
+
121
+ /**
122
+ * Aggregate item scores using SUMPRODUCT: SUM(score * share)
123
+ * where share = maxPotential / SUM(allMaxPotentials)
124
+ */
125
+ function aggregateSumproduct(
126
+ items: Array<{ score: number; maxPotential: number }>,
127
+ ): number {
128
+ const totalPotential = items.reduce((sum, i) => sum + i.maxPotential, 0);
129
+ if (totalPotential === 0) return 0;
130
+ return items.reduce(
131
+ (sum, i) => sum + i.score * (i.maxPotential / totalPotential),
132
+ 0,
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Aggregate item scores using AVERAGE
138
+ */
139
+ function aggregateAverage(scores: number[]): number {
140
+ if (scores.length === 0) return 0;
141
+ return scores.reduce((sum, s) => sum + s, 0) / scores.length;
142
+ }
143
+
144
+ // ── Main Recompute ──
145
+
146
+ /** Full recompute: answers → item scores → dimension scores → overall → CVR → metrics table */
147
+ export const recomputeMetrics = internalMutation({
148
+ args: {
149
+ workspaceId: v.string(),
150
+ domain: v.string(),
151
+ },
152
+ handler: async (ctx, args) => {
153
+ const domain = args.domain as DomainKey;
154
+ const domainDef = getDomain(domain);
155
+ if (!domainDef) return;
156
+
157
+ // Load config for financial inputs and complexity
158
+ const config = await ctx.db
159
+ .query("config")
160
+ .withIndex("by_key", (q) => q.eq("key", "dobt-addon"))
161
+ .first();
162
+
163
+ const financialInputs = (config?.financialInputs as FinancialInputs) ?? {
164
+ revenue: 0,
165
+ cost_volume: 0,
166
+ manufacturing_costs: 0,
167
+ complexity: 3 as const,
168
+ };
169
+ const sensitivity = (config?.sensitivity as SensitivityConfig) ?? DEFAULT_SENSITIVITY;
170
+ const lifetime = (config?.lifetime as LifetimeConfig) ?? DEFAULT_LIFETIME;
171
+ const complexity = financialInputs.complexity || 3;
172
+
173
+ // Load answers for this workspace
174
+ const answers = await ctx.db
175
+ .query("answers")
176
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
177
+ .collect();
178
+ const answerMap = new Map(answers.map((a) => [a.questionKey, a]));
179
+
180
+ // Get items
181
+ const { methods, systems, guidelines } = getItemsForDomain(domain);
182
+
183
+ // Compute dimension scores
184
+ const dimensionScores: Record<string, { status: number; target: number; gap: number }> = {};
185
+
186
+ for (const dim of domainDef.dimensions) {
187
+ let score: number;
188
+
189
+ if (dim.scoringSource === "expert") {
190
+ // Direct expert score
191
+ const qKey = `${domain.toLowerCase()}_expert_${dim.key}`;
192
+ const answer = answerMap.get(qKey);
193
+ score = answer ? Number(answer.value) || 0 : 0;
194
+
195
+ // Handle PM/NPM split for EK expert dimensions
196
+ if (dim.pmNpmSplit) {
197
+ const pmKey = `${domain.toLowerCase()}_expert_${dim.key}_pm`;
198
+ const npmKey = `${domain.toLowerCase()}_expert_${dim.key}_npm`;
199
+ const pmAnswer = answerMap.get(pmKey);
200
+ const npmAnswer = answerMap.get(npmKey);
201
+ if (pmAnswer || npmAnswer) {
202
+ const pmScore = pmAnswer ? Number(pmAnswer.value) || 0 : 0;
203
+ const npmScore = npmAnswer ? Number(npmAnswer.value) || 0 : 0;
204
+ score = (pmScore + npmScore) / 2;
205
+ }
206
+ }
207
+ } else {
208
+ // Methods/systems/guidelines scoring
209
+ let items: ItemDef[];
210
+ if (dim.scoringSource === "methods") {
211
+ items = methods.filter((m) => m.dimension === dim.key);
212
+ } else if (dim.scoringSource === "systems") {
213
+ items = systems.filter((s) => s.dimension === dim.key);
214
+ // Include methods assigned to this dimension (e.g., DM technologies in dm_systeme)
215
+ items.push(...methods.filter((m) => m.dimension === dim.key));
216
+ } else {
217
+ items = guidelines.filter((g) => g.dimension === dim.key);
218
+ }
219
+
220
+ if (dim.pmNpmSplit && domain === "EK") {
221
+ // EK PM/NPM split: compute separately then average
222
+ // "both" items participate in both PM and NPM groups
223
+ const pmItems = items.filter((i) => i.pmNpm === "pm" || i.pmNpm === "both");
224
+ const npmItems = items.filter((i) => i.pmNpm === "npm" || i.pmNpm === "both");
225
+
226
+ const computeGroupScore = (groupItems: ItemDef[]): number => {
227
+ const scored = groupItems.map((item) => {
228
+ const qKey = `${domain.toLowerCase()}_${item.category}_${item.key}`;
229
+ const answer = answerMap.get(qKey);
230
+ const level = answer ? sub5ToLevel(answer.value as Sub5Value) : 0;
231
+ return computeItemScore(item, level, complexity);
232
+ });
233
+ return aggregateSumproduct(scored);
234
+ };
235
+
236
+ const pmScore = pmItems.length > 0 ? computeGroupScore(pmItems) : 0;
237
+ const npmScore = npmItems.length > 0 ? computeGroupScore(npmItems) : 0;
238
+ score = (pmScore + npmScore) / 2;
239
+ } else {
240
+ // Standard scoring
241
+ const scored = items.map((item) => {
242
+ const qKey = `${domain.toLowerCase()}_${item.category}_${item.key}`;
243
+ const answer = answerMap.get(qKey);
244
+ const level = answer ? sub5ToLevel(answer.value as Sub5Value) : 0;
245
+ return computeItemScore(item, level, complexity);
246
+ });
247
+
248
+ if (dim.aggregationMethod === "average") {
249
+ score = aggregateAverage(scored.map((s) => s.score));
250
+ } else {
251
+ score = aggregateSumproduct(scored);
252
+ }
253
+ }
254
+ }
255
+
256
+ dimensionScores[dim.key] = {
257
+ status: Math.round(score * 100) / 100,
258
+ target: 4, // target is always World Class
259
+ gap: Math.round((4 - score) * 100) / 100,
260
+ };
261
+ }
262
+
263
+ // Overall maturity
264
+ const dimSum = Object.values(dimensionScores).reduce((sum, d) => sum + d.status, 0);
265
+ const overallScore = (dimSum / domainDef.maxScore) * 4;
266
+ const overallMaturity = {
267
+ overall_score: Math.round(overallScore * 100) / 100,
268
+ maturity_level: getMaturityLevel(overallScore),
269
+ };
270
+
271
+ // CVR monetization
272
+ const { weights: cvrWeights, buildup: cvrBuildup, factors: implFactors } = getCvrData(domain);
273
+ const monetaryBasis = computeMonetaryBasis(domain, financialInputs);
274
+ const monetaryPotential = computeCvrMonetization(
275
+ domain,
276
+ dimensionScores,
277
+ cvrWeights,
278
+ cvrBuildup,
279
+ implFactors,
280
+ monetaryBasis,
281
+ sensitivity,
282
+ lifetime,
283
+ );
284
+
285
+ // Progress stats
286
+ const domainPrefix = domain.toLowerCase() + "_";
287
+ const domainAnswers = answers.filter((a) => a.questionKey.startsWith(domainPrefix));
288
+ const allQuestions = await ctx.db
289
+ .query("questions")
290
+ .withIndex("by_domain", (q) => q.eq("domain", domain))
291
+ .collect();
292
+ const progress = {
293
+ total: allQuestions.length,
294
+ answered: domainAnswers.length,
295
+ progressPct: allQuestions.length > 0 ? Math.round((domainAnswers.length / allQuestions.length) * 100) : 0,
296
+ };
297
+
298
+ // Store metrics
299
+ const data = {
300
+ dimensionScores,
301
+ overallMaturity,
302
+ monetaryPotential,
303
+ progress,
304
+ };
305
+
306
+ const existing = await ctx.db
307
+ .query("metrics")
308
+ .withIndex("by_workspace_domain", (q) =>
309
+ q.eq("workspaceId", args.workspaceId).eq("domain", domain),
310
+ )
311
+ .first();
312
+
313
+ if (existing) {
314
+ await ctx.db.patch(existing._id, { data, computedAt: Date.now() });
315
+ } else {
316
+ await ctx.db.insert("metrics", {
317
+ workspaceId: args.workspaceId,
318
+ domain,
319
+ data,
320
+ computedAt: Date.now(),
321
+ });
322
+ }
323
+
324
+ // Snapshot policy check
325
+ const snapshotPolicy = (config?.snapshotPolicy as string) ?? "auto";
326
+ let shouldSnapshot = false;
327
+
328
+ if (snapshotPolicy === "auto") {
329
+ shouldSnapshot = true;
330
+ } else if (snapshotPolicy === "threshold" && existing) {
331
+ // Only snapshot when score changes by more than threshold %
332
+ const threshold = (config?.snapshotThreshold as number) ?? 5;
333
+ const oldData = existing.data as Record<string, unknown>;
334
+ const oldMaturity = oldData.overallMaturity as { overall_score: number } | undefined;
335
+ const oldScore = oldMaturity?.overall_score ?? 0;
336
+ const newScore = overallMaturity.overall_score;
337
+ const changePct = oldScore > 0 ? Math.abs(newScore - oldScore) / oldScore * 100 : 100;
338
+ shouldSnapshot = changePct >= threshold;
339
+ }
340
+ // "manual" and "periodic" don't auto-snapshot from recompute
341
+
342
+ if (shouldSnapshot) {
343
+ await ctx.scheduler.runAfter(0, internal.metrics.createSnapshot, {
344
+ workspaceId: args.workspaceId,
345
+ domain,
346
+ snapshotType: snapshotPolicy === "auto" ? "auto" : "threshold",
347
+ });
348
+ }
349
+ },
350
+ });
351
+
352
+ // ── CVR Helpers ──
353
+
354
+ function computeMonetaryBasis(
355
+ domain: DomainKey,
356
+ fi: FinancialInputs,
357
+ ): Record<ValueCategory, number> {
358
+ if (domain === "EK") {
359
+ // EK uses material-based factors
360
+ const directMat = fi.direct_material ?? 0;
361
+ const indirectMat = fi.indirect_material ?? 0;
362
+ return {
363
+ overhead: (fi.headcount_ek ?? 0) * 80000,
364
+ direct_cost: directMat * 0.01,
365
+ additional_business: (directMat + indirectMat) * 0.02,
366
+ innovation: (directMat + indirectMat) * 0.02,
367
+ risk: (directMat + indirectMat) * 0.0025,
368
+ };
369
+ }
370
+ // KM and DM use standard factors
371
+ const headcount = domain === "KM" ? (fi.headcount_km ?? 0) : 0;
372
+ return {
373
+ overhead: headcount * 80000,
374
+ direct_cost: fi.manufacturing_costs * 0.01,
375
+ additional_business: fi.revenue * 0.02,
376
+ innovation: fi.revenue * 0.02,
377
+ risk: fi.cost_volume * 0.0025,
378
+ };
379
+ }
380
+
381
+ function computeCvrMonetization(
382
+ domain: DomainKey,
383
+ dimensionScores: Record<string, { status: number; target: number; gap: number }>,
384
+ cvrWeights: CvrWeightTable,
385
+ cvrBuildup: CvrBuildupTable,
386
+ implFactors: ImplementationFactorTable,
387
+ monetaryBasis: Record<ValueCategory, number>,
388
+ sensitivity: SensitivityConfig,
389
+ lifetime: LifetimeConfig,
390
+ ): Record<string, unknown> {
391
+ const result: Record<string, unknown> = {};
392
+ const totals = {
393
+ total_utilized_monetary: 0,
394
+ total_unused_monetary: 0,
395
+ total_secured_margin: 0,
396
+ total_margin_effect: 0,
397
+ lifetime: { year_1: 0, year_2: 0, year_3: 0, total_3yr: 0 },
398
+ };
399
+
400
+ // For EK, compute CVR separately with PM and NPM factors, then average.
401
+ // For KM/DM, use the single domain factor key.
402
+ const factorKeys = domain === "EK" ? ["EK_PM", "EK_NPM"] : [domain];
403
+
404
+ for (const vc of VALUE_CATEGORIES) {
405
+ // Sum CVR across all dimensions for this value category
406
+ let totalCvrMax = 0;
407
+ let utilized = 0;
408
+ let improvementDelta = 0;
409
+
410
+ for (const [dimKey, dimData] of Object.entries(dimensionScores)) {
411
+ const cvrMax = cvrWeights[dimKey]?.[vc] ?? 0;
412
+ totalCvrMax += cvrMax;
413
+
414
+ // Utilization: score * cvr_max
415
+ utilized += dimData.status * cvrMax;
416
+
417
+ // Improvement: (target - status) * cvr_max
418
+ improvementDelta += dimData.gap * cvrMax;
419
+ }
420
+
421
+ const maxUtilized = totalCvrMax * 4;
422
+ const utilizationRatio = maxUtilized > 0 ? utilized / maxUtilized : 0;
423
+ const basis = monetaryBasis[vc];
424
+ const utilizedMonetary = utilizationRatio * basis;
425
+ const unusedMonetary = (1 - utilizationRatio) * basis;
426
+
427
+ const improvementRate = maxUtilized > 0 ? improvementDelta / maxUtilized : 0;
428
+ const monetaryImprovement = improvementRate * basis;
429
+
430
+ // Average implementation factor across PM/NPM (or single factor for KM/DM)
431
+ const implFactor = factorKeys.reduce(
432
+ (sum, fk) => sum + (implFactors[fk]?.[vc] ?? 0),
433
+ 0,
434
+ ) / factorKeys.length;
435
+ const securedMargin = implFactor * monetaryImprovement;
436
+
437
+ // Sensitivity analytics
438
+ const marginConversion = getMarginConversionFactor(vc, sensitivity);
439
+ const marginEffect = securedMargin * marginConversion;
440
+
441
+ // Lifetime projection
442
+ const ltYear1 = marginEffect * lifetime.year_1;
443
+ const ltYear2 = marginEffect * lifetime.year_2;
444
+ const ltYear3 = marginEffect * lifetime.year_3;
445
+
446
+ result[vc] = {
447
+ monetary_basis: Math.round(basis),
448
+ utilization_ratio: Math.round(utilizationRatio * 10000) / 10000,
449
+ utilized_monetary: Math.round(utilizedMonetary),
450
+ unused_monetary: Math.round(unusedMonetary),
451
+ improvement_rate: Math.round(improvementRate * 10000) / 10000,
452
+ monetary_improvement: Math.round(monetaryImprovement),
453
+ secured_margin: Math.round(securedMargin),
454
+ margin_effect: Math.round(marginEffect),
455
+ lifetime: {
456
+ year_1: Math.round(ltYear1),
457
+ year_2: Math.round(ltYear2),
458
+ year_3: Math.round(ltYear3),
459
+ total_3yr: Math.round(ltYear1 + ltYear2 + ltYear3),
460
+ },
461
+ };
462
+
463
+ totals.total_utilized_monetary += Math.round(utilizedMonetary);
464
+ totals.total_unused_monetary += Math.round(unusedMonetary);
465
+ totals.total_secured_margin += Math.round(securedMargin);
466
+ totals.total_margin_effect += Math.round(marginEffect);
467
+ totals.lifetime.year_1 += Math.round(ltYear1);
468
+ totals.lifetime.year_2 += Math.round(ltYear2);
469
+ totals.lifetime.year_3 += Math.round(ltYear3);
470
+ totals.lifetime.total_3yr += Math.round(ltYear1 + ltYear2 + ltYear3);
471
+ }
472
+
473
+ result.totals = totals;
474
+ return result;
475
+ }
476
+
477
+ function getMarginConversionFactor(vc: ValueCategory, s: SensitivityConfig): number {
478
+ switch (vc) {
479
+ case "overhead": return s.efficiency_to_margin;
480
+ case "direct_cost": return s.direct_cost_to_margin;
481
+ case "additional_business": return s.revenue_to_margin;
482
+ case "innovation": return s.innovation_to_margin;
483
+ case "risk": return s.risk_to_margin;
484
+ }
485
+ }