diff --git a/.sqlx/query-0bae9db9d14012e1b614e2f63b3704cac2c8c030fd05637d5d877060f7f59912.json b/.sqlx/query-0bae9db9d14012e1b614e2f63b3704cac2c8c030fd05637d5d877060f7f59912.json new file mode 100644 index 0000000..d309376 --- /dev/null +++ b/.sqlx/query-0bae9db9d14012e1b614e2f63b3704cac2c8c030fd05637d5d877060f7f59912.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, sales_person_id, amount, category, description, date_time, deleted FROM extra_hours WHERE sales_person_id = ? AND strftime('%Y', date_time) = ? AND strftime('%m', date_time) <= ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "sales_person_id", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "amount", + "ordinal": 2, + "type_info": "Float" + }, + { + "name": "category", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "date_time", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "0bae9db9d14012e1b614e2f63b3704cac2c8c030fd05637d5d877060f7f59912" +} diff --git a/.sqlx/query-0e0d06ab641ad595b8626e89d876c92bff41c677b050393eb210fe3c8eb47b7d.json b/.sqlx/query-0e0d06ab641ad595b8626e89d876c92bff41c677b050393eb210fe3c8eb47b7d.json new file mode 100644 index 0000000..1e506cb --- /dev/null +++ b/.sqlx/query-0e0d06ab641ad595b8626e89d876c92bff41c677b050393eb210fe3c8eb47b7d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO working_hours (\n id,\n sales_person_id,\n expected_hours,\n from_calendar_week,\n from_year,\n to_calendar_week,\n to_year,\n created,\n update_process,\n update_version\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "0e0d06ab641ad595b8626e89d876c92bff41c677b050393eb210fe3c8eb47b7d" +} diff --git a/.sqlx/query-3834c9f6fc72c8d6ab28f01f0607e830240201908048364d8c85246f22929b9b.json b/.sqlx/query-3834c9f6fc72c8d6ab28f01f0607e830240201908048364d8c85246f22929b9b.json new file mode 100644 index 0000000..443162f --- /dev/null +++ b/.sqlx/query-3834c9f6fc72c8d6ab28f01f0607e830240201908048364d8c85246f22929b9b.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n sales_person.id as sales_person_id,\n sum((STRFTIME('%H', slot.time_to) + STRFTIME('%M', slot.time_to) / 60.0) - (STRFTIME('%H', slot.time_from) + STRFTIME('%M', slot.time_from))) as hours,\n booking.year\n FROM slot\n INNER JOIN booking ON (booking.slot_id = slot.id AND booking.deleted IS NULL)\n INNER JOIN sales_person ON booking.sales_person_id = sales_person.id\n WHERE booking.year = ?\n AND booking.calendar_week <= ?\n GROUP BY sales_person_id, year\n ", + "describe": { + "columns": [ + { + "name": "sales_person_id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "hours", + "ordinal": 1, + "type_info": "Float" + }, + { + "name": "year", + "ordinal": 2, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "3834c9f6fc72c8d6ab28f01f0607e830240201908048364d8c85246f22929b9b" +} diff --git a/.sqlx/query-53ab5252dae2a4bbbb5f098bcdb9ee7d55b61ef54b82b80310bea94276dfb7b2.json b/.sqlx/query-53ab5252dae2a4bbbb5f098bcdb9ee7d55b61ef54b82b80310bea94276dfb7b2.json new file mode 100644 index 0000000..82074d2 --- /dev/null +++ b/.sqlx/query-53ab5252dae2a4bbbb5f098bcdb9ee7d55b61ef54b82b80310bea94276dfb7b2.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n sales_person_id,\n expected_hours,\n from_calendar_week,\n from_year,\n to_calendar_week,\n to_year,\n created,\n deleted,\n update_version\n FROM\n working_hours\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "sales_person_id", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "expected_hours", + "ordinal": 2, + "type_info": "Float" + }, + { + "name": "from_calendar_week", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "from_year", + "ordinal": 4, + "type_info": "Int64" + }, + { + "name": "to_calendar_week", + "ordinal": 5, + "type_info": "Int64" + }, + { + "name": "to_year", + "ordinal": 6, + "type_info": "Int64" + }, + { + "name": "created", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "deleted", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "update_version", + "ordinal": 9, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "53ab5252dae2a4bbbb5f098bcdb9ee7d55b61ef54b82b80310bea94276dfb7b2" +} diff --git a/.sqlx/query-a49a3abf9187ba8d16e2e6cc0d6898a2acb26fe5639f8b2351854eb21ffa85d5.json b/.sqlx/query-a49a3abf9187ba8d16e2e6cc0d6898a2acb26fe5639f8b2351854eb21ffa85d5.json new file mode 100644 index 0000000..5a5c54a --- /dev/null +++ b/.sqlx/query-a49a3abf9187ba8d16e2e6cc0d6898a2acb26fe5639f8b2351854eb21ffa85d5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE working_hours SET\n deleted = ?,\n update_process = ?\n WHERE\n id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "a49a3abf9187ba8d16e2e6cc0d6898a2acb26fe5639f8b2351854eb21ffa85d5" +} diff --git a/.sqlx/query-a85db22630c8d55b5eb6be027cca9016d77615573d141348015f7c69021f1581.json b/.sqlx/query-a85db22630c8d55b5eb6be027cca9016d77615573d141348015f7c69021f1581.json new file mode 100644 index 0000000..0127263 --- /dev/null +++ b/.sqlx/query-a85db22630c8d55b5eb6be027cca9016d77615573d141348015f7c69021f1581.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n sales_person_id,\n expected_hours,\n from_calendar_week,\n from_year,\n to_calendar_week,\n to_year,\n created,\n deleted,\n update_version\n FROM\n working_hours\n WHERE\n sales_person_id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "sales_person_id", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "expected_hours", + "ordinal": 2, + "type_info": "Float" + }, + { + "name": "from_calendar_week", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "from_year", + "ordinal": 4, + "type_info": "Int64" + }, + { + "name": "to_calendar_week", + "ordinal": 5, + "type_info": "Int64" + }, + { + "name": "to_year", + "ordinal": 6, + "type_info": "Int64" + }, + { + "name": "created", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "deleted", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "update_version", + "ordinal": 9, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "a85db22630c8d55b5eb6be027cca9016d77615573d141348015f7c69021f1581" +} diff --git a/.sqlx/query-c4cb7c4ac52ee81fb6f61e2399b308d2ac6e11103dd2cfb0f8db60d562ff03df.json b/.sqlx/query-c4cb7c4ac52ee81fb6f61e2399b308d2ac6e11103dd2cfb0f8db60d562ff03df.json new file mode 100644 index 0000000..a4482a4 --- /dev/null +++ b/.sqlx/query-c4cb7c4ac52ee81fb6f61e2399b308d2ac6e11103dd2cfb0f8db60d562ff03df.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n sales_person.id as sales_person_id,\n sum((STRFTIME('%H', slot.time_to) + STRFTIME('%M', slot.time_to) / 60.0) - (STRFTIME('%H', slot.time_from) + STRFTIME('%M', slot.time_from))) as hours,\n booking.calendar_week, booking.year, slot.day_of_week\n FROM slot\n INNER JOIN booking ON (booking.slot_id = slot.id AND booking.deleted IS NULL)\n INNER JOIN sales_person ON booking.sales_person_id = sales_person.id\n WHERE sales_person.id = ?\n AND booking.year = ?\n AND booking.calendar_week <= ?\n GROUP BY year, calendar_week, day_of_week\n ", + "describe": { + "columns": [ + { + "name": "sales_person_id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "hours", + "ordinal": 1, + "type_info": "Float" + }, + { + "name": "calendar_week", + "ordinal": 2, + "type_info": "Int64" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "day_of_week", + "ordinal": 4, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + true, + false, + false, + false + ] + }, + "hash": "c4cb7c4ac52ee81fb6f61e2399b308d2ac6e11103dd2cfb0f8db60d562ff03df" +} diff --git a/app/src/main.rs b/app/src/main.rs index f56dbe2..1c60d58 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -1,5 +1,9 @@ use std::sync::Arc; +use dao_impl::{ + extra_hours::ExtraHoursDaoImpl, shiftplan_report::ShiftplanReportDaoImpl, + working_hours::WorkingHoursDaoImpl, +}; use sqlx::SqlitePool; #[cfg(feature = "mock_auth")] @@ -30,6 +34,21 @@ type BookingService = service_impl::booking::BookingServiceImpl< SalesPersonService, SlotService, >; +type ReportingService = service_impl::reporting::ReportingServiceImpl< + dao_impl::extra_hours::ExtraHoursDaoImpl, + dao_impl::shiftplan_report::ShiftplanReportDaoImpl, + dao_impl::working_hours::WorkingHoursDaoImpl, + SalesPersonService, + PermissionService, + ClockService, + UuidService, +>; +type WorkingHoursService = service_impl::working_hours::WorkingHoursServiceImpl< + dao_impl::working_hours::WorkingHoursDaoImpl, + PermissionService, + ClockService, + UuidService, +>; #[derive(Clone)] pub struct RestStateImpl { @@ -38,6 +57,8 @@ pub struct RestStateImpl { slot_service: Arc, sales_person_service: Arc, booking_service: Arc, + reporting_service: Arc, + working_hours_service: Arc, } impl rest::RestStateDef for RestStateImpl { type UserService = UserService; @@ -45,6 +66,8 @@ impl rest::RestStateDef for RestStateImpl { type SlotService = SlotService; type SalesPersonService = SalesPersonService; type BookingService = BookingService; + type ReportingService = ReportingService; + type WorkingHoursService = WorkingHoursService; fn user_service(&self) -> Arc { self.user_service.clone() @@ -61,13 +84,22 @@ impl rest::RestStateDef for RestStateImpl { fn booking_service(&self) -> Arc { self.booking_service.clone() } + fn reporting_service(&self) -> Arc { + self.reporting_service.clone() + } + fn working_hours_service(&self) -> Arc { + self.working_hours_service.clone() + } } impl RestStateImpl { pub fn new(pool: Arc>) -> Self { let permission_dao = dao_impl::PermissionDaoImpl::new(pool.clone()); let slot_dao = dao_impl::slot::SlotDaoImpl::new(pool.clone()); let sales_person_dao = dao_impl::sales_person::SalesPersonDaoImpl::new(pool.clone()); - let booking_dao = dao_impl::booking::BookingDaoImpl::new(pool); + let booking_dao = dao_impl::booking::BookingDaoImpl::new(pool.clone()); + let extra_hours_dao = Arc::new(ExtraHoursDaoImpl::new(pool.clone())); + let shiftplan_report_dao = Arc::new(ShiftplanReportDaoImpl::new(pool.clone())); + let working_hours_dao = Arc::new(WorkingHoursDaoImpl::new(pool.clone())); // Always authenticate with DEVUSER during development. // This is used to test the permission service locally without a login service. @@ -102,17 +134,35 @@ impl RestStateImpl { let booking_service = Arc::new(service_impl::booking::BookingServiceImpl::new( booking_dao.into(), permission_service.clone(), - clock_service, - uuid_service, + clock_service.clone(), + uuid_service.clone(), sales_person_service.clone(), slot_service.clone(), )); + let reporting_service = Arc::new(service_impl::reporting::ReportingServiceImpl::new( + extra_hours_dao, + shiftplan_report_dao, + working_hours_dao.clone(), + sales_person_service.clone(), + permission_service.clone(), + clock_service.clone(), + uuid_service.clone(), + )); + let working_hours_service = + Arc::new(service_impl::working_hours::WorkingHoursServiceImpl::new( + working_hours_dao, + permission_service.clone(), + clock_service, + uuid_service, + )); Self { user_service, permission_service, slot_service, sales_person_service, booking_service, + reporting_service, + working_hours_service, } } } diff --git a/dao/src/extra_hours.rs b/dao/src/extra_hours.rs new file mode 100644 index 0000000..448242c --- /dev/null +++ b/dao/src/extra_hours.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use mockall::automock; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExtraHoursCategoryEntity { + ExtraWork, + Vacation, + SickLeave, + Holiday, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExtraHoursEntity { + pub id: Uuid, + pub sales_person_id: Uuid, + pub amount: f32, + pub category: ExtraHoursCategoryEntity, + pub description: Arc, + pub date_time: time::PrimitiveDateTime, + pub deleted: Option, +} + +#[automock] +#[async_trait] +pub trait ExtraHoursDao { + async fn find_by_sales_person_id_and_year( + &self, + sales_person_id: Uuid, + year: u32, + until_week: u8, + ) -> Result, crate::DaoError>; + async fn create(&self, entity: &ExtraHoursEntity, process: &str) + -> Result<(), crate::DaoError>; + async fn update(&self, entity: &ExtraHoursEntity, process: &str) + -> Result<(), crate::DaoError>; + async fn delete(&self, id: Uuid, process: &str) -> Result<(), crate::DaoError>; +} diff --git a/dao/src/lib.rs b/dao/src/lib.rs index 85b7077..80cd569 100644 --- a/dao/src/lib.rs +++ b/dao/src/lib.rs @@ -5,9 +5,12 @@ use mockall::automock; use thiserror::Error; pub mod booking; +pub mod extra_hours; pub mod permission; pub mod sales_person; +pub mod shiftplan_report; pub mod slot; +pub mod working_hours; pub use permission::MockPermissionDao; pub use permission::PermissionDao; @@ -28,6 +31,12 @@ pub enum DaoError { #[error("Date/Time parse error: {0}")] DateTimeParseError(#[from] time::error::Parse), + + #[error("Date/Time format error: {0}")] + DateTimeFormatError(#[from] time::error::Format), + + #[error("Enum value not found: {0}")] + EnumValueNotFound(Arc), } #[automock] diff --git a/dao/src/shiftplan_report.rs b/dao/src/shiftplan_report.rs new file mode 100644 index 0000000..ef57747 --- /dev/null +++ b/dao/src/shiftplan_report.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; +use mockall::automock; +use std::sync::Arc; +use uuid::Uuid; + +use crate::slot::DayOfWeek; +use crate::DaoError; + +#[derive(Debug, PartialEq)] +pub struct ShiftplanReportEntity { + pub sales_person_id: Uuid, + pub hours: f32, + pub year: u32, + pub calendar_week: u8, + pub day_of_week: DayOfWeek, +} + +pub struct ShiftplanQuickOverviewEntity { + pub sales_person_id: Uuid, + pub hours: f32, + pub year: u32, +} + +#[automock] +#[async_trait] +pub trait ShiftplanReportDao { + /// A report which contains the worked hours of a sales person for each day. + async fn extract_shiftplan_report( + &self, + sales_person_id: Uuid, + year: u32, + until_week: u8, + ) -> Result, DaoError>; + + /// A report which shows the summed up yearly work hours of all sales persons. + async fn extract_quick_shiftplan_report( + &self, + year: u32, + until_week: u8, + ) -> Result, DaoError>; +} diff --git a/dao/src/working_hours.rs b/dao/src/working_hours.rs new file mode 100644 index 0000000..b13e4cc --- /dev/null +++ b/dao/src/working_hours.rs @@ -0,0 +1,32 @@ +use crate::DaoError; +use async_trait::async_trait; +use mockall::automock; +use std::sync::Arc; +use time::PrimitiveDateTime; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkingHoursEntity { + pub id: Uuid, + pub sales_person_id: Uuid, + pub expected_hours: f32, + pub from_calendar_week: u8, + pub from_year: u32, + pub to_calendar_week: u8, + pub to_year: u32, + pub created: PrimitiveDateTime, + pub deleted: Option, + pub version: Uuid, +} + +#[automock] +#[async_trait] +pub trait WorkingHoursDao { + async fn all(&self) -> Result, DaoError>; + async fn find_by_sales_person_id( + &self, + sales_person_id: Uuid, + ) -> Result, DaoError>; + async fn create(&self, entity: &WorkingHoursEntity, process: &str) -> Result<(), DaoError>; + async fn update(&self, entity: &WorkingHoursEntity, process: &str) -> Result<(), DaoError>; +} diff --git a/dao_impl/src/extra_hours.rs b/dao_impl/src/extra_hours.rs new file mode 100644 index 0000000..063d9e3 --- /dev/null +++ b/dao_impl/src/extra_hours.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use crate::ResultDbErrorExt; +use async_trait::async_trait; +use dao::{ + extra_hours::{ExtraHoursCategoryEntity, ExtraHoursDao, ExtraHoursEntity}, + DaoError, +}; +use sqlx::query_as; +use time::{format_description::well_known::Iso8601, PrimitiveDateTime}; +use uuid::Uuid; + +struct ExtraHoursDb { + id: Vec, + sales_person_id: Vec, + amount: f64, + + category: String, + description: Option, + date_time: String, + deleted: Option, +} +impl TryFrom<&ExtraHoursDb> for ExtraHoursEntity { + type Error = DaoError; + + fn try_from(extra_hours: &ExtraHoursDb) -> Result { + Ok(Self { + id: Uuid::from_slice(extra_hours.id.as_ref()).unwrap(), + sales_person_id: Uuid::from_slice(extra_hours.sales_person_id.as_ref()).unwrap(), + amount: extra_hours.amount as f32, + category: match extra_hours.category.as_str() { + "ExtraWork" => ExtraHoursCategoryEntity::ExtraWork, + "Vacation" => ExtraHoursCategoryEntity::Vacation, + "SickLeave" => ExtraHoursCategoryEntity::SickLeave, + "Holiday" => ExtraHoursCategoryEntity::Holiday, + value @ _ => return Err(DaoError::EnumValueNotFound(value.into())), + }, + description: extra_hours + .description + .clone() + .unwrap_or_else(|| String::new()) + .as_str() + .into(), + date_time: PrimitiveDateTime::parse( + extra_hours.date_time.as_str(), + &Iso8601::DATE_TIME, + ) + .unwrap(), + deleted: extra_hours + .deleted + .as_ref() + .map(|deleted| PrimitiveDateTime::parse(deleted, &Iso8601::DATE_TIME)) + .transpose() + .unwrap(), + }) + } +} + +pub struct ExtraHoursDaoImpl { + pub pool: Arc, +} +impl ExtraHoursDaoImpl { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ExtraHoursDao for ExtraHoursDaoImpl { + async fn find_by_sales_person_id_and_year( + &self, + sales_person_id: Uuid, + year: u32, + until_week: u8, + ) -> Result, crate::DaoError> { + let id_vec = sales_person_id.as_bytes().to_vec(); + Ok(query_as!( + ExtraHoursDb, + "SELECT id, sales_person_id, amount, category, description, date_time, deleted FROM extra_hours WHERE sales_person_id = ? AND strftime('%Y', date_time) = ? AND strftime('%m', date_time) <= ?", + id_vec, + year, + until_week, + ).fetch_all(self.pool.as_ref()) + .await + .map_db_error()? + .iter() + .map(ExtraHoursEntity::try_from) + .collect::, _>>()? + .into()) + } + async fn create( + &self, + _entity: &ExtraHoursEntity, + _process: &str, + ) -> Result<(), crate::DaoError> { + unimplemented!() + } + async fn update( + &self, + _entity: &ExtraHoursEntity, + _process: &str, + ) -> Result<(), crate::DaoError> { + unimplemented!() + } + async fn delete(&self, _id: Uuid, _process: &str) -> Result<(), crate::DaoError> { + unimplemented!() + } +} diff --git a/dao_impl/src/lib.rs b/dao_impl/src/lib.rs index f49c154..e8f342b 100644 --- a/dao_impl/src/lib.rs +++ b/dao_impl/src/lib.rs @@ -5,8 +5,11 @@ use dao::{DaoError, PrivilegeEntity}; use sqlx::{query, query_as, SqlitePool}; pub mod booking; +pub mod extra_hours; pub mod sales_person; +pub mod shiftplan_report; pub mod slot; +pub mod working_hours; pub trait ResultDbErrorExt { fn map_db_error(self) -> Result; diff --git a/dao_impl/src/shiftplan_report.rs b/dao_impl/src/shiftplan_report.rs new file mode 100644 index 0000000..45e0e0a --- /dev/null +++ b/dao_impl/src/shiftplan_report.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use crate::ResultDbErrorExt; +use async_trait::async_trait; +use dao::{ + shiftplan_report::{ShiftplanQuickOverviewEntity, ShiftplanReportDao, ShiftplanReportEntity}, + slot::DayOfWeek, + DaoError, +}; +use sqlx::query_as; +use uuid::Uuid; + +pub struct ShiftplanReportDb { + pub sales_person_id: Vec, + pub hours: Option, + pub year: i64, + pub calendar_week: i64, + pub day_of_week: i64, +} +impl TryFrom<&ShiftplanReportDb> for ShiftplanReportEntity { + type Error = DaoError; + fn try_from(entity: &ShiftplanReportDb) -> Result { + Ok(Self { + sales_person_id: Uuid::from_slice(entity.sales_person_id.as_ref())?, + hours: entity.hours.unwrap_or(0.0) as f32, + year: entity.year as u32, + calendar_week: entity.calendar_week as u8, + day_of_week: DayOfWeek::from_number(entity.day_of_week as u8) + .ok_or_else(|| DaoError::InvalidDayOfWeek(entity.day_of_week as u8))?, + }) + } +} + +pub struct ShiftplanQuickOverviewDb { + pub sales_person_id: Vec, + pub hours: Option, + pub year: i64, +} +impl From<&ShiftplanQuickOverviewDb> for ShiftplanQuickOverviewEntity { + fn from(entity: &ShiftplanQuickOverviewDb) -> Self { + Self { + sales_person_id: Uuid::from_slice(entity.sales_person_id.as_ref()).unwrap(), + hours: entity.hours.unwrap_or(0.0) as f32, + year: entity.year as u32, + } + } +} + +pub struct ShiftplanReportDaoImpl { + pub pool: Arc, +} +impl ShiftplanReportDaoImpl { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ShiftplanReportDao for ShiftplanReportDaoImpl { + async fn extract_shiftplan_report( + &self, + sales_person_id: Uuid, + year: u32, + until_week: u8, + ) -> Result, DaoError> { + let sales_person_id_vec = sales_person_id.as_bytes().to_vec(); + Ok(query_as!( + ShiftplanReportDb, + r#" + SELECT + sales_person.id as sales_person_id, + sum((STRFTIME('%H', slot.time_to) + STRFTIME('%M', slot.time_to) / 60.0) - (STRFTIME('%H', slot.time_from) + STRFTIME('%M', slot.time_from))) as hours, + booking.calendar_week, booking.year, slot.day_of_week + FROM slot + INNER JOIN booking ON (booking.slot_id = slot.id AND booking.deleted IS NULL) + INNER JOIN sales_person ON booking.sales_person_id = sales_person.id + WHERE sales_person.id = ? + AND booking.year = ? + AND booking.calendar_week <= ? + GROUP BY year, calendar_week, day_of_week + "#, + sales_person_id_vec, + year, + until_week + ).fetch_all(self.pool.as_ref()) + .await + .map_db_error()? + .iter() + .map(ShiftplanReportEntity::try_from) + .collect::, _>>()? + ) + } + + async fn extract_quick_shiftplan_report( + &self, + year: u32, + until_week: u8, + ) -> Result, DaoError> { + Ok(query_as!( + ShiftplanQuickOverviewDb, + r#" + SELECT + sales_person.id as sales_person_id, + sum((STRFTIME('%H', slot.time_to) + STRFTIME('%M', slot.time_to) / 60.0) - (STRFTIME('%H', slot.time_from) + STRFTIME('%M', slot.time_from))) as hours, + booking.year + FROM slot + INNER JOIN booking ON (booking.slot_id = slot.id AND booking.deleted IS NULL) + INNER JOIN sales_person ON booking.sales_person_id = sales_person.id + WHERE booking.year = ? + AND booking.calendar_week <= ? + GROUP BY sales_person_id, year + "#, + year, + until_week + ).fetch_all(self.pool.as_ref()) + .await + .map_db_error()? + .iter() + .map(ShiftplanQuickOverviewEntity::from) + .collect::>() + ) + } +} diff --git a/dao_impl/src/working_hours.rs b/dao_impl/src/working_hours.rs new file mode 100644 index 0000000..705bc72 --- /dev/null +++ b/dao_impl/src/working_hours.rs @@ -0,0 +1,183 @@ +use std::sync::Arc; + +use crate::ResultDbErrorExt; +use async_trait::async_trait; +use dao::{ + working_hours::{WorkingHoursDao, WorkingHoursEntity}, + DaoError, +}; +use sqlx::{query, query_as}; +use time::{format_description::well_known::Iso8601, PrimitiveDateTime}; +use uuid::Uuid; + +pub struct WorkingHoursDb { + pub id: Vec, + pub sales_person_id: Vec, + pub expected_hours: f64, + pub from_calendar_week: i64, + pub from_year: i64, + pub to_calendar_week: i64, + pub to_year: i64, + pub created: String, + pub deleted: Option, + update_version: Vec, +} + +impl TryFrom<&WorkingHoursDb> for WorkingHoursEntity { + type Error = DaoError; + + fn try_from(working_hours: &WorkingHoursDb) -> Result { + Ok(Self { + id: Uuid::from_slice(working_hours.id.as_ref())?, + sales_person_id: Uuid::from_slice(working_hours.sales_person_id.as_ref()).unwrap(), + expected_hours: working_hours.expected_hours as f32, + from_calendar_week: working_hours.from_calendar_week as u8, + from_year: working_hours.from_year as u32, + to_calendar_week: working_hours.to_calendar_week as u8, + to_year: working_hours.to_year as u32, + created: PrimitiveDateTime::parse(working_hours.created.as_str(), &Iso8601::DATE_TIME)?, + deleted: working_hours + .deleted + .as_ref() + .map(|deleted| PrimitiveDateTime::parse(deleted, &Iso8601::DATE_TIME)) + .transpose()?, + version: Uuid::from_slice(&working_hours.update_version)?, + }) + } +} + +pub struct WorkingHoursDaoImpl { + pub pool: Arc, +} + +impl WorkingHoursDaoImpl { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WorkingHoursDao for WorkingHoursDaoImpl { + async fn all(&self) -> Result, DaoError> { + query_as!( + WorkingHoursDb, + r#" + SELECT + id, + sales_person_id, + expected_hours, + from_calendar_week, + from_year, + to_calendar_week, + to_year, + created, + deleted, + update_version + FROM + working_hours + "# + ) + .fetch_all(self.pool.as_ref()) + .await + .map_db_error()? + .iter() + .map(WorkingHoursEntity::try_from) + .collect::>() + } + + async fn find_by_sales_person_id( + &self, + sales_person_id: Uuid, + ) -> Result, DaoError> { + let id_vec = sales_person_id.as_bytes().to_vec(); + query_as!( + WorkingHoursDb, + r#" + SELECT + id, + sales_person_id, + expected_hours, + from_calendar_week, + from_year, + to_calendar_week, + to_year, + created, + deleted, + update_version + FROM + working_hours + WHERE + sales_person_id = ? + "#, + id_vec + ) + .fetch_all(self.pool.as_ref()) + .await + .map_db_error()? + .iter() + .map(WorkingHoursEntity::try_from) + .collect::>() + } + async fn create(&self, entity: &WorkingHoursEntity, process: &str) -> Result<(), DaoError> { + let id = entity.id.as_bytes().to_vec(); + let sales_person_id = entity.sales_person_id.as_bytes().to_vec(); + let expected_hours = entity.expected_hours as f64; + let from_calendar_week = entity.from_calendar_week as i64; + let from_year = entity.from_year as i64; + let to_calendar_week = entity.to_calendar_week as i64; + let to_year = entity.to_year as i64; + let created = entity.created.format(&Iso8601::DATE_TIME)?; + let version = entity.id.as_bytes().to_vec(); + query!( + r#" + INSERT INTO working_hours ( + id, + sales_person_id, + expected_hours, + from_calendar_week, + from_year, + to_calendar_week, + to_year, + created, + update_process, + update_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + id, + sales_person_id, + expected_hours, + from_calendar_week, + from_year, + to_calendar_week, + to_year, + created, + process, + version, + ) + .execute(self.pool.as_ref()) + .await + .map_db_error()?; + Ok(()) + } + + async fn update(&self, entity: &WorkingHoursEntity, process: &str) -> Result<(), DaoError> { + let id = entity.id.as_bytes().to_vec(); + let deleted = entity.deleted.as_ref().map(|deleted| deleted.to_string()); + query!( + r#" + UPDATE working_hours SET + deleted = ?, + update_process = ? + WHERE + id = ? + "#, + deleted, + process, + id + ) + .execute(self.pool.as_ref()) + .await + .map_db_error()?; + Ok(()) + } +} diff --git a/migrations/20240618125847_paid-sales-persons.sql b/migrations/20240618125847_paid-sales-persons.sql index fe1d7e1..99a12f8 100644 --- a/migrations/20240618125847_paid-sales-persons.sql +++ b/migrations/20240618125847_paid-sales-persons.sql @@ -6,6 +6,7 @@ ADD COLUMN is_paid BOOLEAN DEFAULT 0 NOT NULL; CREATE TABLE working_hours ( id blob(16) NOT NULL PRIMARY KEY, sales_person_id blob(16) NOT NULL, + expected_hours FLOAT NOT NULL, from_calendar_week INTEGER NOT NULL, from_year INTEGER NOT NULL, to_calendar_week INTEGER NOT NULL, @@ -16,15 +17,16 @@ CREATE TABLE working_hours ( update_timestamp TEXT, update_process TEXT NOT NULL, update_version blob(16) NOT NULL, - + FOREIGN KEY (sales_person_id) REFERENCES sales_person(id) ); CREATE TABLE extra_hours ( id blob(16) NOT NULL PRIMARY KEY, sales_person_id blob(16) NOT NULL, - amount INTEGER NOT NULL, + amount FLOAT NOT NULL, category TEXT NOT NULL, + description TEXT, date_time TEXT NOT NULL, created TEXT NOT NULL, deleted TEXT, diff --git a/migrations/20240619085745_default-slots.sql b/migrations/20240619085745_default-slots.sql new file mode 100644 index 0000000..f28a2a5 --- /dev/null +++ b/migrations/20240619085745_default-slots.sql @@ -0,0 +1,64 @@ +-- Add migration script here + +INSERT INTO slot VALUES(X'08ab25881f9e4d03b70fd6ecd9747e53',1,'09:00:00.0','10:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'5a4be30de0f743789a2da07d11b4f14d'); +INSERT INTO slot VALUES(X'46fe075a4e9b49d28d138a3ce368ba3e',1,'10:00:00.0','11:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'52163187643344519943bac153a5d33f'); +INSERT INTO slot VALUES(X'c34ad85a7bfb4a35b98ecd858eb84ea9',1,'11:00:00.0','12:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'9b3ee57e71eb46eea33eb57ae6abd212'); +INSERT INTO slot VALUES(X'4b6808fd1955436aaee6afaad87832f8',1,'12:00:00.0','13:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'3f719589b0474c5bb575af52290ac24f'); +INSERT INTO slot VALUES(X'58ee28a028b44debae3cb909e14bbc82',1,'13:00:00.0','14:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'3e7351fdfc9049649b69c43dfb51d860'); +INSERT INTO slot VALUES(X'8ef857dea2a7429aad765a898e01948b',1,'14:00:00.0','15:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'b3dc7f132a354ad2b719366b507691ac'); +INSERT INTO slot VALUES(X'0824b95ae1a54ef98150cc7c18172c9f',1,'15:00:00.0','16:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'023a5ad0fe0b4e55bca61bb6634ac1db'); +INSERT INTO slot VALUES(X'ae4fa09ca6b94234b2fad2daa993fee0',1,'16:00:00.0','17:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'2109af124c5742e6b1076559a252e6fe'); +INSERT INTO slot VALUES(X'70695390f5ec4f7b83ca1b4a68bb8a14',1,'17:00:00.0','18:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'1644962cc6fe455f8e6df37126ce67c1'); +INSERT INTO slot VALUES(X'8b538e961f214935ac4c4c045c989473',1,'18:00:00.0','19:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'726b7c1c741246efa7e36114af3c8db5'); +INSERT INTO slot VALUES(X'f0037047d29e4579ae0d1d743818bddc',1,'19:00:00.0','19:30:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'ea91542af18a4a75bc5d1c04ab53e596'); +INSERT INTO slot VALUES(X'96465be6f1664ba68c3630858a7e4207',2,'09:00:00.0','10:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'6a6a7e107738466aacdb7ca4e4f1ab8a'); +INSERT INTO slot VALUES(X'6851abd6b9134ee7b311fbb9502a1d05',3,'09:00:00.0','10:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'0d379974b71d42029ad4f174461d70c4'); +INSERT INTO slot VALUES(X'54d84cb4622b424ba1f6cd3b09910db3',4,'09:00:00.0','10:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'64eb8bcbd88648dc888132e3e5486967'); +INSERT INTO slot VALUES(X'012f7b1eb86a479497ad6d12bf502e1a',5,'09:00:00.0','10:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'72a49fcb3ce9496f85a6be39e067a5ab'); +INSERT INTO slot VALUES(X'a847e67a1a9a45339710987a022dac4b',6,'09:00:00.0','10:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'a3bf1b1fecd04294991ef3f432425991'); +INSERT INTO slot VALUES(X'7cf59fc6d6fe4aba83a6a70044583eeb',6,'11:00:00.0','12:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'db82bcb229c74cec828a2f9c2fef761b'); +INSERT INTO slot VALUES(X'd53ec0272aef4b41823bf500932d6283',6,'12:00:00.0','13:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'76dc52e48cd4429c8c8f48ad9e3643bb'); +INSERT INTO slot VALUES(X'70fb58cf2f674f099ac3dddb8ee5cbc2',6,'13:00:00.0','14:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'197021b8f291497282d9c1f597b5ffeb'); +INSERT INTO slot VALUES(X'c9ff96df42464a18beac02ae7790fb37',6,'14:00:00.0','15:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'd5f2bc5d014a4387afc79085a6bd3b01'); +INSERT INTO slot VALUES(X'6a2537e618f94658abea65504da38325',6,'15:00:00.0','16:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'60b24f0a3dc64634bfaf0e86912abe6b'); +INSERT INTO slot VALUES(X'c4cfc6f8f2c14b7596392bd878c3fe2a',5,'10:00:00.0','11:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'a58e50f7996a47dbbb52d345ce77b02a'); +INSERT INTO slot VALUES(X'194f4a66275f4529a65db8995f225afb',5,'11:00:00.0','12:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'321cb7911dab4d189d371317d853e5ab'); +INSERT INTO slot VALUES(X'd59417fe72244e189a21f28c4e7d52cc',5,'12:00:00.0','13:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'b531a3180b8948f8b2d134b2ceacf6ac'); +INSERT INTO slot VALUES(X'08d74382d32c404ca5f796c109f00787',5,'13:00:00.0','14:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'578a705492c04327afbde317de6624a0'); +INSERT INTO slot VALUES(X'bbea533beaab463bafe80e40a69c8393',5,'14:00:00.0','15:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'c38361f42db9491ca62858ea0beddb50'); +INSERT INTO slot VALUES(X'35f629542e3942b081bd0b1b214d5b59',5,'15:00:00.0','16:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'76edd2f0214d4ffeb0e0d32aa668f248'); +INSERT INTO slot VALUES(X'8720cc55e3954470be7b977c1e31719f',5,'16:00:00.0','17:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'cc5f682b46b84c839bd5b62269422637'); +INSERT INTO slot VALUES(X'345968cebe6c477f9650d81bd28e226a',5,'17:00:00.0','18:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'95feede9d4ab4d0ca5c14a564b4ee4f4'); +INSERT INTO slot VALUES(X'3afe8ab64c034c71bfe8b95ea1872d2b',5,'18:00:00.0','19:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'60bf3c7046334ce7b6ccde20f2768c25'); +INSERT INTO slot VALUES(X'68f62933dead423d8e11c19911a3bc57',5,'19:00:00.0','19:30:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'c9f4bf0f0c07416391f7893da531009b'); +INSERT INTO slot VALUES(X'e38a96be82fc4e8890609bf1326ed377',4,'10:00:00.0','11:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'ed81821119be4dffb1266e29f5dcf5d6'); +INSERT INTO slot VALUES(X'96d0aad4dc334ceb87c4ac233e0c7c88',6,'10:00:00.0','11:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'7b5c1d9ec707472abf25a9f224ee41d1'); +INSERT INTO slot VALUES(X'b07a606213204cee89109c3140f676b8',4,'11:00:00.0','12:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'bded643784b444118ea427821be72f0e'); +INSERT INTO slot VALUES(X'de904c71fc3f4ec2a5e8104e99754fdc',4,'12:00:00.0','13:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'3a0375b7f97c47198db697de983de9fc'); +INSERT INTO slot VALUES(X'87f3e28c0da1443593835f3aed078c1c',4,'13:00:00.0','14:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'bac09f2e9e174c4284585f72fd09f343'); +INSERT INTO slot VALUES(X'81df50ac841e46f18fc7b929c4a83d58',4,'14:00:00.0','15:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'53a09581911446a5b2a75a10db9e34e2'); +INSERT INTO slot VALUES(X'c90300ba0f3446099eba97244267cc2c',4,'15:00:00.0','16:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'0e554ef38f3a4306b2318bd8237ef595'); +INSERT INTO slot VALUES(X'96aad0153a794bc4a90195b7ac8b67d5',4,'16:00:00.0','17:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'bb314bf0d8b64960b8a91f72717de972'); +INSERT INTO slot VALUES(X'844d9ca424c0410798a67518540b1bb7',4,'17:00:00.0','18:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'6040220400a644ef8fbe867e92363a86'); +INSERT INTO slot VALUES(X'62d008388f274da2a6ba0cbf9b2da9f1',4,'18:00:00.0','19:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'46bc35a8fa45423cb8f5dc44d639f95e'); +INSERT INTO slot VALUES(X'06525073a2e1474faf43d013654f1219',4,'19:00:00.0','19:30:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'63affcfa76f8496ba3e93441d085f607'); +INSERT INTO slot VALUES(X'0141437ecfed44dab8e921fd24b3f960',3,'10:00:00.0','11:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'c5cee643e99e421493eda930b33a9e12'); +INSERT INTO slot VALUES(X'fd190d07a228449d96778454c4b7d4e7',3,'11:00:00.0','12:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'60b766ff2229400d95eebc8c101b509d'); +INSERT INTO slot VALUES(X'c254812651734677b7fedca933405920',3,'12:00:00.0','13:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'32a5c06431a046b09fc3a6b6f701dc7f'); +INSERT INTO slot VALUES(X'68efe36f94084de0b409655239a6488e',3,'13:00:00.0','14:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'2781e182e09e49bd88fa1531fbe5ff3a'); +INSERT INTO slot VALUES(X'4ea95b27fe1c402aa5a6e45141fc4a9b',3,'14:00:00.0','15:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'ac7b30f3379d4d7696731f55da78df11'); +INSERT INTO slot VALUES(X'a001bb86efab4f2fbe817cbb065be1da',3,'15:00:00.0','16:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'b912efd3b92c4535962b99d95ab6fee2'); +INSERT INTO slot VALUES(X'af02ed2b3fee48928db7b58c11df0d75',3,'16:00:00.0','17:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'5d42c8a871c54cd79cc56172d7c5deb2'); +INSERT INTO slot VALUES(X'69f19afd18e044d0b75776f2f8704df1',3,'17:00:00.0','18:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'5bc60267cc5149ae9cbda0f89cd43b51'); +INSERT INTO slot VALUES(X'af973478b5c54362863385b39967c8e0',3,'18:00:00.0','19:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'5c4d18709a9b4f8097e16511f96f7415'); +INSERT INTO slot VALUES(X'72b3086cb7db4a7cbe21c0a2b87be249',3,'19:00:00.0','19:30:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'b8e08ba8c6a047db8954c6e63ef33d35'); +INSERT INTO slot VALUES(X'16b777bec4434e69a255665adcdd2977',2,'10:00:00.0','11:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'1d8947cf33f246159fe61c2472ecf20a'); +INSERT INTO slot VALUES(X'47fd87b94aab45848ca8fe3ace7e1469',2,'11:00:00.0','12:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'4c675d9d0ea642f6b75d102a2dfccf2b'); +INSERT INTO slot VALUES(X'81d4f730bc6941338788a078d4f3fc33',2,'12:00:00.0','13:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'446fd4ecfd354f498037ad7a014245ca'); +INSERT INTO slot VALUES(X'ea2ec8d628184d50b820de050f291efc',2,'13:00:00.0','14:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'55ce7330b09545f3b640a163552cd421'); +INSERT INTO slot VALUES(X'1cabdbf2c66a413895fa24c157e68cff',2,'14:00:00.0','15:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'f330de0dcb2e4fccb57919aaa5b2e8af'); +INSERT INTO slot VALUES(X'197f64b7ff294b3ea9fcea6aa3c2335d',2,'15:00:00.0','16:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'484f6a08b70042debab12ae44cca3bc5'); +INSERT INTO slot VALUES(X'959a15a2e77f4f8085f84016befdf588',2,'16:00:00.0','17:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'0e35c7310b734d508cfee3774f71716e'); +INSERT INTO slot VALUES(X'4a9b6cdccdd94e14a221af4f07149e03',2,'17:00:00.0','18:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'0f36127f96e94dd0918edc11d3aa3fe2'); +INSERT INTO slot VALUES(X'a90a970739b94f439a0a46f9134b3aa3',2,'18:00:00.0','19:00:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'bc9fe5894e3b4327afd2831e0842c9c7'); +INSERT INTO slot VALUES(X'bf5245e03bef43b580c1fe245de4a8a9',2,'19:00:00.0','19:30:00.0','2020-01-01',NULL,NULL,NULL,'slot-service',X'b08db53d35844c9ea36f7e63389dd727'); \ No newline at end of file diff --git a/rest-types/src/lib.rs b/rest-types/src/lib.rs index 3fc77cc..b8895e9 100644 --- a/rest-types/src/lib.rs +++ b/rest-types/src/lib.rs @@ -221,3 +221,191 @@ impl From<&SlotTO> for service::slot::Slot { } } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct ShortEmployeeReportTO { + pub sales_person: SalesPersonTO, + pub balance_hours: f32, +} + +impl From<&service::reporting::ShortEmployeeReport> for ShortEmployeeReportTO { + fn from(report: &service::reporting::ShortEmployeeReport) -> Self { + Self { + sales_person: SalesPersonTO::from(report.sales_person.as_ref()), + balance_hours: report.balance_hours, + } + } +} +#[derive(Debug, Serialize, Deserialize)] +pub enum ExtraHoursCategoryTO { + Shiftplan, + ExtraWork, + Vacation, + SickLeave, + Holiday, +} +impl From<&service::reporting::ExtraHoursCategory> for ExtraHoursCategoryTO { + fn from(category: &service::reporting::ExtraHoursCategory) -> Self { + match category { + service::reporting::ExtraHoursCategory::Shiftplan => Self::Shiftplan, + service::reporting::ExtraHoursCategory::ExtraWork => Self::ExtraWork, + service::reporting::ExtraHoursCategory::Vacation => Self::Vacation, + service::reporting::ExtraHoursCategory::SickLeave => Self::SickLeave, + service::reporting::ExtraHoursCategory::Holiday => Self::Holiday, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkingHoursDayTO { + pub date: time::Date, + pub hours: f32, + pub category: ExtraHoursCategoryTO, +} +impl From<&service::reporting::WorkingHoursDay> for WorkingHoursDayTO { + fn from(day: &service::reporting::WorkingHoursDay) -> Self { + Self { + date: day.date, + hours: day.hours, + category: (&day.category).into(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkingHoursReportTO { + pub from: time::Date, + pub to: time::Date, + pub expected_hours: f32, + pub overall_hours: f32, + pub balance: f32, + + pub shiftplan_hours: f32, + pub extra_work_hours: f32, + pub vacation_hours: f32, + pub sick_leave_hours: f32, + pub holiday_hours: f32, + + pub days: Arc<[WorkingHoursDayTO]>, +} + +impl From<&service::reporting::WorkingHours> for WorkingHoursReportTO { + fn from(hours: &service::reporting::WorkingHours) -> Self { + Self { + from: hours.from, + to: hours.to, + expected_hours: hours.expected_hours, + overall_hours: hours.overall_hours, + balance: hours.balance, + shiftplan_hours: hours.shiftplan_hours, + extra_work_hours: hours.extra_work_hours, + vacation_hours: hours.vacation_hours, + sick_leave_hours: hours.sick_leave_hours, + holiday_hours: hours.holiday_hours, + days: hours + .days + .iter() + .map(|day| WorkingHoursDayTO::from(day)) + .collect::>() + .into(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EmployeeReportTO { + pub sales_person: Arc, + + pub balance_hours: f32, + pub overall_hours: f32, + pub expected_hours: f32, + + pub shiftplan_hours: f32, + pub extra_work_hours: f32, + pub vacation_hours: f32, + pub sick_leave_hours: f32, + pub holiday_hours: f32, + + pub by_week: Arc<[WorkingHoursReportTO]>, + pub by_month: Arc<[WorkingHoursReportTO]>, +} + +impl From<&service::reporting::EmployeeReport> for EmployeeReportTO { + fn from(report: &service::reporting::EmployeeReport) -> Self { + Self { + sales_person: Arc::new(SalesPersonTO::from(report.sales_person.as_ref())), + balance_hours: report.balance_hours, + overall_hours: report.overall_hours, + expected_hours: report.expected_hours, + shiftplan_hours: report.shiftplan_hours, + extra_work_hours: report.extra_work_hours, + vacation_hours: report.vacation_hours, + sick_leave_hours: report.sick_leave_hours, + holiday_hours: report.holiday_hours, + by_week: report + .by_week + .iter() + .map(|hours| WorkingHoursReportTO::from(hours)) + .collect::>() + .into(), + by_month: report + .by_month + .iter() + .map(|hours| WorkingHoursReportTO::from(hours)) + .collect::>() + .into(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkingHoursTO { + #[serde(default)] + pub id: Uuid, + pub sales_person_id: Uuid, + pub expected_hours: f32, + pub from_calendar_week: u8, + pub from_year: u32, + pub to_calendar_week: u8, + pub to_year: u32, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub deleted: Option, + #[serde(rename = "$version")] + #[serde(default)] + pub version: Uuid, +} +impl From<&service::working_hours::WorkingHours> for WorkingHoursTO { + fn from(working_hours: &service::working_hours::WorkingHours) -> Self { + Self { + id: working_hours.id, + sales_person_id: working_hours.sales_person_id, + expected_hours: working_hours.expected_hours, + from_calendar_week: working_hours.from_calendar_week, + from_year: working_hours.from_year, + to_calendar_week: working_hours.to_calendar_week, + to_year: working_hours.to_year, + created: working_hours.created, + deleted: working_hours.deleted, + version: working_hours.version, + } + } +} + +impl From<&WorkingHoursTO> for service::working_hours::WorkingHours { + fn from(working_hours: &WorkingHoursTO) -> Self { + Self { + id: working_hours.id, + sales_person_id: working_hours.sales_person_id, + expected_hours: working_hours.expected_hours, + from_calendar_week: working_hours.from_calendar_week, + from_year: working_hours.from_year, + to_calendar_week: working_hours.to_calendar_week, + to_year: working_hours.to_year, + created: working_hours.created, + deleted: working_hours.deleted, + version: working_hours.version, + } + } +} diff --git a/rest/src/lib.rs b/rest/src/lib.rs index 86f2fed..76427b3 100644 --- a/rest/src/lib.rs +++ b/rest/src/lib.rs @@ -2,8 +2,10 @@ use std::{convert::Infallible, sync::Arc}; mod booking; mod permission; +mod report; mod sales_person; mod slot; +mod working_hours; #[cfg(feature = "oidc")] use axum::error_handling::HandleErrorLayer; @@ -199,6 +201,12 @@ fn error_handler(result: Result) -> Response { .body(Body::new(err.to_string())) .unwrap() } + Err(RestError::ServiceError(err @ service::ServiceError::TimeComponentRangeError(_))) => { + Response::builder() + .status(500) + .body(Body::new(err.to_string())) + .unwrap() + } Err(RestError::ServiceError(ServiceError::InternalError)) => Response::builder() .status(500) .body(Body::new("Internal server error".to_string())) @@ -215,12 +223,22 @@ pub trait RestStateDef: Clone + Send + Sync + 'static { + Sync + 'static; type BookingService: service::booking::BookingService + Send + Sync + 'static; + type ReportingService: service::reporting::ReportingService + + Send + + Sync + + 'static; + type WorkingHoursService: service::working_hours::WorkingHoursService + + Send + + Sync + + 'static; fn user_service(&self) -> Arc; fn permission_service(&self) -> Arc; fn slot_service(&self) -> Arc; fn sales_person_service(&self) -> Arc; fn booking_service(&self) -> Arc; + fn reporting_service(&self) -> Arc; + fn working_hours_service(&self) -> Arc; } pub struct OidcConfig { @@ -324,6 +342,8 @@ pub async fn start_server(rest_state: RestState) { .nest("/slot", slot::generate_route()) .nest("/sales-person", sales_person::generate_route()) .nest("/booking", booking::generate_route()) + .nest("/report", report::generate_route()) + .nest("/working-hours", working_hours::generate_route()) .with_state(rest_state) .layer(middleware::from_fn(context_extractor)); diff --git a/rest/src/report.rs b/rest/src/report.rs new file mode 100644 index 0000000..a34c040 --- /dev/null +++ b/rest/src/report.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Path, Query, State}, + response::Response, + routing::get, + Extension, Router, +}; +use rest_types::{EmployeeReportTO, ShortEmployeeReportTO}; +use serde::Deserialize; +use service::reporting::ReportingService; +use uuid::Uuid; + +use crate::{error_handler, Context, RestStateDef}; + +pub fn generate_route() -> Router { + Router::new() + .route("/", get(get_short_report_for_all::)) + .route("/:id", get(get_report::)) +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ReportRequest { + year: u32, + until_week: u8, +} + +pub async fn get_short_report_for_all( + rest_state: State, + query: Query, + Extension(context): Extension, +) -> Response { + error_handler( + (async { + let short_report: Arc<[ShortEmployeeReportTO]> = rest_state + .reporting_service() + .get_reports_for_all_employees(query.year, query.until_week, context.into()) + .await? + .iter() + .map(ShortEmployeeReportTO::from) + .collect(); + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&short_report).unwrap())) + .unwrap()) + }) + .await, + ) +} + +pub async fn get_report( + rest_state: State, + query: Query, + Path(sales_person_id): Path, + Extension(context): Extension, +) -> Response { + error_handler( + (async { + let report: EmployeeReportTO = (&rest_state + .reporting_service() + .get_report_for_employee( + &sales_person_id, + query.year, + query.until_week, + context.into(), + ) + .await?) + .into(); + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&report).unwrap())) + .unwrap()) + }) + .await, + ) +} diff --git a/rest/src/working_hours.rs b/rest/src/working_hours.rs new file mode 100644 index 0000000..31d4733 --- /dev/null +++ b/rest/src/working_hours.rs @@ -0,0 +1,34 @@ +use axum::{ + body::Body, extract::State, response::Response, routing::post, Extension, Json, Router, +}; +use rest_types::WorkingHoursTO; + +use service::working_hours::WorkingHoursService; + +use crate::{error_handler, Context, RestStateDef}; + +pub fn generate_route() -> Router { + Router::new().route("/", post(create_working_hours::)) +} + +pub async fn create_working_hours( + rest_state: State, + Extension(context): Extension, + Json(working_hours): Json, +) -> Response { + error_handler( + (async { + let working_hours = WorkingHoursTO::from( + &rest_state + .working_hours_service() + .create(&(&working_hours).into(), context.into()) + .await?, + ); + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&working_hours).unwrap())) + .unwrap()) + }) + .await, + ) +} diff --git a/service/src/datetime_utils.rs b/service/src/datetime_utils.rs new file mode 100644 index 0000000..074af6a --- /dev/null +++ b/service/src/datetime_utils.rs @@ -0,0 +1,68 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use time::{error::ComponentRange, Date}; + +use crate::slot::DayOfWeek; + +pub fn calenar_week_to_date( + year: i32, + week: u8, + day_of_week: DayOfWeek, +) -> Result { + Date::from_iso_week_date(year, week, day_of_week.into()) +} + +pub fn date_to_calendar_week(date: Date) -> (i32, u8, DayOfWeek) { + let year = date.year(); + let week = date.iso_week(); + let day_of_week = DayOfWeek::from(date.weekday()); + + (year, week, day_of_week) +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, PartialOrd, Ord)] +pub struct CalendarWeek { + pub year: i32, + pub week: u8, +} + +pub trait AsCalendarWeek { + fn as_date(&self) -> CalendarWeek; +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, PartialOrd, Ord)] +pub struct Month { + pub year: i32, + pub month: u8, +} +pub trait AsMonth { + fn as_date(&self) -> Month; +} + +pub fn group_by_calendar_week( + items: &[T], +) -> BTreeMap> { + let mut map = BTreeMap::new(); + for item in items { + let calendar_week = item.as_date(); + map.entry(calendar_week) + .or_insert_with(Vec::new) + .push(item.to_owned()); + } + map.into_iter() + .map(|(calendar_week, items)| (calendar_week, items.into())) + .collect() +} + +pub fn group_by_month(items: &[T]) -> BTreeMap> { + let mut map = BTreeMap::new(); + for item in items { + let month = item.as_date(); + map.entry(month) + .or_insert_with(Vec::new) + .push(item.to_owned()); + } + map.into_iter() + .map(|(month, items)| (month, items.into())) + .collect() +} diff --git a/service/src/extra_hours.rs b/service/src/extra_hours.rs new file mode 100644 index 0000000..d431d6f --- /dev/null +++ b/service/src/extra_hours.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use mockall::automock; +use uuid::Uuid; + +use dao::DaoError; + +#[derive(Clone, Debug, PartialEq)] +pub enum ExtraHoursCategory { + ExtraWork, + Vacation, + SickLeave, + Holiday, +} +impl From<&dao::extra_hours::ExtraHoursCategoryEntity> for ExtraHoursCategory { + fn from(category: &dao::extra_hours::ExtraHoursCategoryEntity) -> Self { + match category { + dao::extra_hours::ExtraHoursCategoryEntity::ExtraWork => Self::ExtraWork, + dao::extra_hours::ExtraHoursCategoryEntity::Vacation => Self::Vacation, + dao::extra_hours::ExtraHoursCategoryEntity::SickLeave => Self::SickLeave, + dao::extra_hours::ExtraHoursCategoryEntity::Holiday => Self::Holiday, + } + } +} +impl From<&ExtraHoursCategory> for dao::extra_hours::ExtraHoursCategoryEntity { + fn from(category: &ExtraHoursCategory) -> Self { + match category { + ExtraHoursCategory::ExtraWork => Self::ExtraWork, + ExtraHoursCategory::Vacation => Self::Vacation, + ExtraHoursCategory::SickLeave => Self::SickLeave, + ExtraHoursCategory::Holiday => Self::Holiday, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExtraHours { + pub id: Uuid, + pub sales_person_id: Uuid, + pub amount: f32, + pub category: ExtraHoursCategory, + pub description: Arc, + pub date_time: time::PrimitiveDateTime, + pub deleted: Option, +} +impl From<&dao::extra_hours::ExtraHoursEntity> for ExtraHours { + fn from(extra_hours: &dao::extra_hours::ExtraHoursEntity) -> Self { + Self { + id: extra_hours.id, + sales_person_id: extra_hours.sales_person_id, + amount: extra_hours.amount, + category: (&extra_hours.category).into(), + description: extra_hours.description.clone(), + date_time: extra_hours.date_time, + deleted: extra_hours.deleted, + } + } +} +impl From<&ExtraHours> for dao::extra_hours::ExtraHoursEntity { + fn from(extra_hours: &ExtraHours) -> Self { + Self { + id: extra_hours.id, + sales_person_id: extra_hours.sales_person_id, + amount: extra_hours.amount, + category: (&extra_hours.category).into(), + description: extra_hours.description.clone(), + date_time: extra_hours.date_time, + deleted: extra_hours.deleted, + } + } +} + +#[automock] +#[async_trait] +pub trait ExtraHoursService { + fn find_by_sales_person_id_and_year( + &self, + sales_person_id: Uuid, + year: u32, + until_week: u8, + ) -> Result, DaoError>; + fn create(&self, entity: &ExtraHours, process: &str) -> Result<(), DaoError>; + fn update(&self, entity: &ExtraHours, process: &str) -> Result<(), DaoError>; + fn delete(&self, id: Uuid, process: &str) -> Result<(), DaoError>; +} diff --git a/service/src/lib.rs b/service/src/lib.rs index cbcca98..46eb0d1 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -6,11 +6,15 @@ use uuid::Uuid; pub mod booking; pub mod clock; +pub mod datetime_utils; +pub mod extra_hours; pub mod permission; +pub mod reporting; pub mod sales_person; pub mod slot; pub mod user_service; pub mod uuid_service; +pub mod working_hours; pub use permission::MockPermissionService; pub use permission::PermissionService; @@ -64,6 +68,9 @@ pub enum ServiceError { #[error("Date order wrong. {0} must is not smaller or equal to {1}")] DateOrderWrong(Date, Date), + #[error("Time component range error: {0}")] + TimeComponentRangeError(#[from] time::error::ComponentRange), + #[error("Internal error")] InternalError, } diff --git a/service/src/reporting.rs b/service/src/reporting.rs new file mode 100644 index 0000000..fedf882 --- /dev/null +++ b/service/src/reporting.rs @@ -0,0 +1,99 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use async_trait::async_trait; +use mockall::automock; +use uuid::Uuid; + +use crate::permission::Authentication; +use crate::sales_person::SalesPerson; +use crate::ServiceError; + +#[derive(Clone, Debug, PartialEq)] +pub enum ExtraHoursCategory { + Shiftplan, + ExtraWork, + Vacation, + SickLeave, + Holiday, +} + +impl From<&dao::extra_hours::ExtraHoursCategoryEntity> for ExtraHoursCategory { + fn from(category: &dao::extra_hours::ExtraHoursCategoryEntity) -> Self { + match category { + dao::extra_hours::ExtraHoursCategoryEntity::ExtraWork => Self::ExtraWork, + dao::extra_hours::ExtraHoursCategoryEntity::Vacation => Self::Vacation, + dao::extra_hours::ExtraHoursCategoryEntity::SickLeave => Self::SickLeave, + dao::extra_hours::ExtraHoursCategoryEntity::Holiday => Self::Holiday, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkingHoursDay { + pub date: time::Date, + pub hours: f32, + pub category: ExtraHoursCategory, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkingHours { + pub from: time::Date, + pub to: time::Date, + pub expected_hours: f32, + pub overall_hours: f32, + pub balance: f32, + + pub shiftplan_hours: f32, + pub extra_work_hours: f32, + pub vacation_hours: f32, + pub sick_leave_hours: f32, + pub holiday_hours: f32, + + pub days: Arc<[WorkingHoursDay]>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ShortEmployeeReport { + pub sales_person: Arc, + pub balance_hours: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EmployeeReport { + pub sales_person: Arc, + + pub balance_hours: f32, + pub overall_hours: f32, + pub expected_hours: f32, + + pub shiftplan_hours: f32, + pub extra_work_hours: f32, + pub vacation_hours: f32, + pub sick_leave_hours: f32, + pub holiday_hours: f32, + + pub by_week: Arc<[WorkingHours]>, + pub by_month: Arc<[WorkingHours]>, +} + +#[automock(type Context=();)] +#[async_trait] +pub trait ReportingService { + type Context: Clone + Debug + PartialEq + Eq + Send + Sync + 'static; + + async fn get_reports_for_all_employees( + &self, + years: u32, + until_week: u8, + context: Authentication, + ) -> Result, ServiceError>; + + async fn get_report_for_employee( + &self, + sales_person_id: &Uuid, + years: u32, + until_week: u8, + context: Authentication, + ) -> Result; +} diff --git a/service/src/slot.rs b/service/src/slot.rs index f4a0787..aa8af40 100644 --- a/service/src/slot.rs +++ b/service/src/slot.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use mockall::automock; use std::fmt::Debug; use std::sync::Arc; +use time::Weekday; use uuid::Uuid; use crate::permission::Authentication; @@ -43,6 +44,32 @@ impl From for dao::slot::DayOfWeek { } } } +impl From for DayOfWeek { + fn from(weekday: Weekday) -> Self { + match weekday { + Weekday::Monday => Self::Monday, + Weekday::Tuesday => Self::Tuesday, + Weekday::Wednesday => Self::Wednesday, + Weekday::Thursday => Self::Thursday, + Weekday::Friday => Self::Friday, + Weekday::Saturday => Self::Saturday, + Weekday::Sunday => Self::Sunday, + } + } +} +impl From for Weekday { + fn from(day_of_week: DayOfWeek) -> Self { + match day_of_week { + DayOfWeek::Monday => Self::Monday, + DayOfWeek::Tuesday => Self::Tuesday, + DayOfWeek::Wednesday => Self::Wednesday, + DayOfWeek::Thursday => Self::Thursday, + DayOfWeek::Friday => Self::Friday, + DayOfWeek::Saturday => Self::Saturday, + DayOfWeek::Sunday => Self::Sunday, + } + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct Slot { diff --git a/service/src/working_hours.rs b/service/src/working_hours.rs new file mode 100644 index 0000000..295900e --- /dev/null +++ b/service/src/working_hours.rs @@ -0,0 +1,85 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use async_trait::async_trait; +use mockall::automock; +use time::PrimitiveDateTime; +use uuid::Uuid; + +use crate::permission::Authentication; +use crate::ServiceError; + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkingHours { + pub id: Uuid, + pub sales_person_id: Uuid, + pub expected_hours: f32, + pub from_calendar_week: u8, + pub from_year: u32, + pub to_calendar_week: u8, + pub to_year: u32, + pub created: Option, + pub deleted: Option, + pub version: Uuid, +} +impl From<&dao::working_hours::WorkingHoursEntity> for WorkingHours { + fn from(working_hours: &dao::working_hours::WorkingHoursEntity) -> Self { + Self { + id: working_hours.id, + sales_person_id: working_hours.sales_person_id, + expected_hours: working_hours.expected_hours, + from_calendar_week: working_hours.from_calendar_week, + from_year: working_hours.from_year, + to_calendar_week: working_hours.to_calendar_week, + to_year: working_hours.to_year, + created: Some(working_hours.created), + deleted: working_hours.deleted, + version: working_hours.version, + } + } +} +impl TryFrom<&WorkingHours> for dao::working_hours::WorkingHoursEntity { + type Error = ServiceError; + fn try_from(working_hours: &WorkingHours) -> Result { + Ok(Self { + id: working_hours.id, + sales_person_id: working_hours.sales_person_id, + expected_hours: working_hours.expected_hours, + from_calendar_week: working_hours.from_calendar_week, + from_year: working_hours.from_year, + to_calendar_week: working_hours.to_calendar_week, + to_year: working_hours.to_year, + created: working_hours + .created + .ok_or_else(|| ServiceError::InternalError)?, + deleted: working_hours.deleted, + version: working_hours.version, + }) + } +} + +#[automock(type Context=();)] +#[async_trait] +pub trait WorkingHoursService { + type Context: Clone + Debug + PartialEq + Eq + Send + Sync + 'static; + + async fn all( + &self, + context: Authentication, + ) -> Result, ServiceError>; + async fn find_by_sales_person_id( + &self, + sales_person_id: Uuid, + context: Authentication, + ) -> Result, ServiceError>; + async fn create( + &self, + entity: &WorkingHours, + context: Authentication, + ) -> Result; + async fn update( + &self, + entity: &WorkingHours, + context: Authentication, + ) -> Result; +} diff --git a/service_impl/src/lib.rs b/service_impl/src/lib.rs index c8a48a5..e7fd793 100644 --- a/service_impl/src/lib.rs +++ b/service_impl/src/lib.rs @@ -5,10 +5,12 @@ use async_trait::async_trait; pub mod booking; pub mod clock; pub mod permission; +pub mod reporting; pub mod sales_person; pub mod slot; mod test; pub mod uuid_service; +pub mod working_hours; pub use permission::PermissionServiceImpl; use service::permission::MockContext; diff --git a/service_impl/src/reporting.rs b/service_impl/src/reporting.rs new file mode 100644 index 0000000..776f4f8 --- /dev/null +++ b/service_impl/src/reporting.rs @@ -0,0 +1,389 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use dao::{ + extra_hours::{ExtraHoursCategoryEntity, ExtraHoursEntity}, + shiftplan_report::ShiftplanReportEntity, + working_hours::WorkingHoursEntity, +}; +use service::{ + permission::{Authentication, HR_PRIVILEGE}, + reporting::{ + EmployeeReport, ExtraHoursCategory, ShortEmployeeReport, WorkingHours, WorkingHoursDay, + }, + ServiceError, +}; +use tokio::join; +use uuid::Uuid; + +pub struct ReportingServiceImpl< + ExtraHoursDao, + ShiftplanReportDao, + WorkingHoursDao, + SalesPersonService, + PermissionService, + ClockService, + UuidService, +> where + ExtraHoursDao: dao::extra_hours::ExtraHoursDao + Send + Sync, + ShiftplanReportDao: dao::shiftplan_report::ShiftplanReportDao + Send + Sync, + WorkingHoursDao: dao::working_hours::WorkingHoursDao + Send + Sync, + SalesPersonService: service::sales_person::SalesPersonService + Send + Sync, + PermissionService: service::permission::PermissionService + Send + Sync, + ClockService: service::clock::ClockService + Send + Sync, + UuidService: service::uuid_service::UuidService + Send + Sync, +{ + pub extra_hours_dao: Arc, + pub shiftplan_report_dao: Arc, + pub working_hours_dao: Arc, + pub sales_person_service: Arc, + pub permission_service: Arc, + pub clock_service: Arc, + pub uuid_service: Arc, +} + +impl< + ExtraHoursDao, + ShiftplanReportDao, + WorkingHoursDao, + SalesPersonService, + PermissionService, + ClockService, + UuidService, + > + ReportingServiceImpl< + ExtraHoursDao, + ShiftplanReportDao, + WorkingHoursDao, + SalesPersonService, + PermissionService, + ClockService, + UuidService, + > +where + ExtraHoursDao: dao::extra_hours::ExtraHoursDao + Send + Sync, + ShiftplanReportDao: dao::shiftplan_report::ShiftplanReportDao + Send + Sync, + WorkingHoursDao: dao::working_hours::WorkingHoursDao + Send + Sync, + SalesPersonService: service::sales_person::SalesPersonService + Send + Sync, + PermissionService: service::permission::PermissionService + Send + Sync, + ClockService: service::clock::ClockService + Send + Sync, + UuidService: service::uuid_service::UuidService + Send + Sync, +{ + pub fn new( + extra_hours_dao: Arc, + shiftplan_report_dao: Arc, + working_hours_dao: Arc, + sales_person_service: Arc, + permission_service: Arc, + clock_service: Arc, + uuid_service: Arc, + ) -> Self { + Self { + extra_hours_dao, + shiftplan_report_dao, + working_hours_dao, + sales_person_service, + permission_service, + clock_service, + uuid_service, + } + } + + pub async fn check_user_is_sales_person( + &self, + sales_person_id: Uuid, + context: AuthContext, + ) -> Result<(), ServiceError> + where + AuthContext: Clone + + Send + + Sync + + std::fmt::Debug + + Eq + + 'static + + Into>, + { + if let Some(sales_person) = self + .sales_person_service + .get_sales_person_current_user(context.into()) + .await? + { + if sales_person.id == sales_person_id { + Ok(()) + } else { + Err(ServiceError::Forbidden) + } + } else { + Err(ServiceError::Forbidden) + } + } +} + +pub fn find_working_hours_for_calendar_week( + working_hours: &[WorkingHoursEntity], + year: u32, + week: u8, +) -> Option<&WorkingHoursEntity> { + dbg!((year, week)); + working_hours.iter().find(|wh| { + (year, week) >= (wh.from_year, wh.from_calendar_week) + && (year, week) <= (wh.to_year, wh.to_calendar_week) + }) +} + +#[async_trait] +impl< + ExtraHoursDao, + ShiftplanReportDao, + WorkingHoursDao, + SalesPersonService, + PermissionService, + ClockService, + UuidService, + > service::reporting::ReportingService + for ReportingServiceImpl< + ExtraHoursDao, + ShiftplanReportDao, + WorkingHoursDao, + SalesPersonService, + PermissionService, + ClockService, + UuidService, + > +where + ExtraHoursDao: dao::extra_hours::ExtraHoursDao + Send + Sync, + ShiftplanReportDao: dao::shiftplan_report::ShiftplanReportDao + Send + Sync, + WorkingHoursDao: dao::working_hours::WorkingHoursDao + Send + Sync, + SalesPersonService: service::sales_person::SalesPersonService + + Send + + Sync, + PermissionService: service::permission::PermissionService + Send + Sync, + ClockService: service::clock::ClockService + Send + Sync, + UuidService: service::uuid_service::UuidService + Send + Sync, +{ + type Context = PermissionService::Context; + + async fn get_reports_for_all_employees( + &self, + year: u32, + until_week: u8, + context: Authentication, + ) -> Result, ServiceError> { + self.permission_service + .check_permission(HR_PRIVILEGE, context) + .await?; + + let shiftplan_report = self + .shiftplan_report_dao + .extract_quick_shiftplan_report(year, until_week) + .await?; + + let working_hours = self.working_hours_dao.all().await?; + + let employees = self + .sales_person_service + .get_all(Authentication::Full) + .await?; + let mut short_employee_report: Vec = Vec::new(); + for paid_employee in employees + .iter() + .filter(|employee| employee.is_paid.unwrap_or(false)) + { + let shiftplan_hours = shiftplan_report + .iter() + .filter(|r| r.sales_person_id == paid_employee.id) + .map(|r| r.hours) + .sum::(); + let working_hours: Arc<[WorkingHoursEntity]> = working_hours + .iter() + .filter(|wh| wh.sales_person_id == paid_employee.id) + .cloned() + .collect(); + let planned_hours: f32 = (1..=until_week) + .map(|week| { + find_working_hours_for_calendar_week(&working_hours, year, week) + .map(|wh| wh.expected_hours) + .unwrap_or(0.0) + }) + .sum(); + let extra_hours = self + .extra_hours_dao + .find_by_sales_person_id_and_year(paid_employee.id, year, until_week) + .await? + .iter() + .map(|eh| eh.amount) + .sum::(); + let balance_hours = shiftplan_hours + extra_hours - planned_hours; + short_employee_report.push(ShortEmployeeReport { + sales_person: Arc::new(paid_employee.clone()), + balance_hours, + }); + } + Ok(short_employee_report.into()) + } + + async fn get_report_for_employee( + &self, + sales_person_id: &Uuid, + year: u32, + until_week: u8, + context: Authentication, + ) -> Result { + let (hr_permission, user_permission) = join!( + self.permission_service + .check_permission(HR_PRIVILEGE, context.clone()), + self.check_user_is_sales_person(*sales_person_id, context.clone()) + ); + hr_permission.or(user_permission)?; + + let sales_person = self + .sales_person_service + .get(*sales_person_id, context) + .await?; + let working_hours = self + .working_hours_dao + .find_by_sales_person_id(*sales_person_id) + .await?; + let shiftplan_report = self + .shiftplan_report_dao + .extract_shiftplan_report(*sales_person_id, year, until_week) + .await?; + let extra_hours = self + .extra_hours_dao + .find_by_sales_person_id_and_year(*sales_person_id, year, until_week) + .await?; + + let planned_hours: f32 = (1..=until_week) + .map(|week| { + find_working_hours_for_calendar_week(&working_hours, year, week) + .map(|wh| wh.expected_hours) + .unwrap_or(0.0) + }) + .sum(); + let shiftplan_hours = shiftplan_report.iter().map(|r| r.hours).sum::() as f32; + let overall_extra_hours = extra_hours.iter().map(|eh| eh.amount).sum::(); + + let employee_report = EmployeeReport { + sales_person: Arc::new(sales_person), + balance_hours: shiftplan_hours + overall_extra_hours - planned_hours, + overall_hours: shiftplan_hours + overall_extra_hours, + expected_hours: planned_hours, + shiftplan_hours, + extra_work_hours: extra_hours + .iter() + .filter(|extra_hours| extra_hours.category == ExtraHoursCategoryEntity::ExtraWork) + .map(|extra_hours| extra_hours.amount) + .sum(), + vacation_hours: extra_hours + .iter() + .filter(|extra_hours| extra_hours.category == ExtraHoursCategoryEntity::Vacation) + .map(|extra_hours| extra_hours.amount) + .sum(), + sick_leave_hours: extra_hours + .iter() + .filter(|extra_hours| extra_hours.category == ExtraHoursCategoryEntity::SickLeave) + .map(|extra_hours| extra_hours.amount) + .sum(), + holiday_hours: extra_hours + .iter() + .filter(|extra_hours| extra_hours.category == ExtraHoursCategoryEntity::Holiday) + .map(|extra_hours| extra_hours.amount) + .sum(), + by_week: hours_per_week( + &shiftplan_report, + &extra_hours, + &working_hours, + year, + until_week, + )?, + by_month: Arc::new([]), + }; + + Ok(employee_report) + } +} + +fn hours_per_week( + shiftplan_hours_list: &Arc<[ShiftplanReportEntity]>, + extra_hours_list: &Arc<[ExtraHoursEntity]>, + working_hours: &[WorkingHoursEntity], + year: u32, + week_until: u8, +) -> Result, ServiceError> { + let mut weeks: Vec = Vec::new(); + for week in 1..=week_until { + let shiftplan_hours = shiftplan_hours_list + .iter() + .filter(|r| r.calendar_week == week) + .map(|r| r.hours) + .sum::(); + let working_hours = working_hours + .iter() + .filter(|wh| wh.from_calendar_week <= week && wh.to_calendar_week >= week) + .map(|wh| wh.expected_hours) + .sum::(); + let extra_hours = extra_hours_list + .iter() + .filter(|eh| eh.date_time.iso_week() == week) + .map(|eh| eh.amount) + .sum::(); + + let mut day_list = extra_hours_list + .iter() + .map(|eh| { + Ok(WorkingHoursDay { + date: eh.date_time.date(), + hours: eh.amount, + category: (&eh.category).into(), + }) + }) + .chain(shiftplan_hours_list.iter().map(|working_hours_day| { + Ok::(WorkingHoursDay { + date: time::Date::from_iso_week_date( + year as i32, + working_hours_day.calendar_week, + time::Weekday::Sunday.nth_next(working_hours_day.day_of_week.to_number()), + )?, + hours: working_hours_day.hours, + category: ExtraHoursCategory::Shiftplan, + }) + })) + .collect::, ServiceError>>()?; + day_list.sort_by_key(|day| day.date); + + weeks.push(WorkingHours { + from: time::Date::from_iso_week_date(year as i32, week, time::Weekday::Monday).unwrap(), + to: time::Date::from_iso_week_date(year as i32, week, time::Weekday::Sunday).unwrap(), + expected_hours: working_hours, + overall_hours: shiftplan_hours + extra_hours, + balance: shiftplan_hours + extra_hours - working_hours, + shiftplan_hours, + extra_work_hours: extra_hours_list + .iter() + .filter(|eh| eh.category == ExtraHoursCategoryEntity::ExtraWork) + .map(|eh| eh.amount) + .sum(), + vacation_hours: extra_hours_list + .iter() + .filter(|eh| eh.category == ExtraHoursCategoryEntity::Vacation) + .map(|eh| eh.amount) + .sum(), + sick_leave_hours: extra_hours_list + .iter() + .filter(|eh| eh.category == ExtraHoursCategoryEntity::SickLeave) + .map(|eh| eh.amount) + .sum(), + holiday_hours: extra_hours_list + .iter() + .filter(|eh| eh.category == ExtraHoursCategoryEntity::Holiday) + .map(|eh| eh.amount) + .sum(), + days: day_list + .iter() + .filter(|day| day.date.iso_week() == week && day.date.year() == year as i32) + .cloned() + .collect(), + }); + } + Ok(weeks.into()) +} diff --git a/service_impl/src/working_hours.rs b/service_impl/src/working_hours.rs new file mode 100644 index 0000000..e9d57fc --- /dev/null +++ b/service_impl/src/working_hours.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use dao::working_hours::WorkingHoursEntity; +use service::{ + permission::{Authentication, HR_PRIVILEGE}, + working_hours::WorkingHours, + ServiceError, +}; +use uuid::Uuid; + +pub struct WorkingHoursServiceImpl< + WorkingHoursDao: dao::working_hours::WorkingHoursDao, + PermissionService: service::PermissionService, + ClockService: service::clock::ClockService, + UuidService: service::uuid_service::UuidService, +> { + working_hours_dao: Arc, + permission_service: Arc, + clock_service: Arc, + uuid_service: Arc, +} + +impl + WorkingHoursServiceImpl +where + WorkingHoursDao: dao::working_hours::WorkingHoursDao + Sync + Send, + PermissionService: service::PermissionService + Sync + Send, + ClockService: service::clock::ClockService + Sync + Send, + UuidService: service::uuid_service::UuidService + Sync + Send, +{ + pub fn new( + working_hours_dao: Arc, + permission_service: Arc, + clock_service: Arc, + uuid_service: Arc, + ) -> Self { + Self { + working_hours_dao, + permission_service, + clock_service, + uuid_service, + } + } +} + +#[async_trait] +impl< + WorkingHoursDao: dao::working_hours::WorkingHoursDao + Sync + Send, + PermissionService: service::PermissionService + Sync + Send, + ClockService: service::clock::ClockService + Sync + Send, + UuidService: service::uuid_service::UuidService + Sync + Send, + > service::working_hours::WorkingHoursService + for WorkingHoursServiceImpl +{ + type Context = PermissionService::Context; + + async fn all( + &self, + _context: Authentication, + ) -> Result, ServiceError> { + unimplemented!() + } + async fn find_by_sales_person_id( + &self, + _sales_person_id: Uuid, + _context: Authentication, + ) -> Result, ServiceError> { + unimplemented!() + } + async fn create( + &self, + working_hours: &WorkingHours, + context: Authentication, + ) -> Result { + let mut working_hours = working_hours.to_owned(); + self.permission_service + .check_permission(HR_PRIVILEGE, context) + .await?; + + working_hours.created = Some(self.clock_service.date_time_now()); + let mut entity: WorkingHoursEntity = (&working_hours).try_into()?; + + if !entity.id.is_nil() { + return Err(ServiceError::IdSetOnCreate); + } + if !entity.version.is_nil() { + return Err(ServiceError::VersionSetOnCreate); + } + entity.id = self + .uuid_service + .new_uuid("working-hours-service::create id"); + entity.version = self + .uuid_service + .new_uuid("working-hours-service::create version"); + self.working_hours_dao + .create(&entity, "working-hours-service::create") + .await?; + + Ok(WorkingHours::from(&entity)) + } + async fn update( + &self, + _entity: &WorkingHours, + _context: Authentication, + ) -> Result { + unimplemented!() + } +}