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