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 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"]; 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 ];
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 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 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 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 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)) - 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 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 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 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 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 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 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