roughly_rs/
lib.rs

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