1use serde::Serialize;
7use serde_json::Value;
8
9use super::{Db, Ts};
10
11#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
12pub struct ProfileRow {
13 pub id: i64,
14 pub ocpp_version: String,
15 pub profile_id: i32,
16 pub evse_number: i32,
17 pub purpose: String,
18 pub stack_level: i32,
19 pub kind: String,
20 pub transaction_uid: Option<String>,
21 pub valid_from: Option<Ts>,
22 pub valid_to: Option<Ts>,
23 pub source: String,
24 pub status: String,
25 pub status_info: Option<Value>,
26 pub profile: Value,
27 pub dyn_update: Option<Value>,
28 pub dyn_updated_at: Option<Ts>,
29 pub created_at: Ts,
30 pub updated_at: Ts,
31}
32
33const COLS: &str = "id, ocpp_version, profile_id, evse_number, purpose, stack_level, kind, transaction_uid, valid_from, valid_to, source, status, \
34 status_info, profile, dyn_update, dyn_updated_at, created_at, updated_at";
35
36pub struct NewProfile<'a> {
38 pub ocpp_version: &'a str,
39 pub profile_id: i64,
40 pub evse_number: i64,
41 pub purpose: &'a str,
42 pub stack_level: i64,
43 pub kind: &'a str,
44 pub transaction_uid: Option<&'a str>,
45 pub valid_from: Option<Ts>,
46 pub valid_to: Option<Ts>,
47 pub source: &'a str,
48 pub profile: &'a Value,
49}
50
51fn i32_of(v: i64) -> i32 {
52 v.clamp(i32::MIN as i64, i32::MAX as i64) as i32
53}
54
55pub async fn insert_pending(db: &Db, station_id: i64, p: &NewProfile<'_>) -> sqlx::Result<i64> {
58 let mut tx = db.begin().await?;
59 sqlx::query("DELETE FROM charging_profiles WHERE station_id = $1 AND profile_id = $2 AND status = 'Pending'")
60 .bind(station_id)
61 .bind(i32_of(p.profile_id))
62 .execute(&mut *tx)
63 .await?;
64 let id = insert(&mut tx, station_id, p, "Pending").await?;
65 tx.commit().await?;
66 Ok(id)
67}
68
69async fn insert(
70 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
71 station_id: i64,
72 p: &NewProfile<'_>,
73 status: &str,
74) -> sqlx::Result<i64> {
75 sqlx::query_scalar(
76 "INSERT INTO charging_profiles (station_id, ocpp_version, profile_id, evse_number, purpose, stack_level, kind, transaction_uid, \
77 valid_from, valid_to, source, status, profile) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
78 )
79 .bind(station_id)
80 .bind(p.ocpp_version)
81 .bind(i32_of(p.profile_id))
82 .bind(i32_of(p.evse_number))
83 .bind(p.purpose)
84 .bind(i32_of(p.stack_level))
85 .bind(p.kind)
86 .bind(p.transaction_uid)
87 .bind(p.valid_from)
88 .bind(p.valid_to)
89 .bind(p.source)
90 .bind(status)
91 .bind(sqlx::types::Json(p.profile))
92 .fetch_one(&mut **tx)
93 .await
94}
95
96pub async fn mark_installed(
101 db: &Db,
102 station_id: i64,
103 row_id: i64,
104 replace_same_slot: bool,
105) -> sqlx::Result<()> {
106 let mut tx = db.begin().await?;
107 sqlx::query(
108 "UPDATE charging_profiles o SET status = 'Cleared', updated_at = now() FROM charging_profiles n \
109 WHERE n.id = $2 AND o.station_id = $1 AND o.id <> n.id AND o.status = 'Installed' AND (o.profile_id = n.profile_id OR \
110 ($3 AND o.evse_number = n.evse_number AND o.purpose = n.purpose AND o.stack_level = n.stack_level \
111 AND o.transaction_uid IS NOT DISTINCT FROM n.transaction_uid))",
112 )
113 .bind(station_id)
114 .bind(row_id)
115 .bind(replace_same_slot)
116 .execute(&mut *tx)
117 .await?;
118 sqlx::query(
119 "UPDATE charging_profiles SET status = 'Installed', updated_at = now() WHERE id = $1",
120 )
121 .bind(row_id)
122 .execute(&mut *tx)
123 .await?;
124 tx.commit().await
125}
126
127pub async fn mark_rejected(db: &Db, row_id: i64, status_info: Option<&Value>) -> sqlx::Result<()> {
128 sqlx::query("UPDATE charging_profiles SET status = 'Rejected', status_info = $2, updated_at = now() WHERE id = $1")
129 .bind(row_id)
130 .bind(status_info.map(sqlx::types::Json))
131 .execute(db)
132 .await?;
133 Ok(())
134}
135
136pub async fn discard_pending(db: &Db, row_id: i64) -> sqlx::Result<()> {
138 sqlx::query("DELETE FROM charging_profiles WHERE id = $1 AND status = 'Pending'")
139 .bind(row_id)
140 .execute(db)
141 .await?;
142 Ok(())
143}
144
145pub async fn list(db: &Db, station_id: i64, status: Option<&str>) -> sqlx::Result<Vec<ProfileRow>> {
146 sqlx::query_as(&format!(
147 "SELECT {COLS} FROM charging_profiles WHERE station_id = $1 AND ($2::text IS NULL OR status = $2) ORDER BY evse_number, purpose, stack_level DESC, id"
148 ))
149 .bind(station_id)
150 .bind(status)
151 .fetch_all(db)
152 .await
153}
154
155pub async fn installed(db: &Db, station_id: i64) -> sqlx::Result<Vec<ProfileRow>> {
156 list(db, station_id, Some("Installed")).await
157}
158
159pub async fn find_installed(
160 db: &Db,
161 station_id: i64,
162 profile_id: i64,
163) -> sqlx::Result<Option<ProfileRow>> {
164 sqlx::query_as(&format!("SELECT {COLS} FROM charging_profiles WHERE station_id = $1 AND profile_id = $2 AND status = 'Installed'"))
165 .bind(station_id)
166 .bind(i32_of(profile_id))
167 .fetch_optional(db)
168 .await
169}
170
171#[derive(Debug, Default)]
173pub struct ClearCriteria<'a> {
174 pub profile_id: Option<i64>,
175 pub evse_number: Option<i64>,
176 pub purpose: Option<&'a str>,
177 pub stack_level: Option<i64>,
178}
179
180pub async fn clear(db: &Db, station_id: i64, c: &ClearCriteria<'_>) -> sqlx::Result<u64> {
181 Ok(sqlx::query(
182 "UPDATE charging_profiles SET status = 'Cleared', updated_at = now() WHERE station_id = $1 AND status = 'Installed' AND source = 'CSO' \
183 AND ($2::int IS NULL OR profile_id = $2) AND ($3::int IS NULL OR evse_number = $3) AND ($4::text IS NULL OR purpose = $4) \
184 AND ($5::int IS NULL OR stack_level = $5)",
185 )
186 .bind(station_id)
187 .bind(c.profile_id.map(i32_of))
188 .bind(c.evse_number.map(i32_of))
189 .bind(c.purpose)
190 .bind(c.stack_level.map(i32_of))
191 .execute(db)
192 .await?
193 .rows_affected())
194}
195
196pub async fn clear_for_transaction(
198 db: &Db,
199 station_id: i64,
200 transaction_uid: &str,
201) -> sqlx::Result<u64> {
202 Ok(sqlx::query(
203 "UPDATE charging_profiles SET status = 'Cleared', updated_at = now() \
204 WHERE station_id = $1 AND status = 'Installed' AND purpose IN ('TxProfile', 'PriorityCharging') AND transaction_uid = $2",
205 )
206 .bind(station_id)
207 .bind(transaction_uid)
208 .execute(db)
209 .await?
210 .rows_affected())
211}
212
213pub async fn upsert_reported(db: &Db, station_id: i64, p: &NewProfile<'_>) -> sqlx::Result<()> {
215 let mut tx = db.begin().await?;
216 sqlx::query("UPDATE charging_profiles SET status = 'Cleared', updated_at = now() WHERE station_id = $1 AND profile_id = $2 AND status IN ('Installed', 'Pending')")
217 .bind(station_id)
218 .bind(i32_of(p.profile_id))
219 .execute(&mut *tx)
220 .await?;
221 insert(&mut tx, station_id, p, "Installed").await?;
222 tx.commit().await
223}
224
225pub async fn set_dyn_update(db: &Db, row_id: i64, update: &Value) -> sqlx::Result<()> {
226 sqlx::query("UPDATE charging_profiles SET dyn_update = $2, dyn_updated_at = now(), updated_at = now() WHERE id = $1")
227 .bind(row_id)
228 .bind(sqlx::types::Json(update))
229 .execute(db)
230 .await?;
231 Ok(())
232}
233
234pub async fn next_profile_id(db: &Db, station_id: i64) -> sqlx::Result<i64> {
237 sqlx::query_scalar("SELECT GREATEST(COALESCE(MAX(profile_id), 0) + 1, 100000)::bigint FROM charging_profiles WHERE station_id = $1")
238 .bind(station_id)
239 .fetch_one(db)
240 .await
241}
242
243#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
244pub struct ExternalLimit {
245 pub evse_number: i32,
246 pub source: String,
247 pub is_grid_critical: Option<bool>,
248 pub is_local_generation: Option<bool>,
249 pub schedules: Option<Value>,
250 pub received_at: Ts,
251}
252
253pub async fn upsert_external_limit(
254 db: &Db,
255 station_id: i64,
256 evse_number: i64,
257 source: &str,
258 grid_critical: Option<bool>,
259 local_generation: Option<bool>,
260 schedules: Option<&Value>,
261) -> sqlx::Result<()> {
262 sqlx::query(
263 "INSERT INTO external_charging_limits (station_id, evse_number, source, is_grid_critical, is_local_generation, schedules, received_at) \
264 VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (station_id, evse_number, source) DO UPDATE SET \
265 is_grid_critical = EXCLUDED.is_grid_critical, is_local_generation = EXCLUDED.is_local_generation, schedules = EXCLUDED.schedules, received_at = now()",
266 )
267 .bind(station_id)
268 .bind(i32_of(evse_number))
269 .bind(source)
270 .bind(grid_critical)
271 .bind(local_generation)
272 .bind(schedules.map(sqlx::types::Json))
273 .execute(db)
274 .await?;
275 Ok(())
276}
277
278pub async fn delete_external_limits(
280 db: &Db,
281 station_id: i64,
282 source: &str,
283 evse_number: Option<i64>,
284) -> sqlx::Result<u64> {
285 Ok(sqlx::query("DELETE FROM external_charging_limits WHERE station_id = $1 AND source = $2 AND ($3::int IS NULL OR evse_number = $3)")
286 .bind(station_id)
287 .bind(source)
288 .bind(evse_number.map(i32_of))
289 .execute(db)
290 .await?
291 .rows_affected())
292}
293
294pub async fn external_limits(db: &Db, station_id: i64) -> sqlx::Result<Vec<ExternalLimit>> {
295 sqlx::query_as("SELECT evse_number, source, is_grid_critical, is_local_generation, schedules, received_at FROM external_charging_limits WHERE station_id = $1 ORDER BY evse_number, source")
296 .bind(station_id)
297 .fetch_all(db)
298 .await
299}