@trafficgroup/knex-rel 0.1.9 → 0.1.10
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/.claude/settings.local.json +5 -2
- package/.env.prod +5 -0
- package/CLAUDE.md +2 -11
- package/dist/constants/video.constants.d.ts +12 -0
- package/dist/constants/video.constants.js +18 -0
- package/dist/constants/video.constants.js.map +1 -0
- package/dist/dao/VideoMinuteResultDAO.d.ts +1 -1
- package/dist/dao/VideoMinuteResultDAO.js +23 -29
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/auth/auth.dao.js +1 -4
- package/dist/dao/auth/auth.dao.js.map +1 -1
- package/dist/dao/batch/batch.dao.js +14 -13
- package/dist/dao/batch/batch.dao.js.map +1 -1
- package/dist/dao/camera/camera.dao.js +7 -10
- package/dist/dao/camera/camera.dao.js.map +1 -1
- package/dist/dao/chat/chat.dao.d.ts +1 -1
- package/dist/dao/chat/chat.dao.js +35 -25
- package/dist/dao/chat/chat.dao.js.map +1 -1
- package/dist/dao/folder/folder.dao.js +2 -7
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/location/location.dao.js +9 -16
- package/dist/dao/location/location.dao.js.map +1 -1
- package/dist/dao/message/message.dao.d.ts +1 -1
- package/dist/dao/message/message.dao.js +26 -18
- package/dist/dao/message/message.dao.js.map +1 -1
- package/dist/dao/report-configuration/report-configuration.dao.js +31 -32
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
- package/dist/dao/study/study.dao.js +2 -7
- package/dist/dao/study/study.dao.js.map +1 -1
- package/dist/dao/user/user.dao.js +1 -4
- package/dist/dao/user/user.dao.js.map +1 -1
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +8 -26
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
- package/dist/dao/video/video.dao.d.ts +2 -1
- package/dist/dao/video/video.dao.js +42 -28
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/batch/batch.interfaces.d.ts +1 -1
- package/dist/interfaces/camera/camera.interfaces.d.ts +1 -1
- package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
- package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
- package/dist/interfaces/message/message.interfaces.d.ts +2 -2
- package/dist/interfaces/study/study.interfaces.d.ts +2 -2
- package/dist/interfaces/user/user.interfaces.d.ts +1 -1
- package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
- package/dist/interfaces/video/video.interfaces.d.ts +2 -2
- package/migrations/20250717160737_migration.ts +1 -1
- package/migrations/20250717160908_migration.ts +2 -5
- package/migrations/20250717161310_migration.ts +1 -1
- package/migrations/20250717161406_migration.ts +3 -3
- package/migrations/20250717162431_migration.ts +1 -1
- package/migrations/20250717173228_migration.ts +2 -2
- package/migrations/20250717204731_migration.ts +1 -1
- package/migrations/20250722210109_migration.ts +4 -8
- package/migrations/20250722211019_migration.ts +1 -1
- package/migrations/20250723153852_migration.ts +10 -13
- package/migrations/20250723162257_migration.ts +7 -4
- package/migrations/20250723171109_migration.ts +7 -4
- package/migrations/20250723205331_migration.ts +9 -6
- package/migrations/20250724191345_migration.ts +11 -8
- package/migrations/20250730180932_migration.ts +13 -14
- package/migrations/20250730213625_migration.ts +11 -8
- package/migrations/20250804124509_migration.ts +21 -26
- package/migrations/20250804132053_migration.ts +8 -5
- package/migrations/20250804164518_migration.ts +7 -7
- package/migrations/20250823223016_migration.ts +21 -32
- package/migrations/20250910015452_migration.ts +6 -18
- package/migrations/20250911000000_migration.ts +4 -18
- package/migrations/20250917144153_migration.ts +7 -14
- package/migrations/20250930200521_migration.ts +4 -8
- package/migrations/20251010143500_migration.ts +6 -27
- package/migrations/20251020225758_migration.ts +15 -51
- package/migrations/20251112120000_migration.ts +2 -10
- package/migrations/20251112120200_migration.ts +7 -19
- package/migrations/20251112120300_migration.ts +2 -7
- package/package.json +1 -1
- package/src/constants/video.constants.ts +19 -0
- package/src/d.types.ts +14 -18
- package/src/dao/VideoMinuteResultDAO.ts +49 -72
- package/src/dao/auth/auth.dao.ts +55 -58
- package/src/dao/batch/batch.dao.ts +98 -101
- package/src/dao/camera/camera.dao.ts +121 -124
- package/src/dao/chat/chat.dao.ts +43 -45
- package/src/dao/folder/folder.dao.ts +56 -65
- package/src/dao/location/location.dao.ts +87 -109
- package/src/dao/message/message.dao.ts +32 -32
- package/src/dao/report-configuration/report-configuration.dao.ts +342 -370
- package/src/dao/study/study.dao.ts +63 -88
- package/src/dao/user/user.dao.ts +50 -52
- package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +49 -83
- package/src/dao/video/video.dao.ts +339 -357
- package/src/entities/BaseEntity.ts +1 -1
- package/src/index.ts +22 -26
- package/src/interfaces/auth/auth.interfaces.ts +10 -10
- package/src/interfaces/batch/batch.interfaces.ts +1 -1
- package/src/interfaces/camera/camera.interfaces.ts +9 -9
- package/src/interfaces/chat/chat.interfaces.ts +4 -4
- package/src/interfaces/folder/folder.interfaces.ts +2 -2
- package/src/interfaces/location/location.interfaces.ts +7 -7
- package/src/interfaces/message/message.interfaces.ts +3 -3
- package/src/interfaces/report-configuration/report-configuration.interfaces.ts +16 -16
- package/src/interfaces/study/study.interfaces.ts +3 -3
- package/src/interfaces/user/user.interfaces.ts +9 -9
- package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
- package/src/interfaces/video/video.interfaces.ts +34 -34
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Knex } from "knex";
|
|
2
2
|
import { IBaseDAO, IDataPaginator } from "../../d.types";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
IReportConfiguration,
|
|
5
|
+
IReportConfigurationData,
|
|
6
|
+
IReportConfigurationInput,
|
|
7
|
+
IValidationResult
|
|
8
8
|
} from "../../interfaces/report-configuration/report-configuration.interfaces";
|
|
9
9
|
import KnexManager from "../../KnexConnection";
|
|
10
10
|
|
|
@@ -23,405 +23,377 @@ import KnexManager from "../../KnexConnection";
|
|
|
23
23
|
* Non-motorized vehicles (pedestrian, bicycle, non_motorized_vehicle) are EXCLUDED
|
|
24
24
|
*/
|
|
25
25
|
const DETECTION_LABEL_TO_FHWA: Record<string, number[]> = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
'motorcycle': [1],
|
|
27
|
+
'car': [2],
|
|
28
|
+
'pickup_truck': [3],
|
|
29
|
+
'motorized_vehicle': [3], // Maps to Class 3 (same as pickup_truck)
|
|
30
|
+
'bus': [4],
|
|
31
|
+
'work_van': [5],
|
|
32
|
+
'single_unit_truck': [6, 7, 8], // Classes 6-8
|
|
33
|
+
'articulated_truck': [9, 10, 11, 12, 13] // Classes 9-13
|
|
34
|
+
// pedestrian, bicycle, non_motorized_vehicle are EXCLUDED
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
export class ReportConfigurationDAO implements IBaseDAO<IReportConfiguration> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
|
|
39
|
+
private tableName = "report_configurations";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a new report configuration
|
|
43
|
+
*/
|
|
44
|
+
async create(item: IReportConfigurationInput): Promise<IReportConfiguration> {
|
|
45
|
+
// Validate configuration before creating
|
|
46
|
+
const validation = this.validateConfiguration(item.configuration);
|
|
47
|
+
if (!validation.valid) {
|
|
48
|
+
throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [createdConfig] = await this._knex(this.tableName)
|
|
52
|
+
.insert({
|
|
53
|
+
name: item.name,
|
|
54
|
+
description: item.description,
|
|
55
|
+
configuration: JSON.stringify(item.configuration)
|
|
56
|
+
})
|
|
57
|
+
.returning("*");
|
|
58
|
+
|
|
59
|
+
return this._deserialize(createdConfig);
|
|
49
60
|
}
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.returning("*");
|
|
58
|
-
|
|
59
|
-
return this._deserialize(createdConfig);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Get configuration by numeric ID
|
|
64
|
-
*/
|
|
65
|
-
async getById(id: number): Promise<IReportConfiguration | null> {
|
|
66
|
-
const config = await this._knex(this.tableName).where({ id }).first();
|
|
67
|
-
return config ? this._deserialize(config) : null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Get configuration by UUID
|
|
72
|
-
*/
|
|
73
|
-
async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
|
|
74
|
-
const config = await this._knex(this.tableName).where({ uuid }).first();
|
|
75
|
-
return config ? this._deserialize(config) : null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Get configuration by name
|
|
80
|
-
*/
|
|
81
|
-
async getByName(name: string): Promise<IReportConfiguration | null> {
|
|
82
|
-
const config = await this._knex(this.tableName)
|
|
83
|
-
.whereRaw("LOWER(name) = LOWER(?)", [name])
|
|
84
|
-
.first();
|
|
85
|
-
return config ? this._deserialize(config) : null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Update a configuration
|
|
90
|
-
*/
|
|
91
|
-
async update(
|
|
92
|
-
id: number,
|
|
93
|
-
item: Partial<IReportConfigurationInput>,
|
|
94
|
-
): Promise<IReportConfiguration | null> {
|
|
95
|
-
// If configuration is being updated, validate it
|
|
96
|
-
if (item.configuration) {
|
|
97
|
-
const validation = this.validateConfiguration(item.configuration);
|
|
98
|
-
if (!validation.valid) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`Invalid configuration: ${validation.errors.join(", ")}`,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
62
|
+
/**
|
|
63
|
+
* Get configuration by numeric ID
|
|
64
|
+
*/
|
|
65
|
+
async getById(id: number): Promise<IReportConfiguration | null> {
|
|
66
|
+
const config = await this._knex(this.tableName).where({ id }).first();
|
|
67
|
+
return config ? this._deserialize(config) : null;
|
|
103
68
|
}
|
|
104
69
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const [updatedConfig] = await this._knex(this.tableName)
|
|
113
|
-
.where({ id })
|
|
114
|
-
.update(updateData)
|
|
115
|
-
.returning("*");
|
|
116
|
-
|
|
117
|
-
return updatedConfig ? this._deserialize(updatedConfig) : null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Delete a configuration
|
|
122
|
-
* Prevents deletion of the last configuration (business logic protection)
|
|
123
|
-
*/
|
|
124
|
-
async delete(id: number): Promise<boolean> {
|
|
125
|
-
// Count total configurations
|
|
126
|
-
const [{ count }] = await this._knex(this.tableName).count("* as count");
|
|
127
|
-
|
|
128
|
-
if (parseInt(count as string) <= 1) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
"Cannot delete the last configuration. At least one configuration must exist.",
|
|
131
|
-
);
|
|
70
|
+
/**
|
|
71
|
+
* Get configuration by UUID
|
|
72
|
+
*/
|
|
73
|
+
async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
|
|
74
|
+
const config = await this._knex(this.tableName).where({ uuid }).first();
|
|
75
|
+
return config ? this._deserialize(config) : null;
|
|
132
76
|
}
|
|
133
77
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
page: number,
|
|
143
|
-
limit: number,
|
|
144
|
-
): Promise<IDataPaginator<IReportConfiguration>> {
|
|
145
|
-
const offset = (page - 1) * limit;
|
|
146
|
-
|
|
147
|
-
const [countResult] = await this._knex(this.tableName).count("* as count");
|
|
148
|
-
const totalCount = +countResult.count;
|
|
149
|
-
const configs = await this._knex(this.tableName)
|
|
150
|
-
.limit(limit)
|
|
151
|
-
.offset(offset)
|
|
152
|
-
.orderBy("created_at", "desc");
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
success: true,
|
|
156
|
-
data: configs.map((c) => this._deserialize(c)),
|
|
157
|
-
page,
|
|
158
|
-
limit,
|
|
159
|
-
count: configs.length,
|
|
160
|
-
totalCount,
|
|
161
|
-
totalPages: Math.ceil(totalCount / limit),
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Validate a report configuration
|
|
167
|
-
*
|
|
168
|
-
* Rules:
|
|
169
|
-
* - Minimum 2, maximum 7 custom classes
|
|
170
|
-
* - Custom class names must be 1-30 characters
|
|
171
|
-
* - FHWA classes must be in range 1-13
|
|
172
|
-
* - Each FHWA class can only be mapped to one custom class (no duplicates)
|
|
173
|
-
* - Each custom class must have at least one FHWA class
|
|
174
|
-
*/
|
|
175
|
-
validateConfiguration(config: IReportConfigurationData): IValidationResult {
|
|
176
|
-
const errors: string[] = [];
|
|
177
|
-
|
|
178
|
-
// Validate version exists
|
|
179
|
-
if (!config.version) {
|
|
180
|
-
errors.push("Configuration version is required");
|
|
78
|
+
/**
|
|
79
|
+
* Get configuration by name
|
|
80
|
+
*/
|
|
81
|
+
async getByName(name: string): Promise<IReportConfiguration | null> {
|
|
82
|
+
const config = await this._knex(this.tableName)
|
|
83
|
+
.whereRaw("LOWER(name) = LOWER(?)", [name])
|
|
84
|
+
.first();
|
|
85
|
+
return config ? this._deserialize(config) : null;
|
|
181
86
|
}
|
|
182
87
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Update a configuration
|
|
90
|
+
*/
|
|
91
|
+
async update(id: number, item: Partial<IReportConfigurationInput>): Promise<IReportConfiguration | null> {
|
|
92
|
+
// If configuration is being updated, validate it
|
|
93
|
+
if (item.configuration) {
|
|
94
|
+
const validation = this.validateConfiguration(item.configuration);
|
|
95
|
+
if (!validation.valid) {
|
|
96
|
+
throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const updateData: any = {};
|
|
101
|
+
if (item.name !== undefined) updateData.name = item.name;
|
|
102
|
+
if (item.description !== undefined) updateData.description = item.description;
|
|
103
|
+
if (item.configuration !== undefined) updateData.configuration = JSON.stringify(item.configuration);
|
|
104
|
+
|
|
105
|
+
const [updatedConfig] = await this._knex(this.tableName)
|
|
106
|
+
.where({ id })
|
|
107
|
+
.update(updateData)
|
|
108
|
+
.returning("*");
|
|
109
|
+
|
|
110
|
+
return updatedConfig ? this._deserialize(updatedConfig) : null;
|
|
187
111
|
}
|
|
188
112
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Delete a configuration
|
|
115
|
+
* Prevents deletion of the last configuration (business logic protection)
|
|
116
|
+
*/
|
|
117
|
+
async delete(id: number): Promise<boolean> {
|
|
118
|
+
// Count total configurations
|
|
119
|
+
const [{ count }] = await this._knex(this.tableName).count('* as count');
|
|
120
|
+
|
|
121
|
+
if (parseInt(count as string) <= 1) {
|
|
122
|
+
throw new Error('Cannot delete the last configuration. At least one configuration must exist.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await this._knex(this.tableName).where({ id }).del();
|
|
126
|
+
return result > 0;
|
|
192
127
|
}
|
|
193
|
-
|
|
194
|
-
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get all configurations with pagination
|
|
131
|
+
*/
|
|
132
|
+
async getAll(page: number, limit: number): Promise<IDataPaginator<IReportConfiguration>> {
|
|
133
|
+
const offset = (page - 1) * limit;
|
|
134
|
+
|
|
135
|
+
const [countResult] = await this._knex(this.tableName).count("* as count");
|
|
136
|
+
const totalCount = +countResult.count;
|
|
137
|
+
const configs = await this._knex(this.tableName)
|
|
138
|
+
.limit(limit)
|
|
139
|
+
.offset(offset)
|
|
140
|
+
.orderBy("created_at", "desc");
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
success: true,
|
|
144
|
+
data: configs.map(c => this._deserialize(c)),
|
|
145
|
+
page,
|
|
146
|
+
limit,
|
|
147
|
+
count: configs.length,
|
|
148
|
+
totalCount,
|
|
149
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
150
|
+
};
|
|
195
151
|
}
|
|
196
152
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Validate a report configuration
|
|
155
|
+
*
|
|
156
|
+
* Rules:
|
|
157
|
+
* - Minimum 2, maximum 7 custom classes
|
|
158
|
+
* - Custom class names must be 1-30 characters
|
|
159
|
+
* - FHWA classes must be in range 1-13
|
|
160
|
+
* - Each FHWA class can only be mapped to one custom class (no duplicates)
|
|
161
|
+
* - Each custom class must have at least one FHWA class
|
|
162
|
+
*/
|
|
163
|
+
validateConfiguration(config: IReportConfigurationData): IValidationResult {
|
|
164
|
+
const errors: string[] = [];
|
|
165
|
+
|
|
166
|
+
// Validate version exists
|
|
167
|
+
if (!config.version) {
|
|
168
|
+
errors.push('Configuration version is required');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Validate custom classes array
|
|
172
|
+
if (!config.customClasses || !Array.isArray(config.customClasses)) {
|
|
173
|
+
errors.push('customClasses must be an array');
|
|
174
|
+
return { valid: false, errors };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Min 2, max 7 custom classes
|
|
178
|
+
if (config.customClasses.length < 2) {
|
|
179
|
+
errors.push('Minimum 2 custom classes required');
|
|
180
|
+
}
|
|
181
|
+
if (config.customClasses.length > 7) {
|
|
182
|
+
errors.push('Maximum 7 custom classes allowed');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check name length (max 30 chars) and FHWA classes validity
|
|
186
|
+
const allFhwaClasses: number[] = [];
|
|
187
|
+
config.customClasses.forEach((cls, idx) => {
|
|
188
|
+
if (!cls.name || cls.name.length === 0) {
|
|
189
|
+
errors.push(`Custom class ${idx + 1}: name cannot be empty`);
|
|
190
|
+
}
|
|
191
|
+
if (cls.name && cls.name.toLowerCase() === 'total') {
|
|
192
|
+
errors.push(`Custom class ${idx + 1}: "Total" is a reserved name and cannot be used`);
|
|
193
|
+
}
|
|
194
|
+
if (cls.name && cls.name.length > 30) {
|
|
195
|
+
errors.push(`Custom class ${idx + 1}: name exceeds 30 characters`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!Array.isArray(cls.fhwaClasses) || cls.fhwaClasses.length === 0) {
|
|
199
|
+
errors.push(`Custom class ${idx + 1}: must have at least one FHWA class`);
|
|
200
|
+
} else {
|
|
201
|
+
cls.fhwaClasses.forEach(fhwa => {
|
|
202
|
+
if (!Number.isInteger(fhwa) || fhwa < 1 || fhwa > 13) {
|
|
203
|
+
errors.push(`Custom class ${idx + 1}: FHWA class ${fhwa} is invalid (must be 1-13)`);
|
|
204
|
+
}
|
|
205
|
+
allFhwaClasses.push(fhwa);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
224
208
|
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
errors.push(
|
|
235
|
-
`Duplicate FHWA classes detected: ${[...new Set(duplicates)].join(", ")}. Each FHWA class can only be mapped to one custom class.`,
|
|
236
|
-
);
|
|
209
|
+
|
|
210
|
+
// Check for duplicate FHWA classes (many-to-one only)
|
|
211
|
+
const uniqueFhwaClasses = new Set(allFhwaClasses);
|
|
212
|
+
if (uniqueFhwaClasses.size !== allFhwaClasses.length) {
|
|
213
|
+
const duplicates = allFhwaClasses.filter((item, index) => allFhwaClasses.indexOf(item) !== index);
|
|
214
|
+
errors.push(`Duplicate FHWA classes detected: ${[...new Set(duplicates)].join(', ')}. Each FHWA class can only be mapped to one custom class.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { valid: errors.length === 0, errors };
|
|
237
218
|
}
|
|
238
219
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Apply configuration transformation to detection results
|
|
222
|
+
*
|
|
223
|
+
* Two-step transformation:
|
|
224
|
+
* 1. Detection labels → FHWA classes (using DETECTION_LABEL_TO_FHWA mapping)
|
|
225
|
+
* 2. FHWA classes → Custom classes (using configuration)
|
|
226
|
+
*
|
|
227
|
+
* @param detectionResults - Raw detection results with labels as keys and counts as values
|
|
228
|
+
* Example: { 'car': 150, 'articulated_truck': 23, 'motorcycle': 5 }
|
|
229
|
+
* @param config - The report configuration to apply
|
|
230
|
+
* @returns Transformed results with custom class names as keys and counts as values
|
|
231
|
+
* Example: { 'Cars': 155, 'Heavy Trucks': 23 }
|
|
232
|
+
*/
|
|
233
|
+
applyConfiguration(
|
|
234
|
+
detectionResults: Record<string, number>,
|
|
235
|
+
config: IReportConfiguration
|
|
236
|
+
): Record<string, number> {
|
|
237
|
+
// Step 1: Detection labels → FHWA classes
|
|
238
|
+
const fhwaClassCounts: Record<number, number> = {};
|
|
239
|
+
|
|
240
|
+
for (const [label, count] of Object.entries(detectionResults)) {
|
|
241
|
+
const fhwaClasses = DETECTION_LABEL_TO_FHWA[label];
|
|
242
|
+
if (fhwaClasses && fhwaClasses.length > 0) {
|
|
243
|
+
fhwaClasses.forEach(fhwaClass => {
|
|
244
|
+
fhwaClassCounts[fhwaClass] = (fhwaClassCounts[fhwaClass] || 0) + count;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Step 2: FHWA classes → Custom classes
|
|
251
|
+
const customClassCounts: Record<string, number> = {};
|
|
252
|
+
|
|
253
|
+
config.configuration.customClasses.forEach(customClass => {
|
|
254
|
+
let total = 0;
|
|
255
|
+
customClass.fhwaClasses.forEach(fhwaClass => {
|
|
256
|
+
total += fhwaClassCounts[fhwaClass] || 0;
|
|
257
|
+
});
|
|
258
|
+
customClassCounts[customClass.name] = total;
|
|
268
259
|
});
|
|
269
|
-
|
|
270
|
-
|
|
260
|
+
|
|
261
|
+
return customClassCounts;
|
|
271
262
|
}
|
|
272
263
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
): Record<string, any> {
|
|
303
|
-
// Build reverse mapping: detection label → custom class name
|
|
304
|
-
const detectionToCustomClass: Record<string, string> = {};
|
|
305
|
-
|
|
306
|
-
for (const customClass of config.configuration.customClasses) {
|
|
307
|
-
// For each FHWA class in this custom class
|
|
308
|
-
for (const fhwaClass of customClass.fhwaClasses) {
|
|
309
|
-
// Find all detection labels that map to this FHWA class
|
|
310
|
-
for (const [label, fhwaClasses] of Object.entries(
|
|
311
|
-
DETECTION_LABEL_TO_FHWA,
|
|
312
|
-
)) {
|
|
313
|
-
if (fhwaClasses.includes(fhwaClass)) {
|
|
314
|
-
detectionToCustomClass[label] = customClass.name;
|
|
315
|
-
}
|
|
264
|
+
/**
|
|
265
|
+
* Transform nested vehicle structure with custom class mapping
|
|
266
|
+
*
|
|
267
|
+
* Handles both ATR (lane-based) and TMC (direction/turn-based) formats
|
|
268
|
+
* Preserves all nesting levels while transforming detection labels to custom classes
|
|
269
|
+
*
|
|
270
|
+
* @param vehiclesStructure - Nested vehicles object with detection labels as keys
|
|
271
|
+
* ATR: { "car": { "0": 45, "1": 50 }, ... }
|
|
272
|
+
* TMC: { "car": { "NORTH": { "straight": 10 }, ... }, ... }
|
|
273
|
+
* @param config - Report configuration with custom class mappings
|
|
274
|
+
* @returns Transformed structure with custom class names as keys
|
|
275
|
+
*/
|
|
276
|
+
applyConfigurationToNestedStructure(
|
|
277
|
+
vehiclesStructure: Record<string, any>,
|
|
278
|
+
config: IReportConfiguration
|
|
279
|
+
): Record<string, any> {
|
|
280
|
+
// Build reverse mapping: detection label → custom class name
|
|
281
|
+
const detectionToCustomClass: Record<string, string> = {};
|
|
282
|
+
|
|
283
|
+
for (const customClass of config.configuration.customClasses) {
|
|
284
|
+
// For each FHWA class in this custom class
|
|
285
|
+
for (const fhwaClass of customClass.fhwaClasses) {
|
|
286
|
+
// Find all detection labels that map to this FHWA class
|
|
287
|
+
for (const [label, fhwaClasses] of Object.entries(DETECTION_LABEL_TO_FHWA)) {
|
|
288
|
+
if (fhwaClasses.includes(fhwaClass)) {
|
|
289
|
+
detectionToCustomClass[label] = customClass.name;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
316
293
|
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
294
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
295
|
+
// Initialize empty structure for each custom class
|
|
296
|
+
const result: Record<string, any> = {};
|
|
297
|
+
for (const customClass of config.configuration.customClasses) {
|
|
298
|
+
result[customClass.name] = {};
|
|
299
|
+
}
|
|
325
300
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
)) {
|
|
330
|
-
const customClassName = detectionToCustomClass[detectionLabel];
|
|
331
|
-
|
|
332
|
-
// Skip labels not mapped to any custom class (e.g., pedestrian, bicycle)
|
|
333
|
-
if (!customClassName) {
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Deep merge nested data into custom class accumulator
|
|
338
|
-
result[customClassName] = this._deepMergeNumericData(
|
|
339
|
-
result[customClassName],
|
|
340
|
-
nestedData,
|
|
341
|
-
);
|
|
342
|
-
}
|
|
301
|
+
// Iterate through detection labels in input structure
|
|
302
|
+
for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
|
|
303
|
+
const customClassName = detectionToCustomClass[detectionLabel];
|
|
343
304
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
* @param source - Source data to merge into target
|
|
359
|
-
* @returns Merged object with summed numeric values
|
|
360
|
-
*/
|
|
361
|
-
private _deepMergeNumericData(target: any, source: any): any {
|
|
362
|
-
// Base case: if source is a number, add it to target
|
|
363
|
-
if (typeof source === "number") {
|
|
364
|
-
return (typeof target === "number" ? target : 0) + source;
|
|
365
|
-
}
|
|
305
|
+
// Skip labels not mapped to any custom class (e.g., pedestrian, bicycle)
|
|
306
|
+
if (!customClassName) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Deep merge nested data into custom class accumulator
|
|
311
|
+
result[customClassName] = this._deepMergeNumericData(
|
|
312
|
+
result[customClassName],
|
|
313
|
+
nestedData
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Add "Total" class that aggregates all custom classes
|
|
318
|
+
result["Total"] = this._createTotalClass(result);
|
|
366
319
|
|
|
367
|
-
|
|
368
|
-
if (typeof source !== "object" || source === null) {
|
|
369
|
-
return target;
|
|
320
|
+
return result;
|
|
370
321
|
}
|
|
371
322
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
323
|
+
/**
|
|
324
|
+
* Deep merge numeric data at arbitrary nesting levels
|
|
325
|
+
*
|
|
326
|
+
* Recursively merges two nested structures, summing numeric leaf values
|
|
327
|
+
* Handles ATR format (2 levels: vehicle → lane → count)
|
|
328
|
+
* Handles TMC format (3 levels: vehicle → direction → turn → count)
|
|
329
|
+
*
|
|
330
|
+
* @param target - Target accumulator object
|
|
331
|
+
* @param source - Source data to merge into target
|
|
332
|
+
* @returns Merged object with summed numeric values
|
|
333
|
+
*/
|
|
334
|
+
private _deepMergeNumericData(target: any, source: any): any {
|
|
335
|
+
// Base case: if source is a number, add it to target
|
|
336
|
+
if (typeof source === 'number') {
|
|
337
|
+
return (typeof target === 'number' ? target : 0) + source;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// If source is not an object, return target unchanged
|
|
341
|
+
if (typeof source !== 'object' || source === null) {
|
|
342
|
+
return target;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Ensure target is an object
|
|
346
|
+
if (typeof target !== 'object' || target === null) {
|
|
347
|
+
target = {};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Recursively merge each key in source
|
|
351
|
+
for (const [key, value] of Object.entries(source)) {
|
|
352
|
+
target[key] = this._deepMergeNumericData(target[key], value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return target;
|
|
375
356
|
}
|
|
376
357
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Create "Total" vehicle class by summing all custom classes
|
|
360
|
+
* Works for both ATR and TMC formats through structure-agnostic deep merge
|
|
361
|
+
*
|
|
362
|
+
* @param customClassesData - Object with custom class names as keys
|
|
363
|
+
* @returns Aggregated nested structure summing all classes
|
|
364
|
+
*/
|
|
365
|
+
private _createTotalClass(customClassesData: Record<string, any>): any {
|
|
366
|
+
let total: any = {};
|
|
367
|
+
|
|
368
|
+
for (const [className, nestedData] of Object.entries(customClassesData)) {
|
|
369
|
+
total = this._deepMergeNumericData(total, nestedData);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return total;
|
|
380
373
|
}
|
|
381
374
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
* Works for both ATR and TMC formats through structure-agnostic deep merge
|
|
388
|
-
*
|
|
389
|
-
* @param customClassesData - Object with custom class names as keys
|
|
390
|
-
* @returns Aggregated nested structure summing all classes
|
|
391
|
-
*/
|
|
392
|
-
private _createTotalClass(customClassesData: Record<string, any>): any {
|
|
393
|
-
let total: any = {};
|
|
394
|
-
|
|
395
|
-
for (const [className, nestedData] of Object.entries(customClassesData)) {
|
|
396
|
-
total = this._deepMergeNumericData(total, nestedData);
|
|
375
|
+
/**
|
|
376
|
+
* Get the FHWA mapping constant (for reference/debugging)
|
|
377
|
+
*/
|
|
378
|
+
getDetectionLabelToFhwaMapping(): Record<string, number[]> {
|
|
379
|
+
return { ...DETECTION_LABEL_TO_FHWA };
|
|
397
380
|
}
|
|
398
381
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
uuid: row.uuid,
|
|
417
|
-
name: row.name,
|
|
418
|
-
description: row.description,
|
|
419
|
-
configuration:
|
|
420
|
-
typeof row.configuration === "string"
|
|
421
|
-
? JSON.parse(row.configuration)
|
|
422
|
-
: row.configuration,
|
|
423
|
-
created_at: row.created_at,
|
|
424
|
-
updated_at: row.updated_at,
|
|
425
|
-
};
|
|
426
|
-
}
|
|
382
|
+
/**
|
|
383
|
+
* Deserialize database row to IReportConfiguration interface
|
|
384
|
+
* Converts snake_case to camelCase and parses JSONB
|
|
385
|
+
*/
|
|
386
|
+
private _deserialize(row: any): IReportConfiguration {
|
|
387
|
+
return {
|
|
388
|
+
id: row.id,
|
|
389
|
+
uuid: row.uuid,
|
|
390
|
+
name: row.name,
|
|
391
|
+
description: row.description,
|
|
392
|
+
configuration: typeof row.configuration === 'string'
|
|
393
|
+
? JSON.parse(row.configuration)
|
|
394
|
+
: row.configuration,
|
|
395
|
+
created_at: row.created_at,
|
|
396
|
+
updated_at: row.updated_at
|
|
397
|
+
};
|
|
398
|
+
}
|
|
427
399
|
}
|