@trafficgroup/knex-rel 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dao/report-configuration/report-configuration.dao.d.ts +8 -0
- package/dist/dao/report-configuration/report-configuration.dao.js +19 -0
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
- package/package.json +1 -1
- package/plan.md +611 -571
- package/src/dao/report-configuration/report-configuration.dao.ts +25 -0
package/plan.md
CHANGED
|
@@ -1,806 +1,837 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Total Vehicle Class Implementation Plan
|
|
2
2
|
|
|
3
3
|
## Executive Summary
|
|
4
4
|
|
|
5
|
-
This plan details the implementation of
|
|
5
|
+
This plan details the implementation of a "Total" vehicle class that aggregates all custom vehicle classes for both TMC and ATR minute results. The "Total" class will be added during transformation in the knex-rel module and will always appear as the LAST class in the vehicles object.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**Key Requirements**:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- Add "Total" class AFTER all custom classes
|
|
10
|
+
- For ATR: "Total" reuses existing `lane_counts` (all classes per lane)
|
|
11
|
+
- For TMC: "Total" sums all vehicle classes across all directions and turns
|
|
12
|
+
- NO database schema changes required
|
|
13
|
+
- Transformation happens in `ReportConfigurationDAO.applyConfigurationToNestedStructure()`
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
---
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
## Current Architecture Analysis
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
id: SERIAL PRIMARY KEY
|
|
17
|
-
uuid: UUID NOT NULL UNIQUE (indexed)
|
|
18
|
-
name: VARCHAR(100) NOT NULL
|
|
19
|
-
longitude: DECIMAL(10,7) NOT NULL
|
|
20
|
-
latitude: DECIMAL(10,7) NOT NULL
|
|
21
|
-
created_at: TIMESTAMP
|
|
22
|
-
updated_at: TIMESTAMP
|
|
23
|
-
INDEX: (uuid)
|
|
24
|
-
INDEX: (longitude, latitude)
|
|
25
|
-
```
|
|
19
|
+
### Data Flow
|
|
26
20
|
|
|
27
|
-
**video**
|
|
28
|
-
|
|
29
|
-
```sql
|
|
30
|
-
id: SERIAL PRIMARY KEY
|
|
31
|
-
uuid: UUID NOT NULL UNIQUE
|
|
32
|
-
folderId: INTEGER NOT NULL REFERENCES folders(id)
|
|
33
|
-
cameraId: INTEGER NULL REFERENCES cameras(id) ON DELETE SET NULL
|
|
34
|
-
name: VARCHAR
|
|
35
|
-
videoLocation: VARCHAR
|
|
36
|
-
status: VARCHAR (QUEUED|PROCESSING|COMPLETED|FAILED|PENDING)
|
|
37
|
-
... (additional fields)
|
|
38
|
-
INDEX: (cameraId)
|
|
39
21
|
```
|
|
22
|
+
1. Python Processor → Database (JSONB results with detection labels)
|
|
23
|
+
↓
|
|
24
|
+
2. VideoMinuteResultDAO → Retrieve raw minute results
|
|
25
|
+
↓
|
|
26
|
+
3. API Controller → Apply report configuration
|
|
27
|
+
↓
|
|
28
|
+
4. ReportConfigurationService.transformVideoResults()
|
|
29
|
+
↓
|
|
30
|
+
5. ReportConfigurationDAO.applyConfigurationToNestedStructure()
|
|
31
|
+
↓
|
|
32
|
+
6. Frontend → Display custom classes + Total
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Current Structure Patterns
|
|
40
36
|
|
|
41
|
-
**
|
|
37
|
+
**ATR Format (Lane-based)**:
|
|
42
38
|
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"vehicles": {
|
|
42
|
+
"car": { "0": 45, "1": 50 },
|
|
43
|
+
"truck": { "0": 10, "1": 8 }
|
|
44
|
+
},
|
|
45
|
+
"lane_counts": { "0": 55, "1": 58 }, // Already has totals per lane!
|
|
46
|
+
"total_count": 113,
|
|
47
|
+
"study_type": "ATR"
|
|
48
|
+
}
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
**TMC Format (Direction/Turn-based)**:
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"vehicles": {
|
|
56
|
+
"car": {
|
|
57
|
+
"NORTH": { "straight": 10, "left": 5, "right": 3, "u-turn": 0 },
|
|
58
|
+
"SOUTH": { "straight": 8, "left": 2, "right": 4, "u-turn": 1 }
|
|
59
|
+
},
|
|
60
|
+
"truck": {
|
|
61
|
+
"NORTH": { "straight": 5, "left": 1, "right": 0, "u-turn": 0 }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"counts": {
|
|
65
|
+
"total_vehicles": 39,
|
|
66
|
+
"entry_vehicles": 39
|
|
67
|
+
},
|
|
68
|
+
"study_type": "TMC"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
**After Custom Class Transformation** (2 custom classes: "Light" and "Heavy"):
|
|
56
73
|
|
|
57
|
-
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"vehicles": {
|
|
77
|
+
"Light": { "NORTH": { "straight": 10, "left": 5 }, ... },
|
|
78
|
+
"Heavy": { "NORTH": { "straight": 5, "left": 1 }, ... }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
58
82
|
|
|
59
|
-
**
|
|
83
|
+
**After Total Addition** (desired output):
|
|
60
84
|
|
|
61
|
-
```
|
|
62
|
-
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"vehicles": {
|
|
88
|
+
"Light": { "NORTH": { "straight": 10, "left": 5 }, ... },
|
|
89
|
+
"Heavy": { "NORTH": { "straight": 5, "left": 1 }, ... },
|
|
90
|
+
"Total": { "NORTH": { "straight": 15, "left": 6 }, ... } // Sum of all classes
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
```
|
|
64
94
|
|
|
65
|
-
|
|
95
|
+
---
|
|
66
96
|
|
|
67
|
-
|
|
68
|
-
- **Return Value**: IDataPaginator<ICamera> - used to map to CameraDTO[]
|
|
69
|
-
- **Purpose**: Filter cameras by name with pagination support
|
|
97
|
+
## Implementation Location
|
|
70
98
|
|
|
71
|
-
|
|
99
|
+
### File: `knex-rel/src/dao/report-configuration/report-configuration.dao.ts`
|
|
72
100
|
|
|
73
|
-
|
|
74
|
-
API Request → CameraService.getAllWithSearch(page, limit, name)
|
|
75
|
-
→ CameraDAO.getAllWithSearch(page, limit, name)
|
|
76
|
-
→ Returns IDataPaginator<ICamera>
|
|
77
|
-
→ Service maps to CameraDTO[] (exposes UUID, hides ID)
|
|
78
|
-
→ API Response
|
|
79
|
-
```
|
|
101
|
+
**Method to Modify**: `applyConfigurationToNestedStructure()` (Lines 273-315)
|
|
80
102
|
|
|
81
|
-
|
|
103
|
+
**Current Implementation**:
|
|
82
104
|
|
|
83
|
-
|
|
105
|
+
1. Builds reverse mapping: detection label → custom class name (Lines 278-290)
|
|
106
|
+
2. Initializes empty structure for each custom class (Lines 293-296)
|
|
107
|
+
3. Iterates through detection labels and merges into custom classes (Lines 299-312)
|
|
108
|
+
4. Returns transformed structure (Line 314)
|
|
84
109
|
|
|
85
|
-
|
|
86
|
-
-- Count query
|
|
87
|
-
SELECT COUNT(*) as count FROM cameras WHERE name ILIKE '%search%'
|
|
110
|
+
**Where to Add "Total"**: AFTER line 312, BEFORE return statement (Line 314)
|
|
88
111
|
|
|
89
|
-
|
|
90
|
-
SELECT * FROM cameras
|
|
91
|
-
WHERE name ILIKE '%search%'
|
|
92
|
-
LIMIT ? OFFSET ?
|
|
93
|
-
```
|
|
112
|
+
---
|
|
94
113
|
|
|
95
|
-
|
|
114
|
+
## Detailed Implementation Plan
|
|
96
115
|
|
|
97
|
-
|
|
98
|
-
- Search is optional (if name undefined/null, return all)
|
|
99
|
-
- Use '%search%' pattern for partial matching
|
|
100
|
-
- Follow existing getAll() pattern from camera.dao.ts:34-50
|
|
116
|
+
### Step 1: Add "Total" Class After Custom Class Transformation
|
|
101
117
|
|
|
102
|
-
**
|
|
118
|
+
**Location**: `ReportConfigurationDAO.applyConfigurationToNestedStructure()` (After line 312)
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
- ILIKE with leading wildcard prevents index usage
|
|
106
|
-
- For now, acceptable for camera table (likely small dataset)
|
|
120
|
+
**Logic**:
|
|
107
121
|
|
|
108
|
-
|
|
122
|
+
```typescript
|
|
123
|
+
// After line 312 (end of detection label iteration)
|
|
124
|
+
// Add "Total" class that aggregates all custom classes
|
|
125
|
+
|
|
126
|
+
result["Total"] = this._createTotalClass(result);
|
|
109
127
|
|
|
110
|
-
|
|
128
|
+
return result;
|
|
129
|
+
```
|
|
111
130
|
|
|
112
|
-
###
|
|
131
|
+
### Step 2: Implement `_createTotalClass()` Helper Method
|
|
113
132
|
|
|
114
|
-
**Location**: `
|
|
133
|
+
**Location**: Add new private method in `ReportConfigurationDAO` class (After line 350)
|
|
115
134
|
|
|
116
135
|
**Method Signature**:
|
|
117
136
|
|
|
118
137
|
```typescript
|
|
119
|
-
|
|
138
|
+
private _createTotalClass(customClassesData: Record<string, any>): any
|
|
120
139
|
```
|
|
121
140
|
|
|
122
|
-
**
|
|
141
|
+
**Implementation**:
|
|
123
142
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
```typescript
|
|
144
|
+
/**
|
|
145
|
+
* Create "Total" class by aggregating all custom vehicle classes
|
|
146
|
+
*
|
|
147
|
+
* Handles both ATR and TMC formats:
|
|
148
|
+
* - ATR: Aggregates counts per lane across all vehicle classes
|
|
149
|
+
* - TMC: Aggregates counts per direction and turn across all vehicle classes
|
|
150
|
+
*
|
|
151
|
+
* @param customClassesData - Transformed custom classes structure
|
|
152
|
+
* @returns Aggregated totals matching the same nested structure
|
|
153
|
+
*/
|
|
154
|
+
private _createTotalClass(customClassesData: Record<string, any>): any {
|
|
155
|
+
const total: any = {};
|
|
156
|
+
|
|
157
|
+
// Iterate through all custom classes and merge their data
|
|
158
|
+
for (const [className, nestedData] of Object.entries(customClassesData)) {
|
|
159
|
+
// Use existing _deepMergeNumericData to sum all nested values
|
|
160
|
+
Object.assign(total, this._deepMergeNumericData(total, nestedData));
|
|
161
|
+
}
|
|
129
162
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
→ Get camera by UUID to obtain ID (line 302)
|
|
133
|
-
→ CameraDAO.getVideosByCamera(camera.id, page, limit)
|
|
134
|
-
→ Returns IDataPaginator<IVideo> with folder data
|
|
135
|
-
→ Service maps to VideoWithFolderDTO[] (line 311-312)
|
|
136
|
-
→ API Response
|
|
163
|
+
return total;
|
|
164
|
+
}
|
|
137
165
|
```
|
|
138
166
|
|
|
139
|
-
**
|
|
167
|
+
**Explanation**:
|
|
140
168
|
|
|
141
|
-
|
|
169
|
+
- Reuses existing `_deepMergeNumericData()` helper (Lines 328-350)
|
|
170
|
+
- Works for both ATR (2-level: vehicle → lane → count) and TMC (3-level: vehicle → direction → turn → count)
|
|
171
|
+
- No need to detect study type - structure-agnostic aggregation
|
|
172
|
+
- Handles empty data (returns empty object `{}`)
|
|
142
173
|
|
|
143
|
-
|
|
174
|
+
---
|
|
144
175
|
|
|
145
|
-
|
|
146
|
-
-- Count query
|
|
147
|
-
SELECT COUNT(*) as count
|
|
148
|
-
FROM video v
|
|
149
|
-
WHERE v.cameraId = ?
|
|
176
|
+
## Key Ordering Strategy
|
|
150
177
|
|
|
151
|
-
|
|
152
|
-
SELECT v.*, to_jsonb(f.*) as folder
|
|
153
|
-
FROM video v
|
|
154
|
-
INNER JOIN folders f ON v.folderId = f.id
|
|
155
|
-
WHERE v.cameraId = ?
|
|
156
|
-
LIMIT ? OFFSET ?
|
|
157
|
-
```
|
|
178
|
+
### Ensuring "Total" Appears Last
|
|
158
179
|
|
|
159
|
-
|
|
180
|
+
JavaScript object key ordering is preserved in modern engines (ES2015+) when:
|
|
160
181
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- Follow VideoDAO.getAll() pattern for folder JOIN (lines 50-59)
|
|
164
|
-
- Return IDataPaginator<IVideo> with folder field populated
|
|
182
|
+
1. String keys are added in insertion order
|
|
183
|
+
2. Keys are enumerated via `Object.entries()`, `Object.keys()`, `for...in`
|
|
165
184
|
|
|
166
|
-
**
|
|
185
|
+
**Our Implementation**:
|
|
167
186
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
```typescript
|
|
188
|
+
// Lines 293-296: Initialize custom classes FIRST (insertion order)
|
|
189
|
+
for (const customClass of config.configuration.customClasses) {
|
|
190
|
+
result[customClass.name] = {};
|
|
191
|
+
}
|
|
171
192
|
|
|
172
|
-
|
|
193
|
+
// Lines 299-312: Populate custom classes (preserves order)
|
|
194
|
+
for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
|
|
195
|
+
// ... merge logic
|
|
196
|
+
}
|
|
173
197
|
|
|
174
|
-
|
|
198
|
+
// NEW: Add "Total" LAST (after all custom classes)
|
|
199
|
+
result["Total"] = this._createTotalClass(result);
|
|
175
200
|
|
|
176
|
-
|
|
201
|
+
// Line 314: Return result (Total is last key)
|
|
202
|
+
return result;
|
|
203
|
+
```
|
|
177
204
|
|
|
178
|
-
**
|
|
179
|
-
**Location 2**: `api-rel/src/service/video/video.service.ts:34`
|
|
180
|
-
**Location 3**: `api-rel/src/service/folder/folder.service.ts:96`
|
|
205
|
+
**Why This Works**:
|
|
181
206
|
|
|
182
|
-
|
|
207
|
+
- Custom classes initialized in config order (Lines 293-296)
|
|
208
|
+
- "Total" added AFTER all custom classes (new code)
|
|
209
|
+
- JavaScript guarantees insertion order preservation
|
|
210
|
+
- Frontend will render "Total" as last tab
|
|
183
211
|
|
|
184
|
-
|
|
185
|
-
async bulkUpdateCamera(videoIds: number[], cameraId: number | null, trx?: Knex.Transaction): Promise<number>
|
|
186
|
-
```
|
|
212
|
+
---
|
|
187
213
|
|
|
188
|
-
|
|
214
|
+
## ATR vs TMC Behavior
|
|
189
215
|
|
|
190
|
-
|
|
216
|
+
### ATR (Lane-Based Totals)
|
|
191
217
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
**Input Structure**:
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"Light": { "0": 45, "1": 50 },
|
|
223
|
+
"Heavy": { "0": 10, "1": 8 }
|
|
224
|
+
}
|
|
225
|
+
```
|
|
196
226
|
|
|
197
|
-
**
|
|
227
|
+
**Total Output**:
|
|
198
228
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"Total": { "0": 55, "1": 58 } // Sum per lane
|
|
232
|
+
}
|
|
233
|
+
```
|
|
204
234
|
|
|
205
|
-
**
|
|
235
|
+
**Calculation**:
|
|
206
236
|
|
|
207
|
-
-
|
|
208
|
-
-
|
|
209
|
-
- Transaction: OPTIONAL - may be passed from folder update (line 86)
|
|
210
|
-
- Return: updatedCount for cascade logging (line 59)
|
|
237
|
+
- Lane "0": 45 (Light) + 10 (Heavy) = 55
|
|
238
|
+
- Lane "1": 50 (Light) + 8 (Heavy) = 58
|
|
211
239
|
|
|
212
|
-
|
|
240
|
+
**Alternative (Use existing `lane_counts`)**:
|
|
213
241
|
|
|
214
|
-
|
|
242
|
+
The ATR structure ALREADY has `lane_counts` at the top level:
|
|
215
243
|
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"vehicles": { "Light": {...}, "Heavy": {...} },
|
|
247
|
+
"lane_counts": { "0": 55, "1": 58 } // Already calculated!
|
|
248
|
+
}
|
|
221
249
|
```
|
|
222
250
|
|
|
223
|
-
**
|
|
251
|
+
**Decision**: For ATR, we can EITHER:
|
|
224
252
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
- Update cameraId (can be null for unassignment)
|
|
228
|
-
- Update updated_at timestamp
|
|
229
|
-
- Return count of updated rows (number)
|
|
230
|
-
- Use whereIn() for array parameter
|
|
253
|
+
1. ✅ **Option A (Recommended)**: Calculate "Total" by summing custom classes (consistent with TMC)
|
|
254
|
+
2. ⚠️ **Option B**: Copy `lane_counts` directly into `vehicles["Total"]` (reuses existing data)
|
|
231
255
|
|
|
232
|
-
**
|
|
256
|
+
**Recommendation**: Use Option A (sum custom classes) for consistency with TMC, even though Option B is technically available.
|
|
233
257
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
258
|
+
### TMC (Direction/Turn-Based Totals)
|
|
259
|
+
|
|
260
|
+
**Input Structure**:
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{
|
|
264
|
+
"Light": {
|
|
265
|
+
"NORTH": { "straight": 10, "left": 5, "right": 3 },
|
|
266
|
+
"SOUTH": { "straight": 8, "left": 2, "right": 4 }
|
|
267
|
+
},
|
|
268
|
+
"Heavy": {
|
|
269
|
+
"NORTH": { "straight": 5, "left": 1, "right": 0 },
|
|
270
|
+
"SOUTH": { "straight": 2, "left": 0, "right": 1 }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
241
273
|
```
|
|
242
274
|
|
|
243
|
-
**
|
|
275
|
+
**Total Output**:
|
|
244
276
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"Total": {
|
|
280
|
+
"NORTH": { "straight": 15, "left": 6, "right": 3 },
|
|
281
|
+
"SOUTH": { "straight": 10, "left": 2, "right": 5 }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
249
285
|
|
|
250
|
-
**
|
|
286
|
+
**Calculation**:
|
|
251
287
|
|
|
252
|
-
-
|
|
253
|
-
-
|
|
254
|
-
-
|
|
288
|
+
- NORTH/straight: 10 (Light) + 5 (Heavy) = 15
|
|
289
|
+
- NORTH/left: 5 (Light) + 1 (Heavy) = 6
|
|
290
|
+
- SOUTH/straight: 8 (Light) + 2 (Heavy) = 10
|
|
291
|
+
- etc.
|
|
255
292
|
|
|
256
293
|
---
|
|
257
294
|
|
|
258
|
-
##
|
|
295
|
+
## Edge Cases & Validation
|
|
259
296
|
|
|
260
|
-
###
|
|
297
|
+
### Edge Case 1: Empty Data (No Vehicles)
|
|
261
298
|
|
|
262
|
-
**
|
|
263
|
-
**Location 2**: `api-rel/src/service/folder/folder.service.ts:89`
|
|
264
|
-
**Location 3**: `api-rel/src/service/folder/folder.service.ts:163`
|
|
265
|
-
**Location 4**: `api-rel/src/service/video/video.service.ts:119`
|
|
299
|
+
**Input**:
|
|
266
300
|
|
|
267
|
-
|
|
301
|
+
```json
|
|
302
|
+
{
|
|
303
|
+
"vehicles": {}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
268
306
|
|
|
269
|
-
|
|
270
|
-
|
|
307
|
+
**After Transformation**:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"vehicles": {
|
|
312
|
+
"Light": {},
|
|
313
|
+
"Heavy": {},
|
|
314
|
+
"Total": {} // Empty object
|
|
315
|
+
}
|
|
316
|
+
}
|
|
271
317
|
```
|
|
272
318
|
|
|
273
|
-
**
|
|
319
|
+
**Behavior**: `_createTotalClass()` returns empty object `{}`
|
|
274
320
|
|
|
275
|
-
|
|
321
|
+
### Edge Case 2: Single Custom Class
|
|
276
322
|
|
|
277
|
-
|
|
278
|
-
- Parameters: existingFolder.id
|
|
279
|
-
- Return: videoIds array used for cascading (line 52-57)
|
|
280
|
-
- Transaction Context: Within folder update transaction
|
|
323
|
+
**Input**:
|
|
281
324
|
|
|
282
|
-
|
|
325
|
+
```json
|
|
326
|
+
{
|
|
327
|
+
"vehicles": {
|
|
328
|
+
"AllVehicles": { "0": 100, "1": 95 }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
283
332
|
|
|
284
|
-
|
|
285
|
-
- Parameters: folderId
|
|
286
|
-
- Return: videoIds passed to bulkUpdateCamera (line 96)
|
|
287
|
-
- Early return if empty array (line 91-93)
|
|
333
|
+
**Output**:
|
|
288
334
|
|
|
289
|
-
|
|
335
|
+
```json
|
|
336
|
+
{
|
|
337
|
+
"vehicles": {
|
|
338
|
+
"AllVehicles": { "0": 100, "1": 95 },
|
|
339
|
+
"Total": { "0": 100, "1": 95 } // Same as single class
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
290
343
|
|
|
291
|
-
|
|
292
|
-
- Parameters: folder.id
|
|
293
|
-
- Return: videoIds.length for count (line 164)
|
|
294
|
-
- Read-only operation (no updates)
|
|
344
|
+
**Behavior**: "Total" equals the single custom class (valid)
|
|
295
345
|
|
|
296
|
-
|
|
346
|
+
### Edge Case 3: Missing Directions/Turns
|
|
297
347
|
|
|
298
|
-
|
|
299
|
-
- Parameters: folderId
|
|
300
|
-
- Return: number[] passed back to caller
|
|
301
|
-
- Used for service-level cascade logic
|
|
348
|
+
**Input** (TMC - Light has NORTH, Heavy only has SOUTH):
|
|
302
349
|
|
|
303
|
-
|
|
350
|
+
```json
|
|
351
|
+
{
|
|
352
|
+
"Light": { "NORTH": { "straight": 10 } },
|
|
353
|
+
"Heavy": { "SOUTH": { "straight": 5 } }
|
|
354
|
+
}
|
|
355
|
+
```
|
|
304
356
|
|
|
305
|
-
**
|
|
357
|
+
**Output**:
|
|
306
358
|
|
|
307
|
-
```
|
|
308
|
-
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"Total": {
|
|
362
|
+
"NORTH": { "straight": 10 },
|
|
363
|
+
"SOUTH": { "straight": 5 }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
309
366
|
```
|
|
310
367
|
|
|
311
|
-
**
|
|
368
|
+
**Behavior**: `_deepMergeNumericData()` handles missing keys gracefully (Lines 339-347)
|
|
312
369
|
|
|
313
|
-
|
|
314
|
-
- Filter by folderId
|
|
315
|
-
- Return array of numeric IDs: number[]
|
|
316
|
-
- No pagination needed (cascade operations need ALL videos)
|
|
317
|
-
- No JOIN needed (only IDs required)
|
|
370
|
+
### Edge Case 4: Null/Undefined Values
|
|
318
371
|
|
|
319
|
-
**
|
|
372
|
+
**Input**:
|
|
320
373
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
374
|
+
```json
|
|
375
|
+
{
|
|
376
|
+
"Light": { "0": 10, "1": null },
|
|
377
|
+
"Heavy": { "0": 5 }
|
|
378
|
+
}
|
|
379
|
+
```
|
|
325
380
|
|
|
326
|
-
**
|
|
381
|
+
**Output**:
|
|
327
382
|
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
.orderBy("id", "asc");
|
|
333
|
-
return ids.map((row) => row.id);
|
|
383
|
+
```json
|
|
384
|
+
{
|
|
385
|
+
"Total": { "0": 15, "1": 0 } // null treated as 0
|
|
386
|
+
}
|
|
334
387
|
```
|
|
335
388
|
|
|
389
|
+
**Behavior**: `_deepMergeNumericData()` checks `typeof source === 'number'` (Line 330)
|
|
390
|
+
|
|
336
391
|
---
|
|
337
392
|
|
|
338
|
-
##
|
|
393
|
+
## Testing Strategy
|
|
339
394
|
|
|
340
|
-
###
|
|
395
|
+
### Unit Tests (to be created)
|
|
341
396
|
|
|
342
|
-
**
|
|
397
|
+
**File**: `knex-rel/src/dao/report-configuration/report-configuration.dao.test.ts`
|
|
343
398
|
|
|
344
|
-
**
|
|
399
|
+
**Test Cases**:
|
|
345
400
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
401
|
+
1. **ATR: Total Class Aggregation**
|
|
402
|
+
- Input: 2 custom classes with lane counts
|
|
403
|
+
- Verify: Total sums all lanes correctly
|
|
404
|
+
- Verify: Total appears as last key
|
|
349
405
|
|
|
350
|
-
**
|
|
406
|
+
2. **TMC: Total Class Aggregation**
|
|
407
|
+
- Input: 2 custom classes with direction/turn structure
|
|
408
|
+
- Verify: Total sums all directions and turns
|
|
409
|
+
- Verify: Total appears as last key
|
|
351
410
|
|
|
352
|
-
|
|
353
|
-
-
|
|
354
|
-
-
|
|
411
|
+
3. **Edge Case: Empty Vehicles**
|
|
412
|
+
- Input: No vehicle data
|
|
413
|
+
- Verify: Total is empty object `{}`
|
|
355
414
|
|
|
356
|
-
**
|
|
415
|
+
4. **Edge Case: Single Custom Class**
|
|
416
|
+
- Input: 1 custom class
|
|
417
|
+
- Verify: Total equals the single class
|
|
357
418
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
→ Returns IDataPaginator<IVideo>
|
|
362
|
-
→ Service returns directly (line 77)
|
|
363
|
-
```
|
|
419
|
+
5. **Edge Case: Missing Directions**
|
|
420
|
+
- Input: Asymmetric direction/turn data
|
|
421
|
+
- Verify: Total includes all unique keys
|
|
364
422
|
|
|
365
|
-
**
|
|
423
|
+
6. **Key Ordering Test**
|
|
424
|
+
- Input: 5 custom classes
|
|
425
|
+
- Verify: Total is the 6th key (last)
|
|
426
|
+
- Verify: `Object.keys(result)` ends with "Total"
|
|
366
427
|
|
|
367
|
-
###
|
|
428
|
+
### Integration Tests
|
|
368
429
|
|
|
369
|
-
**
|
|
430
|
+
**Test in api-rel controller**:
|
|
370
431
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
432
|
+
1. Create video with ATR results
|
|
433
|
+
2. Apply configuration with 3 custom classes
|
|
434
|
+
3. Call `GET /videos/:uuid/results?configUuid=...`
|
|
435
|
+
4. Verify response includes "Total" as last class
|
|
436
|
+
5. Verify Total values equal sum of custom classes
|
|
376
437
|
|
|
377
|
-
|
|
378
|
-
SELECT v.*, to_jsonb(f.*) as folder
|
|
379
|
-
FROM video v
|
|
380
|
-
INNER JOIN folders f ON v.folderId = f.id
|
|
381
|
-
WHERE v.cameraId = ?
|
|
382
|
-
ORDER BY v.created_at DESC
|
|
383
|
-
LIMIT ? OFFSET ?
|
|
384
|
-
```
|
|
438
|
+
**Test in traffic-webapp**:
|
|
385
439
|
|
|
386
|
-
|
|
440
|
+
1. Display video results with custom classes
|
|
441
|
+
2. Verify "Total" tab appears last
|
|
442
|
+
3. Verify Total chart shows aggregated data
|
|
443
|
+
4. Test with both ATR and TMC videos
|
|
387
444
|
|
|
388
|
-
|
|
389
|
-
- Filter by cameraId
|
|
390
|
-
- MUST include folder data via JOIN
|
|
391
|
-
- Follow VideoDAO.getAll() JOIN pattern (lines 50-59)
|
|
392
|
-
- Return IDataPaginator<IVideo>
|
|
445
|
+
---
|
|
393
446
|
|
|
394
|
-
|
|
447
|
+
## Performance Considerations
|
|
395
448
|
|
|
396
|
-
|
|
397
|
-
- cameraId indexed
|
|
398
|
-
- Consider composite index on (cameraId, created_at) for sorting
|
|
449
|
+
### Computational Complexity
|
|
399
450
|
|
|
400
|
-
**
|
|
451
|
+
**Current**: O(n × m) where:
|
|
401
452
|
|
|
402
|
-
-
|
|
403
|
-
-
|
|
404
|
-
- Consider refactoring to shared private method in future
|
|
453
|
+
- n = number of detection labels
|
|
454
|
+
- m = average nesting depth (2 for ATR, 3 for TMC)
|
|
405
455
|
|
|
406
|
-
|
|
456
|
+
**After Adding Total**: O(n × m + c × m) where:
|
|
407
457
|
|
|
408
|
-
|
|
458
|
+
- c = number of custom classes (2-7 per validation)
|
|
409
459
|
|
|
410
|
-
|
|
460
|
+
**Impact**: Minimal - additional O(c × m) is negligible
|
|
411
461
|
|
|
412
|
-
|
|
413
|
-
|
|
462
|
+
- c ≤ 7 (max custom classes)
|
|
463
|
+
- m ≤ 3 (max nesting depth)
|
|
464
|
+
- Total iteration: ~21 operations per minute result
|
|
414
465
|
|
|
415
|
-
###
|
|
466
|
+
### Memory Impact
|
|
416
467
|
|
|
417
|
-
|
|
418
|
-
4. **VideoDAO.getVideosByCameraIdWithFolder()** - Same as #3, different DAO
|
|
468
|
+
**Additional Memory**: One new key per transformed result
|
|
419
469
|
|
|
420
|
-
|
|
470
|
+
- ATR Total: ~100 bytes (lanes object)
|
|
471
|
+
- TMC Total: ~500 bytes (directions/turns object)
|
|
472
|
+
- Per minute result: negligible
|
|
473
|
+
- For 60 minutes: ~6-30 KB additional
|
|
421
474
|
|
|
422
|
-
|
|
475
|
+
**Conclusion**: No performance concerns
|
|
423
476
|
|
|
424
477
|
---
|
|
425
478
|
|
|
426
|
-
##
|
|
427
|
-
|
|
428
|
-
### Existing Indexes (Already Present)
|
|
479
|
+
## Backward Compatibility
|
|
429
480
|
|
|
430
|
-
|
|
431
|
-
- cameras.(longitude, latitude) (migration 20250911000000_migration.ts:14)
|
|
432
|
-
- video.cameraId (migration 20250911000000_migration.ts:20)
|
|
433
|
-
- folders.cameraId (migration 20250911000000_migration.ts:26)
|
|
481
|
+
### Changes Are Additive Only
|
|
434
482
|
|
|
435
|
-
|
|
483
|
+
✅ **No Breaking Changes**:
|
|
436
484
|
|
|
437
|
-
|
|
485
|
+
- Existing custom classes remain unchanged
|
|
486
|
+
- Existing API response structure preserved
|
|
487
|
+
- "Total" is a NEW key (optional to consume)
|
|
438
488
|
|
|
439
|
-
|
|
440
|
-
CREATE INDEX idx_cameras_name ON cameras(name);
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
- Benefits: Speeds up getAllWithSearch() ILIKE queries
|
|
444
|
-
- Tradeoff: ILIKE with leading wildcard still can't use index fully
|
|
445
|
-
- Decision: SKIP for now (camera table likely small)
|
|
489
|
+
✅ **Frontend Compatibility**:
|
|
446
490
|
|
|
447
|
-
|
|
491
|
+
- Old frontend: Ignores "Total" key (no errors)
|
|
492
|
+
- New frontend: Displays "Total" as last tab
|
|
448
493
|
|
|
449
|
-
|
|
450
|
-
CREATE INDEX idx_video_folder_id ON video(folderId);
|
|
451
|
-
```
|
|
494
|
+
✅ **Database Compatibility**:
|
|
452
495
|
|
|
453
|
-
-
|
|
454
|
-
-
|
|
455
|
-
-
|
|
496
|
+
- No schema changes required
|
|
497
|
+
- Raw results in database unchanged
|
|
498
|
+
- Transformation happens at runtime
|
|
456
499
|
|
|
457
|
-
|
|
500
|
+
---
|
|
458
501
|
|
|
459
|
-
|
|
460
|
-
CREATE INDEX idx_video_camera_created ON video(cameraId, created_at DESC);
|
|
461
|
-
```
|
|
502
|
+
## Implementation Checklist
|
|
462
503
|
|
|
463
|
-
-
|
|
464
|
-
- Decision: SKIP for now (single column index sufficient)
|
|
504
|
+
### Pre-Implementation
|
|
465
505
|
|
|
466
|
-
|
|
506
|
+
- [x] Analyze current transformation logic
|
|
507
|
+
- [x] Identify exact insertion point (line 312)
|
|
508
|
+
- [x] Verify `_deepMergeNumericData()` handles both ATR/TMC
|
|
509
|
+
- [x] Confirm JavaScript key ordering guarantees
|
|
467
510
|
|
|
468
|
-
|
|
511
|
+
### Implementation Phase
|
|
469
512
|
|
|
470
|
-
|
|
513
|
+
- [ ] Add `_createTotalClass()` helper method after line 350
|
|
514
|
+
- [ ] Add "Total" class insertion after line 312 in `applyConfigurationToNestedStructure()`
|
|
515
|
+
- [ ] Add JSDoc comments for new method
|
|
516
|
+
- [ ] Verify TypeScript compilation: `npm run build`
|
|
471
517
|
|
|
472
|
-
|
|
518
|
+
### Testing Phase
|
|
473
519
|
|
|
474
|
-
-
|
|
475
|
-
-
|
|
520
|
+
- [ ] Write unit tests for `_createTotalClass()`
|
|
521
|
+
- [ ] Test ATR format (lane-based totals)
|
|
522
|
+
- [ ] Test TMC format (direction/turn totals)
|
|
523
|
+
- [ ] Test edge cases (empty, single class, missing keys)
|
|
524
|
+
- [ ] Test key ordering (Total is last)
|
|
525
|
+
- [ ] Run all tests: `npm test`
|
|
476
526
|
|
|
477
|
-
|
|
527
|
+
### Integration Phase
|
|
478
528
|
|
|
479
|
-
-
|
|
480
|
-
-
|
|
481
|
-
-
|
|
482
|
-
-
|
|
529
|
+
- [ ] Build knex-rel: `npm run build`
|
|
530
|
+
- [ ] Test in api-rel: `npm run start:dev`
|
|
531
|
+
- [ ] Call `/videos/:uuid/results` endpoint with ATR video
|
|
532
|
+
- [ ] Call `/videos/:uuid/results` endpoint with TMC video
|
|
533
|
+
- [ ] Verify "Total" appears in response as last class
|
|
534
|
+
- [ ] Verify Total values are correct sums
|
|
483
535
|
|
|
484
|
-
|
|
536
|
+
### Frontend Integration (Separate Task)
|
|
485
537
|
|
|
486
|
-
-
|
|
487
|
-
-
|
|
488
|
-
-
|
|
489
|
-
-
|
|
490
|
-
- count: number
|
|
491
|
-
- totalCount: number
|
|
492
|
-
- totalPages: number
|
|
538
|
+
- [ ] Update traffic-webapp to display "Total" tab
|
|
539
|
+
- [ ] Position "Total" tab as last tab
|
|
540
|
+
- [ ] Render Total data in charts/tables
|
|
541
|
+
- [ ] Test with both ATR and TMC videos
|
|
493
542
|
|
|
494
543
|
---
|
|
495
544
|
|
|
496
|
-
##
|
|
545
|
+
## Code Changes Summary
|
|
546
|
+
|
|
547
|
+
### File 1: `knex-rel/src/dao/report-configuration/report-configuration.dao.ts`
|
|
497
548
|
|
|
498
|
-
|
|
549
|
+
**Location 1: After Line 312 (Inside `applyConfigurationToNestedStructure()`)**
|
|
499
550
|
|
|
500
551
|
```typescript
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
552
|
+
// Lines 299-312: Existing iteration through detection labels
|
|
553
|
+
for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
|
|
554
|
+
const customClassName = detectionToCustomClass[detectionLabel];
|
|
555
|
+
if (!customClassName) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
result[customClassName] = this._deepMergeNumericData(
|
|
559
|
+
result[customClassName],
|
|
560
|
+
nestedData,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
506
563
|
|
|
507
|
-
|
|
564
|
+
// NEW CODE: Add "Total" class that aggregates all custom classes
|
|
565
|
+
result["Total"] = this._createTotalClass(result);
|
|
508
566
|
|
|
509
|
-
|
|
510
|
-
// No try/catch in DAO
|
|
511
|
-
// Let transaction rollback handle errors at service level
|
|
512
|
-
const query = trx || this._knex;
|
|
513
|
-
const result = await query...
|
|
514
|
-
return result.length;
|
|
567
|
+
return result;
|
|
515
568
|
```
|
|
516
569
|
|
|
517
|
-
|
|
570
|
+
**Location 2: After Line 350 (New Private Method)**
|
|
518
571
|
|
|
519
572
|
```typescript
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
573
|
+
/**
|
|
574
|
+
* Create "Total" class by aggregating all custom vehicle classes
|
|
575
|
+
*
|
|
576
|
+
* Sums all custom class counts across their nested structures.
|
|
577
|
+
* Works for both ATR (lane-based) and TMC (direction/turn-based) formats.
|
|
578
|
+
*
|
|
579
|
+
* @param customClassesData - Transformed custom classes structure
|
|
580
|
+
* Example ATR: { "Light": { "0": 45, "1": 50 }, "Heavy": { "0": 10 } }
|
|
581
|
+
* Example TMC: { "Light": { "NORTH": { "straight": 10 } }, ... }
|
|
582
|
+
* @returns Aggregated totals matching the same nested structure
|
|
583
|
+
* Example ATR: { "0": 55, "1": 50 }
|
|
584
|
+
* Example TMC: { "NORTH": { "straight": 10 }, ... }
|
|
585
|
+
*/
|
|
586
|
+
private _createTotalClass(customClassesData: Record<string, any>): any {
|
|
587
|
+
let total: any = {};
|
|
588
|
+
|
|
589
|
+
// Iterate through all custom classes and merge their data
|
|
590
|
+
for (const [className, nestedData] of Object.entries(customClassesData)) {
|
|
591
|
+
total = this._deepMergeNumericData(total, nestedData);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return total;
|
|
595
|
+
}
|
|
524
596
|
```
|
|
525
597
|
|
|
526
598
|
---
|
|
527
599
|
|
|
528
|
-
##
|
|
600
|
+
## Validation Plan
|
|
529
601
|
|
|
530
|
-
###
|
|
602
|
+
### Manual Testing Checklist
|
|
531
603
|
|
|
532
|
-
|
|
533
|
-
async getAllWithSearch(page: number, limit: number, name?: string): Promise<IDataPaginator<ICamera>> {
|
|
534
|
-
const offset = (page - 1) * limit;
|
|
604
|
+
**Test 1: ATR Video (2 Custom Classes)**
|
|
535
605
|
|
|
536
|
-
|
|
606
|
+
- [ ] Create configuration: "Light" (FHWA 1-3), "Heavy" (FHWA 4-13)
|
|
607
|
+
- [ ] Upload ATR video with mixed vehicle types
|
|
608
|
+
- [ ] Process video and get results
|
|
609
|
+
- [ ] Verify "Light" shows cars/motorcycles per lane
|
|
610
|
+
- [ ] Verify "Heavy" shows trucks/buses per lane
|
|
611
|
+
- [ ] Verify "Total" shows sum of Light + Heavy per lane
|
|
612
|
+
- [ ] Verify "Total" is last key in vehicles object
|
|
537
613
|
|
|
538
|
-
|
|
539
|
-
if (name && name.trim().length > 0) {
|
|
540
|
-
query = query.where('name', 'ilike', `%${name.trim()}%`);
|
|
541
|
-
}
|
|
614
|
+
**Test 2: TMC Video (3 Custom Classes)**
|
|
542
615
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
page,
|
|
551
|
-
limit,
|
|
552
|
-
count: cameras.length,
|
|
553
|
-
totalCount,
|
|
554
|
-
totalPages: Math.ceil(totalCount / limit),
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
```
|
|
616
|
+
- [ ] Create configuration: "Cars", "Trucks", "Buses"
|
|
617
|
+
- [ ] Upload TMC video with turning movements
|
|
618
|
+
- [ ] Process video and get results
|
|
619
|
+
- [ ] Verify each class shows direction/turn breakdown
|
|
620
|
+
- [ ] Verify "Total" shows sum across all classes
|
|
621
|
+
- [ ] Verify NORTH/straight in Total = sum of all classes' NORTH/straight
|
|
622
|
+
- [ ] Verify "Total" is last key in vehicles object
|
|
558
623
|
|
|
559
|
-
|
|
624
|
+
**Test 3: Edge Cases**
|
|
560
625
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
626
|
+
- [ ] Video with no vehicles detected (empty Total)
|
|
627
|
+
- [ ] Video with single custom class (Total equals that class)
|
|
628
|
+
- [ ] Video with asymmetric turns (Light has left, Heavy doesn't)
|
|
629
|
+
- [ ] Configuration with 7 custom classes (max allowed)
|
|
566
630
|
|
|
567
|
-
|
|
631
|
+
**Test 4: Grouped Results (Multiple Minutes)**
|
|
568
632
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
updated_at: query.fn.now()
|
|
574
|
-
})
|
|
575
|
-
.returning('id');
|
|
633
|
+
- [ ] Get grouped results with grouping=15
|
|
634
|
+
- [ ] Verify each timeGroup has "Total" class
|
|
635
|
+
- [ ] Verify "Total" sums correctly across aggregated minutes
|
|
636
|
+
- [ ] Verify ordering preserved in all timeGroups
|
|
576
637
|
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
```
|
|
638
|
+
---
|
|
580
639
|
|
|
581
|
-
|
|
640
|
+
## Risks & Mitigations
|
|
582
641
|
|
|
583
|
-
|
|
584
|
-
async getVideosByCamera(cameraId: number, page: number, limit: number): Promise<IDataPaginator<IVideo>> {
|
|
585
|
-
const offset = (page - 1) * limit;
|
|
586
|
-
|
|
587
|
-
const query = this._knex("video as v")
|
|
588
|
-
.innerJoin("folders as f", "v.folderId", "f.id")
|
|
589
|
-
.select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
|
|
590
|
-
.where("v.cameraId", cameraId);
|
|
591
|
-
|
|
592
|
-
const [countResult] = await query.clone().clearSelect().count("* as count");
|
|
593
|
-
const totalCount = +countResult.count;
|
|
594
|
-
const videos = await query.clone().limit(limit).offset(offset).orderBy('v.created_at', 'desc');
|
|
595
|
-
|
|
596
|
-
return {
|
|
597
|
-
success: true,
|
|
598
|
-
data: videos,
|
|
599
|
-
page,
|
|
600
|
-
limit,
|
|
601
|
-
count: videos.length,
|
|
602
|
-
totalCount,
|
|
603
|
-
totalPages: Math.ceil(totalCount / limit),
|
|
604
|
-
};
|
|
605
|
-
}
|
|
606
|
-
```
|
|
642
|
+
### Risk 1: Key Ordering Not Guaranteed
|
|
607
643
|
|
|
608
|
-
|
|
644
|
+
**Risk**: JavaScript object keys might not preserve insertion order
|
|
609
645
|
|
|
610
|
-
|
|
611
|
-
async getVideoIdsByFolderId(folderId: number): Promise<number[]> {
|
|
612
|
-
const rows = await this._knex('video')
|
|
613
|
-
.where({ folderId })
|
|
614
|
-
.select('id')
|
|
615
|
-
.orderBy('id', 'asc');
|
|
646
|
+
**Likelihood**: Very Low (ES2015+ guarantees string key order)
|
|
616
647
|
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
```
|
|
648
|
+
**Mitigation**:
|
|
620
649
|
|
|
621
|
-
|
|
650
|
+
- Add unit test to verify `Object.keys(result)` ends with "Total"
|
|
651
|
+
- If ordering fails, use explicit ordering array in frontend
|
|
622
652
|
|
|
623
|
-
|
|
624
|
-
async getVideosByCameraIdWithFolder(cameraId: number, page: number, limit: number): Promise<IDataPaginator<IVideo>> {
|
|
625
|
-
const offset = (page - 1) * limit;
|
|
626
|
-
|
|
627
|
-
const query = this._knex("video as v")
|
|
628
|
-
.innerJoin("folders as f", "v.folderId", "f.id")
|
|
629
|
-
.select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
|
|
630
|
-
.where("v.cameraId", cameraId);
|
|
631
|
-
|
|
632
|
-
const [countResult] = await query.clone().clearSelect().count("* as count");
|
|
633
|
-
const totalCount = +countResult.count;
|
|
634
|
-
const videos = await query.clone().limit(limit).offset(offset).orderBy('v.created_at', 'desc');
|
|
635
|
-
|
|
636
|
-
return {
|
|
637
|
-
success: true,
|
|
638
|
-
data: videos,
|
|
639
|
-
page,
|
|
640
|
-
limit,
|
|
641
|
-
count: videos.length,
|
|
642
|
-
totalCount,
|
|
643
|
-
totalPages: Math.ceil(totalCount / limit),
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
```
|
|
653
|
+
### Risk 2: Performance Degradation
|
|
647
654
|
|
|
648
|
-
|
|
655
|
+
**Risk**: Additional aggregation adds processing time
|
|
656
|
+
|
|
657
|
+
**Likelihood**: Very Low (O(c × m) is negligible)
|
|
658
|
+
|
|
659
|
+
**Mitigation**:
|
|
660
|
+
|
|
661
|
+
- Benchmark transformation time before/after
|
|
662
|
+
- If impact > 10ms, consider caching Total class
|
|
663
|
+
|
|
664
|
+
### Risk 3: Frontend Breaks on Unknown Class
|
|
649
665
|
|
|
650
|
-
|
|
666
|
+
**Risk**: Old frontend version throws error on "Total" key
|
|
651
667
|
|
|
652
|
-
**
|
|
668
|
+
**Likelihood**: Very Low (key is additive, not breaking)
|
|
653
669
|
|
|
654
|
-
|
|
655
|
-
- video.cameraId column: migration 20250911000000_migration.ts:19
|
|
656
|
-
- folders.cameraId column: migration 20250911000000_migration.ts:25
|
|
657
|
-
- All indexes already created
|
|
670
|
+
**Mitigation**:
|
|
658
671
|
|
|
659
|
-
|
|
672
|
+
- "Total" is just another custom class name
|
|
673
|
+
- Frontend already iterates dynamic class names
|
|
674
|
+
- No special handling needed
|
|
660
675
|
|
|
661
|
-
|
|
676
|
+
### Risk 4: Incorrect Aggregation Logic
|
|
677
|
+
|
|
678
|
+
**Risk**: `_deepMergeNumericData()` doesn't handle all cases
|
|
679
|
+
|
|
680
|
+
**Likelihood**: Low (method already tested for custom class merging)
|
|
681
|
+
|
|
682
|
+
**Mitigation**:
|
|
683
|
+
|
|
684
|
+
- Comprehensive unit tests for edge cases
|
|
685
|
+
- Manual validation with real ATR/TMC videos
|
|
686
|
+
- Use existing, battle-tested `_deepMergeNumericData()`
|
|
662
687
|
|
|
663
688
|
---
|
|
664
689
|
|
|
665
|
-
##
|
|
690
|
+
## Alternative Approaches Considered
|
|
666
691
|
|
|
667
|
-
###
|
|
692
|
+
### Alternative 1: Add "Total" in Frontend
|
|
668
693
|
|
|
669
|
-
**
|
|
694
|
+
**Approach**: Calculate "Total" in traffic-webapp instead of backend
|
|
670
695
|
|
|
671
|
-
|
|
672
|
-
- Test: Search with no matches (empty result)
|
|
673
|
-
- Test: Search with null/undefined name (return all)
|
|
674
|
-
- Test: Pagination (page 1, page 2, etc.)
|
|
675
|
-
- Test: Case insensitivity (ILIKE behavior)
|
|
696
|
+
**Pros**:
|
|
676
697
|
|
|
677
|
-
|
|
698
|
+
- No backend changes required
|
|
699
|
+
- Frontend controls display logic
|
|
678
700
|
|
|
679
|
-
|
|
680
|
-
- Test: Camera with no videos (empty result)
|
|
681
|
-
- Test: Pagination with multiple pages
|
|
682
|
-
- Test: Verify folder data structure in results
|
|
701
|
+
**Cons**:
|
|
683
702
|
|
|
684
|
-
|
|
703
|
+
- Duplicates logic across frontend
|
|
704
|
+
- Inconsistent if API consumed elsewhere
|
|
705
|
+
- Cannot use "Total" in backend reports/exports
|
|
685
706
|
|
|
686
|
-
-
|
|
687
|
-
- Test: Update with null cameraId (unassignment)
|
|
688
|
-
- Test: Update with transaction (commit)
|
|
689
|
-
- Test: Update with transaction (rollback)
|
|
690
|
-
- Test: Empty videoIds array (return 0)
|
|
691
|
-
- Test: Invalid videoIds (non-existent IDs)
|
|
707
|
+
**Decision**: ❌ Rejected - Backend is single source of truth
|
|
692
708
|
|
|
693
|
-
|
|
709
|
+
### Alternative 2: Add "Total" as Separate Field
|
|
694
710
|
|
|
695
|
-
|
|
696
|
-
- Test: Folder with no videos (empty array)
|
|
697
|
-
- Test: Non-existent folderId (empty array)
|
|
698
|
-
- Test: Large folder (100+ videos)
|
|
711
|
+
**Approach**: Add `results.totalClass` instead of `results.vehicles["Total"]`
|
|
699
712
|
|
|
700
|
-
**
|
|
713
|
+
**Pros**:
|
|
701
714
|
|
|
702
|
-
-
|
|
703
|
-
-
|
|
704
|
-
- Test: Pagination
|
|
705
|
-
- Test: Verify folder data in results
|
|
706
|
-
- Test: Null cameraId handling
|
|
715
|
+
- Clearer separation of concerns
|
|
716
|
+
- No risk of key ordering issues
|
|
707
717
|
|
|
708
|
-
|
|
718
|
+
**Cons**:
|
|
709
719
|
|
|
710
|
-
|
|
720
|
+
- Inconsistent with custom class structure
|
|
721
|
+
- Frontend needs special handling
|
|
722
|
+
- Breaks uniform class iteration logic
|
|
711
723
|
|
|
712
|
-
|
|
724
|
+
**Decision**: ❌ Rejected - Keep "Total" as vehicle class for uniformity
|
|
713
725
|
|
|
714
|
-
|
|
715
|
-
- [ ] Review existing DAO patterns in camera.dao.ts and video.dao.ts
|
|
716
|
-
- [ ] Verify IDataPaginator structure in d.types.ts
|
|
726
|
+
### Alternative 3: Make "Total" Configurable
|
|
717
727
|
|
|
718
|
-
|
|
728
|
+
**Approach**: Add `includeTotalClass: boolean` to report configuration
|
|
719
729
|
|
|
720
|
-
|
|
721
|
-
- [ ] Implement VideoDAO.getVideoIdsByFolderId()
|
|
722
|
-
- [ ] Implement CameraDAO.getVideosByCamera()
|
|
723
|
-
- [ ] Implement VideoDAO.getVideosByCameraIdWithFolder()
|
|
724
|
-
- [ ] Implement VideoDAO.bulkUpdateCamera()
|
|
730
|
+
**Pros**:
|
|
725
731
|
|
|
726
|
-
|
|
732
|
+
- User control over Total display
|
|
733
|
+
- Backward compatible (default false)
|
|
727
734
|
|
|
728
|
-
|
|
729
|
-
- [ ] Test transaction handling in bulkUpdateCamera
|
|
730
|
-
- [ ] Test pagination edge cases (page 1, last page, beyond last page)
|
|
731
|
-
- [ ] Test search with special characters (SQL injection prevention)
|
|
735
|
+
**Cons**:
|
|
732
736
|
|
|
733
|
-
|
|
737
|
+
- Adds complexity to configuration
|
|
738
|
+
- Not needed (Total is always useful)
|
|
739
|
+
- Extra validation logic
|
|
734
740
|
|
|
735
|
-
|
|
736
|
-
- [ ] Verify no TypeScript compilation errors
|
|
737
|
-
- [ ] Test in api-rel: `npm run start:dev`
|
|
738
|
-
- [ ] Verify all service methods work end-to-end
|
|
739
|
-
- [ ] Test camera bulk assignment workflow
|
|
740
|
-
- [ ] Test folder camera cascade workflow
|
|
741
|
+
**Decision**: ❌ Rejected - Always include "Total" (KISS principle)
|
|
741
742
|
|
|
742
743
|
---
|
|
743
744
|
|
|
744
|
-
##
|
|
745
|
+
## Success Criteria
|
|
746
|
+
|
|
747
|
+
### Implementation Success
|
|
748
|
+
|
|
749
|
+
✅ TypeScript compiles without errors
|
|
750
|
+
✅ `npm run build` succeeds in knex-rel
|
|
751
|
+
✅ All existing unit tests pass
|
|
752
|
+
✅ New unit tests pass (Total class logic)
|
|
745
753
|
|
|
746
|
-
###
|
|
754
|
+
### Functional Success
|
|
755
|
+
|
|
756
|
+
✅ ATR videos show "Total" with correct lane sums
|
|
757
|
+
✅ TMC videos show "Total" with correct direction/turn sums
|
|
758
|
+
✅ "Total" appears as LAST class in all responses
|
|
759
|
+
✅ Edge cases handled (empty, single class, missing keys)
|
|
760
|
+
|
|
761
|
+
### Integration Success
|
|
762
|
+
|
|
763
|
+
✅ API endpoint returns "Total" in response
|
|
764
|
+
✅ Grouped results include "Total" in each timeGroup
|
|
765
|
+
✅ No breaking changes to existing API consumers
|
|
766
|
+
✅ Frontend displays "Total" tab correctly (separate task)
|
|
767
|
+
|
|
768
|
+
---
|
|
747
769
|
|
|
748
|
-
|
|
749
|
-
- getVideoIdsByFolderId() - Straightforward SELECT query
|
|
770
|
+
## Timeline Estimate
|
|
750
771
|
|
|
751
|
-
###
|
|
772
|
+
### Backend Implementation (knex-rel)
|
|
752
773
|
|
|
753
|
-
-
|
|
754
|
-
-
|
|
774
|
+
- Code changes: 30 minutes
|
|
775
|
+
- Unit tests: 1 hour
|
|
776
|
+
- Build & test: 15 minutes
|
|
777
|
+
- **Total: ~2 hours**
|
|
755
778
|
|
|
756
|
-
###
|
|
779
|
+
### Integration Testing (api-rel)
|
|
757
780
|
|
|
758
|
-
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
781
|
+
- Manual testing with ATR video: 30 minutes
|
|
782
|
+
- Manual testing with TMC video: 30 minutes
|
|
783
|
+
- Edge case validation: 30 minutes
|
|
784
|
+
- **Total: ~1.5 hours**
|
|
762
785
|
|
|
763
|
-
###
|
|
786
|
+
### Frontend Integration (traffic-webapp - Separate)
|
|
764
787
|
|
|
765
|
-
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
-
|
|
769
|
-
|
|
770
|
-
|
|
788
|
+
- Display "Total" tab: 1 hour
|
|
789
|
+
- Chart/table rendering: 1 hour
|
|
790
|
+
- Testing both video types: 1 hour
|
|
791
|
+
- **Total: ~3 hours**
|
|
792
|
+
|
|
793
|
+
**Overall Estimate: ~6.5 hours** (Backend: 3.5 hours, Frontend: 3 hours)
|
|
771
794
|
|
|
772
795
|
---
|
|
773
796
|
|
|
774
797
|
## Summary
|
|
775
798
|
|
|
776
|
-
|
|
799
|
+
### What Changes
|
|
800
|
+
|
|
801
|
+
✅ **ADD**: `_createTotalClass()` private method in ReportConfigurationDAO
|
|
802
|
+
✅ **ADD**: `result["Total"] = ...` line after custom class transformation
|
|
803
|
+
✅ **ADD**: Unit tests for Total class aggregation
|
|
804
|
+
|
|
805
|
+
### What Stays the Same
|
|
806
|
+
|
|
807
|
+
✅ Database schema (no migrations)
|
|
808
|
+
✅ Existing custom classes (no modifications)
|
|
809
|
+
✅ API response structure (additive only)
|
|
810
|
+
✅ `_deepMergeNumericData()` helper (reused, not changed)
|
|
811
|
+
|
|
812
|
+
### Key Benefits
|
|
777
813
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
- **No migrations needed** - schema already complete
|
|
784
|
-
- **Implementation order prioritized** - simple to complex
|
|
785
|
-
- **Testing strategy defined** - 25+ test cases identified
|
|
786
|
-
- **Risk assessment completed** - mitigations specified
|
|
814
|
+
✅ Unified "Total" view across all custom classes
|
|
815
|
+
✅ Consistent with existing transformation architecture
|
|
816
|
+
✅ No breaking changes (backward compatible)
|
|
817
|
+
✅ Minimal performance impact (< 1ms per minute result)
|
|
818
|
+
✅ Reuses existing, tested aggregation logic
|
|
787
819
|
|
|
788
|
-
**Ready for
|
|
820
|
+
**Ready for Implementation** - Plan is complete with exact code locations, logic, edge cases, and validation strategy.
|
|
789
821
|
|
|
790
822
|
---
|
|
791
823
|
|
|
792
824
|
## Files to Modify
|
|
793
825
|
|
|
794
|
-
### knex-rel/src/dao/
|
|
826
|
+
### knex-rel/src/dao/report-configuration/report-configuration.dao.ts
|
|
795
827
|
|
|
796
|
-
- Add
|
|
797
|
-
- Add
|
|
828
|
+
- **Line 312**: Add `result["Total"] = this._createTotalClass(result);`
|
|
829
|
+
- **After Line 350**: Add `_createTotalClass()` private method
|
|
798
830
|
|
|
799
|
-
### knex-rel/src/dao/
|
|
831
|
+
### knex-rel/src/dao/report-configuration/report-configuration.dao.test.ts (New File)
|
|
800
832
|
|
|
801
|
-
-
|
|
802
|
-
-
|
|
803
|
-
- Add method: `getVideosByCameraIdWithFolder(cameraId, page, limit)`
|
|
833
|
+
- Create unit tests for Total class aggregation
|
|
834
|
+
- Test ATR format, TMC format, edge cases, key ordering
|
|
804
835
|
|
|
805
836
|
---
|
|
806
837
|
|
|
@@ -808,24 +839,33 @@ All 5 missing methods have been thoroughly analyzed:
|
|
|
808
839
|
|
|
809
840
|
### Database Patterns ✅
|
|
810
841
|
|
|
811
|
-
-
|
|
812
|
-
-
|
|
813
|
-
-
|
|
814
|
-
- Foreign keys use ON DELETE SET NULL for cameras
|
|
815
|
-
- Indexes on UUID and foreign keys
|
|
842
|
+
- No database changes required (transformation only)
|
|
843
|
+
- Follows DAO pattern (logic in ReportConfigurationDAO)
|
|
844
|
+
- No new migrations needed
|
|
816
845
|
|
|
817
|
-
###
|
|
846
|
+
### Code Patterns ✅
|
|
818
847
|
|
|
819
|
-
-
|
|
820
|
-
-
|
|
821
|
-
-
|
|
822
|
-
-
|
|
823
|
-
|
|
848
|
+
- Reuses existing `_deepMergeNumericData()` helper
|
|
849
|
+
- Follows private method naming convention (`_methodName`)
|
|
850
|
+
- Maintains nested structure consistency
|
|
851
|
+
- Preserves key ordering via insertion order
|
|
852
|
+
|
|
853
|
+
### Performance Patterns ✅
|
|
854
|
+
|
|
855
|
+
- O(c × m) additional complexity (negligible)
|
|
856
|
+
- No N+1 queries (transformation in memory)
|
|
857
|
+
- No database roundtrips
|
|
858
|
+
- Minimal memory overhead (~500 bytes per result)
|
|
859
|
+
|
|
860
|
+
---
|
|
824
861
|
|
|
825
|
-
|
|
862
|
+
## Next Steps
|
|
826
863
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
-
|
|
831
|
-
|
|
864
|
+
1. **Implement** `_createTotalClass()` method in ReportConfigurationDAO
|
|
865
|
+
2. **Add** Total class insertion after line 312
|
|
866
|
+
3. **Write** unit tests for all edge cases
|
|
867
|
+
4. **Build** knex-rel package and verify compilation
|
|
868
|
+
5. **Test** with real ATR and TMC videos in api-rel
|
|
869
|
+
6. **Validate** key ordering and correct aggregation
|
|
870
|
+
7. **Document** changes (if needed)
|
|
871
|
+
8. **Coordinate** with frontend team for Total tab display
|