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