roughly_rs/
lib.rs

1use std::{collections::{HashMap, HashSet}, error::Error, fs::File, path::PathBuf, time::{SystemTime, UNIX_EPOCH}};
2use std::io::Write;
3pub mod data;
4pub mod missing_hours_data;
5pub mod entities;
6
7use calamine::{open_workbook, RangeDeserializerBuilder, Xlsx, Reader};
8use chrono::{ Datelike, NaiveDate };
9
10use data::{CompactEmployee, CompactProject, Customer, Employee, MonthlyAnalysis, Project, WeekData};
11use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
12use reqwest::{header::{
13    HeaderMap, HeaderValue, CACHE_CONTROL, CONTENT_TYPE, REFERER
14}, Client, Response, };
15
16pub struct RoughlyRight {
17    username: String,
18    password: String,
19    client: Client,
20    logged_in: bool,
21    last: u64,
22}
23
24const CUSTOMER_IMAGE_URL: &str = "https://rr-space-prod.ams3.cdn.digitaloceanspaces.com/img/customers";
25const EMPLOYEE_IMAGE_URL: &str = "https://rr-space-prod.ams3.cdn.digitaloceanspaces.com/img/profile";
26const DATA_FOLDER: &str = "./data";
27
28impl RoughlyRight {
29
30    pub fn new(username: &str, password: &str) -> Self {
31
32        // Build a client that can store cookies
33        let client = Client::builder().user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36").cookie_store(true).build().unwrap(); 
34
35        RoughlyRight { 
36            username: username.to_string(), 
37            password: password.to_string(),
38            client,
39            logged_in: false,
40            last: 0,
41        }
42
43    }
44
45    pub async fn get(&mut self, url: &str) -> Result<Response, Box<dyn Error>> {
46
47        self.login().await?;
48        let response = self
49            .client
50            .get(url)
51            .header("user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36")
52            .header("content-type", "application/json")
53            .send()
54        .await?;
55
56        if response.status().is_success() {
57            return Ok(response);
58        } else if response.status() == 401 {
59            self.logged_in = false;
60            self.login().await?;
61            let response = self.client.get(url).send().await?;
62            return Ok(response);
63        }
64
65        eprintln!("Failed to fetch data: {}", response.text().await?);
66        Err("Failed to fetch data".into())
67        
68
69    }
70
71    pub async fn employees(&mut self) -> Result<Vec<Employee>, Box<dyn Error>> {
72        let url = "https://app.roughlyright.com/employees?active=true";
73        let response = self.get(url).await?;
74        if response.status().is_success() {
75            let project_list: Vec<Employee> = response.json().await?;
76            return Ok(project_list);
77        } else {
78            eprintln!("Failed to fetch data: {}", response.status());
79        }
80        Ok(Vec::new())
81
82    }
83
84    pub async fn get_monthly_hours(&mut self, from: String, to:String, file: Option<PathBuf> ) -> Result<MonthlyAnalysis, Box<dyn Error>> {
85
86
87        let exclude = ["Intern adm", "Intern tid", "Utbildning", "Försäljning"]; //, "Permission", "Vård av närstående"];
88        let doesnt_cost = [
89            "VAB",
90            "Föräldraledig",
91            "Permission",
92            "Vård av närstående",
93            "Tjänstledig",
94        ];
95
96        let super_exclude = [
97            "VAB",
98            "Föräldraledig",
99            "Permission",
100            "Vård av närstående",
101            "Sjuk",
102            "Semester",
103            "Tjänstledig",
104        ];
105
106        let not_countable = [
107            "Föräldraledig",
108            //--"Tjänstledig"
109        ];
110
111        let path = match file {
112            None => self.get_monthly_hours_xlsx(from.clone(), to.clone()).await?,
113            Some(file) => file,
114        };
115
116        // let path = PathBuf::from(format!("{}/{}.xlsx", DATA_FOLDER, date));
117
118        let mut workbook: Xlsx<_> = open_workbook(path)?;
119        let range = workbook.worksheet_range("project hours")?;
120
121        let iter = RangeDeserializerBuilder::new().from_range(&range)?;
122
123        let mut hour_rate_count: f64 = 0.0;
124        let mut hour_rate_total: f64 = 0.0;
125        let mut hours_billable_count: f64 = 0.0;
126        let mut hours_not_counted: f64 = 0.0;
127        let mut billable_amount: f64 = 0.0;
128        let mut all_hours: f64 = 0.0;
129        let mut hours_away: f64 = 0.0;
130        let mut hours_with_cost: f64 = 0.0;
131       
132        for (count, result) in iter.enumerate() {
133            if count > 0 {
134                let (
135
136                    //                    Employee,
137                    //                    Type,
138                    //                    Client,
139                    //                    Project,
140                    //                    Project number,
141                    //                    Project tags,
142                    //                    Subproject,
143                    //                    Day	
144                    //                    Activity,
145                    //                    Comment,
146                    //                    Reported,
147                    //                    Rate,
148                    //                    Value
149                    name, 
150                    _action_type, 
151                    _company, 
152                    _project, 
153                    _projectnr, 
154                    _adjusted, 
155                    _day, 
156                    _dubproject, 
157                    activity, 
158                    _comment, 
159                    reported, 
160                    hour_rate, 
161                    value,
162                ): (
163                    String, 
164                    String,
165                    String,
166                    String, 
167                    String, 
168                    String, 
169                    String, 
170                    String, 
171                    String, 
172                    String, 
173                    String, 
174                    String, 
175                    String,
176                ) = result?;
177
178                if name.is_empty() {
179                    continue;
180                }
181
182                if super_exclude.contains(&activity.as_str()) {
183                    let reported = reported.parse::<f64>().unwrap_or(0.0);
184                    if !doesnt_cost.contains(&activity.as_str()) {
185                        hours_with_cost += reported;
186                    }
187                    if !not_countable.contains(&activity.as_str()) {
188                        hours_away += reported;
189                    }
190                    continue;
191                }
192
193                let value =  value.parse::<f64>().unwrap_or(0.0);
194                if !hour_rate.is_empty() {
195                    let hour_rate = hour_rate.parse::<f64>().unwrap();
196                    hour_rate_total += hour_rate;
197                    hour_rate_count += 1.0;
198                    if value > 0.0 {
199                        billable_amount += value;
200                    }
201                }
202
203                let reported = reported.parse::<f64>().unwrap_or(0.0);
204
205                if !doesnt_cost.contains(&activity.as_str()) {
206                    hours_with_cost += reported;
207                }
208
209                if !exclude.contains(&activity.as_str()) {
210                    hours_billable_count += reported
211                } else {
212                    hours_not_counted += reported;
213                }
214
215                all_hours += reported;
216
217            }
218
219        }
220
221        let hour_rate_average = hour_rate_total / hour_rate_count;
222
223        let analysis = MonthlyAnalysis {
224            all_hours,
225            all_hours_billable: hours_billable_count,
226            all_hours_non_billable: hours_not_counted,
227            all_hours_with_cost: hours_with_cost,
228            billing_rate: 0.0,
229            hours_away,
230            average_rate: hour_rate_average,
231            total_invoiced: billable_amount,
232        };
233
234        Ok(analysis)
235
236    }
237
238    pub async fn get_monthly_hours_xlsx(&mut self, from: String, to: String) -> Result<PathBuf, Box<dyn Error>> {
239
240        let from_year = &from[0..4].parse::<i32>().expect("Invalid year");
241        let from_month = &from[4..6].parse::<u32>().expect("Invalid month");
242
243        // Create a date for the first day of the month
244        let start_date = NaiveDate::from_ymd(*from_year, *from_month, 1);
245        let start_date_str = start_date.format("%Y%m%d").to_string();
246
247        let to_year = &to[0..4].parse::<i32>().expect("Invalid year");
248        let to_month = &to[4..6].parse::<u32>().expect("Invalid month");
249
250        // Create a date for the first day of the month
251        let end_date = NaiveDate::from_ymd(*to_year, *to_month, 1);
252
253        let end_date = end_date
254            .with_day(1)
255            .and_then(|date| date.with_month(date.month() + 1))
256            .unwrap_or_else(|| NaiveDate::from_ymd(*to_year + 1, 1, 1)) // handle December case
257        - chrono::Duration::days(1);
258
259        let end_date_str = end_date.format("%Y%m%d").to_string();
260
261        let url = format!("https://app.roughlyright.com/excel/hours?startDate={}&endDate={}", start_date_str, end_date_str);
262
263        let response = self.get(&url).await?;
264
265        if response.status().is_success() {
266            let bytes = response.bytes().await?;
267
268            let download_dir = dirs::download_dir();
269
270            let path = PathBuf::from(format!("{}/{}-{}.xlsx", download_dir.unwrap().to_str().unwrap(), from, to));
271            let mut file = File::create(path.clone())?;
272            file.write_all(&bytes)?;
273            return Ok(path);
274        } else {
275            eprintln!("Failed to fetch data: {}", response.status());
276        }
277
278        Err("Failed to fetch data".into())
279
280    }
281
282    pub async fn employees_map(&mut self) -> Result<HashMap<String, Employee>, Box<dyn Error>> {
283        let list = self.employees().await?;
284        let map: HashMap<String, Employee> = list.into_iter().map(|item| (item.id.clone(), item)).collect();
285        Ok(map)
286    }
287
288    pub async fn projects(&mut self) -> Result<Vec<Project>, Box<dyn Error>> {
289        let url = "https://app.roughlyright.com/projects?finished=false&noGroupFilter=true&projection=planning";
290        let response = self.get(url).await?;
291        if response.status().is_success() {
292            let list: Vec<Project> = response.json().await?;
293            return Ok(list);
294        } else {
295            eprintln!("Failed to fetch data: {}", response.status());
296            eprintln!("Body: {}", response.text().await?);
297        }
298        Ok(Vec::new())
299    }
300
301    pub async fn projects_map(&mut self) -> Result<HashMap<String, Project>, Box<dyn Error>> {
302        let list = self.projects().await?;
303        let map: HashMap<String, Project> = list.into_iter().map(|item| (item.id.clone(), item)).collect();
304        Ok(map)
305    }
306
307    pub async fn customers(&mut self) -> Result<Vec<Customer>, Box<dyn Error>> {
308        let url = "https://app.roughlyright.com/customers";
309        let response = self.get(url).await?;
310        if response.status().is_success() {
311            let list: Vec<Customer> = response.json().await?;
312            return Ok(list);
313        } else {
314            eprintln!("Failed to fetch data: {}", response.status());
315        }
316        Ok(Vec::new())
317    }
318
319    pub async fn customers_map(&mut self) -> Result<HashMap<String, Customer>, Box<dyn Error>> {
320        let list = self.customers().await?;
321        let map: HashMap<String, Customer> = list.into_iter().map(|customer| (customer.id.clone(), customer)).collect();
322        Ok(map)
323    }
324
325
326    pub async fn week_hours(&mut self, week_start: &str, week_end: &str) -> Result<Vec<WeekData>, Box<dyn Error>> {
327        let url = format!("https://app.roughlyright.com/weekhours?allPlansForProjects=true&endWeek={}&startWeek={}", week_start, week_end);
328        let response = self.get(&url).await?;
329        if response.status().is_success() {
330            let week_data_list: Vec<WeekData> = response.json().await?;
331            return Ok(week_data_list);
332        } else {
333            eprintln!("Failed to fetch data: {}", response.status());
334        }
335        Ok(Vec::new())
336    }
337
338    /// Returns a map of all projects and who works in them on a certain week. Ignores employees
339    /// with no hours for the specific week. This only returns a subset of the data.
340    ///
341    /// let mut rr = RoughlyRight::new(&username, &password);
342    /// let weekly_projects: = rr.weekly_work("202440").await?;
343    pub async fn weekly_work(&mut self, week: &str, ignore: Option<Vec<String>>) -> Result<HashMap<String, CompactProject>, Box<dyn Error>> {
344
345        let week_list = self.week_hours(week, week).await?;
346        let projects = self.projects_map().await?;
347        let employees = self.employees_map().await?;
348        let customers = self.customers_map().await?;
349
350        let mut weekly_list: HashMap<String, CompactProject> = HashMap::new();
351
352        for entry in week_list {
353            if entry.project.is_some() {
354
355                if entry.employee.is_none() || entry.project.is_none() {
356                    continue;
357                }
358
359
360                if entry.weeks.is_none() {
361                    continue;
362                }
363
364                let weeks = entry.weeks.unwrap();
365                let current_week_hours = weeks.get(week).unwrap_or(&0.0);
366                if *current_week_hours <= 0.0 {
367                    continue;
368                }
369
370                let project = projects.get(entry.project.as_ref().unwrap());
371                if project.is_none() {
372                    continue;
373                }
374                let project = project.unwrap();
375
376
377                if project.customer_id.is_none() {
378                    println!("Customer not found: {:?} = {:?}", project.name, project.customer_id);
379                    continue;
380                }
381                let customer = customers.get(project.customer_id.as_ref().unwrap());
382                if customer.is_none() {
383                    println!("Customer not found: {:?}", project.customer_id);
384                    continue;
385                }
386                let customer = customer.unwrap();
387
388
389                let employee = employees.get(entry.employee.as_ref().unwrap());
390                if employee.is_none() {
391                    continue;
392                }
393                let employee = employee.unwrap();
394
395                if ignore.is_some() && ignore.as_ref().unwrap().contains(&employee.id) {
396                    continue;
397                }
398
399                let key = format!("{} - {}", customer.name, project.name);
400                let key_clone = key.clone();
401                let key_clone_2 = key.clone();
402
403                if let std::collections::hash_map::Entry::Vacant(e) = weekly_list.entry(key) {
404                    let mut set: HashSet<CompactEmployee> = HashSet::new();
405                    let mut customer_image = None;
406                    if customer.image.is_some() {
407                        let image = customer.image.clone().unwrap().replace("/img/customers/", "");
408                        customer_image = Some(format!("{}/{}", CUSTOMER_IMAGE_URL, image));
409                    }
410                    let mut employee_image = None;
411                    if employee.image.is_some() {
412                        let image = employee.image.clone().unwrap();
413                        let image = image.replace("/img/profile/", "");
414                        employee_image = Some(format!("{}/{}", EMPLOYEE_IMAGE_URL, image));
415                    }
416                    let person = CompactEmployee {
417                        name: employee.name.clone(),
418                        image: employee_image,
419                    };
420                    set.insert(person);
421                    let compact_project = CompactProject {
422                        project: key_clone,
423                        employees: set,
424                        image: customer_image,
425                    };
426                    e.insert(compact_project);
427                } else {
428                    let list = weekly_list.get_mut(&key_clone_2).unwrap();
429                    let mut employee_image = None;
430                    if employee.image.is_some() {
431                        let image = employee.image.clone().unwrap();
432                        let image = image.replace("/img/profile/", "");
433                        employee_image = Some(format!("{}/{}", EMPLOYEE_IMAGE_URL, image));
434                    }
435                    let person = CompactEmployee {
436                        name: employee.name.clone(),
437                        image: employee_image,
438                    };
439                    list.employees.insert(person);
440                }
441
442            }
443
444        }
445
446        Ok(weekly_list)
447
448    }
449
450    pub async fn month_missing_income(&mut self, month: &str) -> Result<Vec<missing_hours_data::SimpleEmployee>, Box<dyn Error>> {
451
452        let from_year = &month[0..4].parse::<i32>().expect("Invalid year");
453        let from_month = &month[4..6].parse::<u32>().expect("Invalid month");
454
455        // Create a date for the first day of the month
456
457        let mut end_date;
458        if *from_month < 12 {
459            end_date = NaiveDate::from_ymd(*from_year, *from_month + 1, 1).pred_opt().unwrap();
460        } else {
461            end_date = NaiveDate::from_ymd(*from_year + 1, 1, 1).pred_opt().unwrap();
462        }
463
464        let end_date_str = end_date.format("%Y%m%d").to_string();
465
466        println!("Fetching month missing income for month: {}, end date: {}", month, end_date_str);
467
468        let url = format!("https://app.roughlyright.com/trpc/news.getNews,users.getSelectableUserCompanies,guides.getUserManualIframeLink,fortnoxMisc.getFortnoxStatus,money.getMoneySalaryRows?batch=1&input=%7B%224%22%3A%7B%22month%22%3A%22{}%22%2C%22todayDate%22%3A%22{}%22%7D%7D", month, end_date_str);
469        let response = self.get(&url).await?;
470        if response.status().is_success() {
471            let data: Vec<missing_hours_data::Entry> = response.json().await?;
472
473            let mut result = Vec::new();
474            for entry in data.iter() {
475                if let Some(missing_hours_data::Data::Employees(employees)) = &entry.result.data {
476                    for e in employees {
477
478                        let rate = self.get_user_employment_rate(&e.employee_id).await?;
479
480                        result.push(missing_hours_data::SimpleEmployee {
481                            employment_rate: rate,
482                            employee_id: e.employee_id.clone(),
483                            employee_name: e.employee_name.clone(),
484                            hours_sum_until_today: e.hours_sum_until_today,
485                            until_today_expected: e.until_today_expected,
486                        });
487                    }
488                }
489            }
490
491
492
493            // Save the data
494            return Ok(result);
495        } else {
496            eprintln!("Failed to fetch data: {}", response.status());
497        }
498        Ok(Vec::new())
499    }
500
501    pub async fn get_user_employment_rate(&mut self, employee_id: &str) -> Result<i32, Box<dyn Error>> {
502
503        let mut map: HashMap<String, entities::employee::EmployeeId> = HashMap::new();
504
505        map.insert(
506            "0".to_string(),
507            entities::employee::EmployeeId {
508                employee_id: employee_id.to_string(),
509            },
510        );
511
512        map.insert(
513            "1".to_string(),
514            entities::employee::EmployeeId {
515                employee_id: employee_id.to_string(),
516            },
517        );
518
519
520
521        let vac = serde_json::to_value(map).unwrap();
522        let encoded = utf8_percent_encode(&vac.to_string(), NON_ALPHANUMERIC).to_string();
523
524        let url = format!("https://app.roughlyright.com/trpc/employees.findByIdFull,employees.getEmployeeNetworkCompanies?batch=1&input={}", encoded);
525
526        //println!("Fetching employment rates for employees: {}", url);
527
528        //println!("Fetching employment rates for employees: {}", vac);
529
530        let response = self.get(&url).await?;
531
532        if response.status().is_success() {
533            let result: Vec<entities::employee::Entry> = response.json().await?;
534
535            // println!("Got {} employment rates", result.len());
536
537            for entry in result {
538                if let Some(entities::employee::Data::Employee(emp)) = entry.result.data {
539                    return Ok(emp.employment_rate);
540                }
541            }
542
543
544        } else {
545            eprintln!("Failed to fetch data: {}", response.status());
546        }
547        Ok(0)
548
549    }
550
551
552
553    pub async fn login(&mut self) -> Result<(), Box<dyn Error>> {
554
555        let now: SystemTime = SystemTime::now();
556        let since_epoch = now .duration_since(UNIX_EPOCH).expect("SystemTime before UNIX EPOCH!");
557        let now_secs = since_epoch.as_secs();
558
559        if self.logged_in && (self.last + 60 * 5 > now_secs) {
560            return Ok(());
561        }
562
563        self.last = now_secs;
564
565        // We need to do a pre run, otherwise we dont get the correct cookies
566        let pre_url = "https://app.roughlyright.com/rr2/login";
567        let _pre = self.client.get(pre_url).send().await?;
568
569        let url = "https://app.roughlyright.com/auth/login";
570
571        let mut headers = HeaderMap::new();
572        headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
573        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/x-www-form-urlencoded"));
574        headers.insert(REFERER, HeaderValue::from_static("https://app.roughlyright.com/rr2/login"));
575
576        let body = format!("username={}&password={}", self.username, self.password);
577
578        let response = self.client
579            .post(url)
580            .headers(headers)
581            .body(body)
582            .send()
583        .await?;
584
585        let status = response.status();
586
587        if status.is_success() {
588            self.logged_in = true;
589            return Ok(());
590        } else {
591            eprintln!("Failed to login: {}", status);
592        }
593
594        Ok(())
595
596    }
597}
598