fixing the checkin
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ keywords = ["rust", "axum", "postgres", "scheduling", "control-structures"]
|
||||
categories = ["web-programming::http-server"]
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.4"
|
||||
axum = { version = "0.8.4", features = ["multipart"] }
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
dotenvy = "0.15.7"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ pub struct AppState {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct ApiError(sqlx::Error);
|
||||
@@ -36,7 +36,7 @@ impl IntoResponse for ApiError {
|
||||
(
|
||||
status,
|
||||
Json(ErrorResponse {
|
||||
error: self.0.to_string(),
|
||||
message: self.0.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::database::types::DayOfWeek;
|
||||
use crate::database::types::{DayOfWeek, WorkTime};
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||
pub struct Schedule {
|
||||
@@ -9,7 +9,8 @@ pub struct Schedule {
|
||||
pub user_id: i32,
|
||||
pub enter_time: NaiveDateTime,
|
||||
pub day_of_week: DayOfWeek,
|
||||
pub out_time: NaiveDateTime,
|
||||
pub out_time: Option<NaiveDateTime>,
|
||||
pub shift: WorkTime,
|
||||
pub date_created: NaiveDateTime,
|
||||
pub date_updated: NaiveDateTime,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ pub struct WeeklyPreference {
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
pub day_of_week: DayOfWeek,
|
||||
pub first_preference: WorkTime,
|
||||
pub second_preference: WorkTime,
|
||||
pub third_preference: WorkTime,
|
||||
pub first: WorkTime,
|
||||
pub second: WorkTime,
|
||||
pub third: WorkTime,
|
||||
pub date_created: NaiveDateTime,
|
||||
pub date_updated: NaiveDateTime,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::database::entities::{Company, CompanyUser, User};
|
||||
use crate::database::entities::{Company, CompanyUser};
|
||||
|
||||
pub async fn add_company(pool: &PgPool, name: &str) -> Result<Company, sqlx::Error> {
|
||||
sqlx::query_as::<_, Company>(
|
||||
@@ -58,17 +58,28 @@ pub async fn add_company_user(
|
||||
company_id: i32,
|
||||
user_id: i32,
|
||||
) -> Result<CompanyUser, sqlx::Error> {
|
||||
sqlx::query_as::<_, CompanyUser>(
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM company_users WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let company_user = sqlx::query_as::<_, CompanyUser>(
|
||||
r#"
|
||||
INSERT INTO company_users (user_id, company_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW()
|
||||
RETURNING id, user_id, company_id, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(company_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(company_user)
|
||||
}
|
||||
|
||||
pub async fn get_company_by_name(pool: &PgPool, name: &str) -> Result<Company, sqlx::Error> {
|
||||
@@ -100,22 +111,3 @@ pub async fn get_user_id_by_company(
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_users_by_company(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
) -> Result<Vec<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
SELECT users.id, users.name, users.last_name, users.preference,
|
||||
users.date_created, users.date_updated
|
||||
FROM users
|
||||
INNER JOIN company_users ON company_users.user_id = users.id
|
||||
WHERE company_users.company_id = $1
|
||||
ORDER BY users.name, users.last_name
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -18,13 +18,84 @@ pub async fn list_employees(pool: &PgPool) -> Result<Vec<EmployeeRow>, sqlx::Err
|
||||
SELECT users.id, company_users.company_id, users.name, users.last_name, users.preference
|
||||
FROM users
|
||||
INNER JOIN company_users ON company_users.user_id = users.id
|
||||
ORDER BY users.name, users.last_name
|
||||
ORDER BY users.id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_employee(
|
||||
pool: &PgPool,
|
||||
id: i32,
|
||||
name: &str,
|
||||
last_name: &str,
|
||||
preference: WorkTime,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET name = $2, last_name = $3, preference = $4, date_updated = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, last_name, preference, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(last_name)
|
||||
.bind(preference)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_company(pool: &PgPool, id: i32, company_id: i32) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM company_users WHERE user_id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO company_users (user_id, company_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(company_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await
|
||||
}
|
||||
|
||||
pub async fn get_company_id_by_user(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
) -> Result<Option<i32>, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, i32>(
|
||||
r#"
|
||||
SELECT company_id
|
||||
FROM company_users
|
||||
WHERE user_id = $1
|
||||
ORDER BY date_updated DESC, id DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_preference(pool: &PgPool, user_id: i32) -> Result<Option<WorkTime>, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, WorkTime>("SELECT preference FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_employee(pool: &PgPool, id: i32) -> Result<EmployeeRow, sqlx::Error> {
|
||||
sqlx::query_as::<_, EmployeeRow>(
|
||||
r#"
|
||||
|
||||
@@ -9,56 +9,49 @@ pub async fn upsert_weekly_preference(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
first_preference: WorkTime,
|
||||
second_preference: WorkTime,
|
||||
third_preference: WorkTime,
|
||||
first: WorkTime,
|
||||
second: WorkTime,
|
||||
third: WorkTime,
|
||||
) -> Result<WeeklyPreference, sqlx::Error> {
|
||||
sqlx::query_as::<_, WeeklyPreference>(
|
||||
r#"
|
||||
INSERT INTO employee_weekly_preferences (
|
||||
user_id, day_of_week, first_preference, second_preference, third_preference
|
||||
INSERT INTO weekly_preferences (
|
||||
user_id, day_of_week, first, second, third
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, day_of_week)
|
||||
DO UPDATE SET
|
||||
first_preference = EXCLUDED.first_preference,
|
||||
second_preference = EXCLUDED.second_preference,
|
||||
third_preference = EXCLUDED.third_preference,
|
||||
first = EXCLUDED.first,
|
||||
second = EXCLUDED.second,
|
||||
third = EXCLUDED.third,
|
||||
date_updated = NOW()
|
||||
RETURNING id, user_id, day_of_week, first_preference, second_preference,
|
||||
third_preference, date_created, date_updated
|
||||
RETURNING id, user_id, day_of_week, first, second, third, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(day_of_week)
|
||||
.bind(first_preference)
|
||||
.bind(second_preference)
|
||||
.bind(third_preference)
|
||||
.bind(first)
|
||||
.bind(second)
|
||||
.bind(third)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_preferences_for_company(
|
||||
pub async fn get_weekly_preference(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
) -> Result<Vec<WeeklyPreference>, sqlx::Error> {
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
) -> Result<Option<WeeklyPreference>, sqlx::Error> {
|
||||
sqlx::query_as::<_, WeeklyPreference>(
|
||||
r#"
|
||||
SELECT employee_weekly_preferences.id,
|
||||
employee_weekly_preferences.user_id,
|
||||
employee_weekly_preferences.day_of_week,
|
||||
employee_weekly_preferences.first_preference,
|
||||
employee_weekly_preferences.second_preference,
|
||||
employee_weekly_preferences.third_preference,
|
||||
employee_weekly_preferences.date_created,
|
||||
employee_weekly_preferences.date_updated
|
||||
FROM employee_weekly_preferences
|
||||
INNER JOIN company_users ON company_users.user_id = employee_weekly_preferences.user_id
|
||||
WHERE company_users.company_id = $1
|
||||
ORDER BY employee_weekly_preferences.day_of_week, employee_weekly_preferences.user_id
|
||||
SELECT id, user_id, day_of_week, first, second, third, date_created, date_updated
|
||||
FROM weekly_preferences
|
||||
WHERE user_id = $1 AND day_of_week = $2
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_all(pool)
|
||||
.bind(user_id)
|
||||
.bind(day_of_week)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,78 +1,96 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::database::{entities::Schedule, types::DayOfWeek};
|
||||
use crate::database::{
|
||||
entities::Schedule,
|
||||
types::{DayOfWeek, WorkTime},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct WeeklyScheduleRow {
|
||||
pub id: i32,
|
||||
pub user_name: String,
|
||||
pub user_last_name: String,
|
||||
pub user_preference: WorkTime,
|
||||
pub company: String,
|
||||
pub enter_time: NaiveDateTime,
|
||||
pub day_of_week: DayOfWeek,
|
||||
pub out_time: Option<NaiveDateTime>,
|
||||
pub shift: WorkTime,
|
||||
}
|
||||
|
||||
pub async fn add_schedule(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
enter_time: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: NaiveDateTime,
|
||||
out_time: Option<NaiveDateTime>,
|
||||
shift: WorkTime,
|
||||
) -> Result<Schedule, sqlx::Error> {
|
||||
// Keep compatible with legacy DBs that have an extra `shift` column.
|
||||
// Try insert with `shift` first, fall back to 4-column insert if the column was dropped/never created.
|
||||
let with_shift = sqlx::query_as::<_, Schedule>(
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
INSERT INTO schedules (user_id, enter_time, day_of_week, shift, out_time)
|
||||
VALUES ($1, $2, $3, 'morning', $4)
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, date_created, date_updated
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(enter_time)
|
||||
.bind(day_of_week)
|
||||
.bind(shift)
|
||||
.bind(out_time)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
|
||||
match with_shift {
|
||||
Ok(schedule) => Ok(schedule),
|
||||
Err(_) => {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
INSERT INTO schedules (user_id, enter_time, day_of_week, out_time)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(enter_time)
|
||||
.bind(day_of_week)
|
||||
.bind(out_time)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_schedules(pool: &PgPool) -> Result<Vec<Schedule>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
SELECT id, user_id, enter_time, day_of_week, out_time, date_created, date_updated
|
||||
FROM schedules
|
||||
ORDER BY day_of_week, user_id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_schedules_for_company(
|
||||
pub async fn list_weekly(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
) -> Result<Vec<Schedule>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
company_id: Option<i32>,
|
||||
) -> Result<Vec<WeeklyScheduleRow>, sqlx::Error> {
|
||||
if let Some(company_id) = company_id {
|
||||
return sqlx::query_as::<_, WeeklyScheduleRow>(
|
||||
r#"
|
||||
SELECT schedules.id,
|
||||
users.name AS user_name,
|
||||
users.last_name AS user_last_name,
|
||||
users.preference AS user_preference,
|
||||
companies.name AS company,
|
||||
schedules.enter_time,
|
||||
schedules.day_of_week,
|
||||
schedules.out_time,
|
||||
schedules.shift
|
||||
FROM schedules
|
||||
INNER JOIN company_users ON company_users.user_id = schedules.user_id
|
||||
INNER JOIN users ON users.id = schedules.user_id
|
||||
INNER JOIN companies ON companies.id = company_users.company_id
|
||||
WHERE company_users.company_id = $1
|
||||
ORDER BY schedules.day_of_week, schedules.enter_time, schedules.id
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
sqlx::query_as::<_, WeeklyScheduleRow>(
|
||||
r#"
|
||||
SELECT schedules.id, schedules.user_id, schedules.enter_time, schedules.day_of_week,
|
||||
schedules.out_time, schedules.date_created, schedules.date_updated
|
||||
SELECT schedules.id,
|
||||
users.name AS user_name,
|
||||
users.last_name AS user_last_name,
|
||||
users.preference AS user_preference,
|
||||
companies.name AS company,
|
||||
schedules.enter_time,
|
||||
schedules.day_of_week,
|
||||
schedules.out_time,
|
||||
schedules.shift
|
||||
FROM schedules
|
||||
INNER JOIN company_users ON company_users.user_id = schedules.user_id
|
||||
WHERE company_users.company_id = $1
|
||||
ORDER BY schedules.day_of_week, schedules.user_id
|
||||
INNER JOIN users ON users.id = schedules.user_id
|
||||
INNER JOIN companies ON companies.id = company_users.company_id
|
||||
ORDER BY schedules.day_of_week, schedules.enter_time, schedules.id
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
@@ -101,9 +119,9 @@ pub async fn clock_in(
|
||||
user_id: i32,
|
||||
now: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
) -> Result<Schedule, sqlx::Error> {
|
||||
// out_time is required in reference API, initialize with enter_time and let clock-out update it
|
||||
add_schedule(pool, user_id, now, day_of_week, now).await
|
||||
add_schedule(pool, user_id, now, day_of_week, None, shift).await
|
||||
}
|
||||
|
||||
pub async fn clock_out(
|
||||
@@ -123,7 +141,7 @@ pub async fn clock_out(
|
||||
ORDER BY enter_time DESC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, date_created, date_updated
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
@@ -132,3 +150,127 @@ pub async fn clock_out(
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_by_user_and_day_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
week_start: NaiveDateTime,
|
||||
week_end: NaiveDateTime,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
SELECT id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
|
||||
FROM schedules
|
||||
WHERE user_id = $1
|
||||
AND day_of_week = $2
|
||||
AND enter_time >= $3
|
||||
AND enter_time < $4
|
||||
ORDER BY date_created DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(day_of_week)
|
||||
.bind(week_start)
|
||||
.bind(week_end)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_distinct_days_by_user_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
week_start: NaiveDateTime,
|
||||
week_end: NaiveDateTime,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT day_of_week)
|
||||
FROM schedules
|
||||
WHERE user_id = $1
|
||||
AND enter_time >= $2
|
||||
AND enter_time < $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(week_start)
|
||||
.bind(week_end)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_by_day_shift(
|
||||
pool: &PgPool,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
company_id: Option<i32>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
if let Some(company_id) = company_id {
|
||||
return sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM schedules
|
||||
INNER JOIN company_users ON company_users.user_id = schedules.user_id
|
||||
WHERE schedules.day_of_week = $1
|
||||
AND schedules.shift = $2
|
||||
AND company_users.company_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(day_of_week)
|
||||
.bind(shift)
|
||||
.bind(company_id)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM schedules
|
||||
WHERE day_of_week = $1 AND shift = $2
|
||||
"#,
|
||||
)
|
||||
.bind(day_of_week)
|
||||
.bind(shift)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_clock_in_by_id(
|
||||
pool: &PgPool,
|
||||
schedule_id: i32,
|
||||
enter_time: NaiveDateTime,
|
||||
) -> Result<Schedule, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
UPDATE schedules
|
||||
SET enter_time = $2, date_updated = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(schedule_id)
|
||||
.bind(enter_time)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_clock_out_by_id(
|
||||
pool: &PgPool,
|
||||
schedule_id: i32,
|
||||
out_time: NaiveDateTime,
|
||||
) -> Result<Schedule, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
UPDATE schedules
|
||||
SET out_time = $2, date_updated = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(schedule_id)
|
||||
.bind(out_time)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::database::{
|
||||
entities::{Schedule, User},
|
||||
entities::User,
|
||||
types::{DayOfWeek, WorkTime},
|
||||
};
|
||||
|
||||
@@ -58,88 +58,52 @@ pub async fn remove_user(pool: &PgPool, id: i32) -> Result<User, sqlx::Error> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn add_schedule(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
enter_time: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: Option<NaiveDateTime>,
|
||||
) -> Result<Schedule, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
INSERT INTO schedules (user_id, enter_time, day_of_week, shift, out_time)
|
||||
VALUES ($1, $2, $3, 'morning', $4)
|
||||
RETURNING id, user_id, enter_time, day_of_week, shift, out_time, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(enter_time)
|
||||
.bind(day_of_week)
|
||||
.bind(out_time)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_days_worked_by_user(
|
||||
pub async fn get_days_worked_by_user_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
week_start: NaiveDateTime,
|
||||
week_end: NaiveDateTime,
|
||||
) -> Result<Vec<DayOfWeek>, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, DayOfWeek>(
|
||||
r#"
|
||||
SELECT DISTINCT day_of_week
|
||||
FROM schedules
|
||||
WHERE user_id = $1
|
||||
AND enter_time >= $2
|
||||
AND enter_time < $3
|
||||
ORDER BY day_of_week
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(week_start)
|
||||
.bind(week_end)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_enter_time_by_day(
|
||||
pub async fn get_user_enter_time_by_day_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
week_start: NaiveDateTime,
|
||||
week_end: NaiveDateTime,
|
||||
) -> Result<Option<NaiveDateTime>, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, NaiveDateTime>(
|
||||
r#"
|
||||
SELECT enter_time
|
||||
FROM schedules
|
||||
WHERE user_id = $1 AND day_of_week = $2
|
||||
WHERE user_id = $1
|
||||
AND day_of_week = $2
|
||||
AND enter_time >= $3
|
||||
AND enter_time < $4
|
||||
ORDER BY date_created DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(day_of_week)
|
||||
.bind(week_start)
|
||||
.bind(week_end)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_day_end_by_user_and_day(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: NaiveDateTime,
|
||||
) -> Result<Schedule, sqlx::Error> {
|
||||
sqlx::query_as::<_, Schedule>(
|
||||
r#"
|
||||
UPDATE schedules
|
||||
SET out_time = $3, date_updated = NOW()
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM schedules
|
||||
WHERE user_id = $1 AND day_of_week = $2
|
||||
ORDER BY date_created DESC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, user_id, enter_time, day_of_week, shift, out_time, date_created, date_updated
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(day_of_week)
|
||||
.bind(out_time)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
+20
-9
@@ -61,20 +61,21 @@ CREATE TABLE IF NOT EXISTS schedules (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
enter_time TIMESTAMP NOT NULL,
|
||||
day_of_week day_of_week NOT NULL,
|
||||
out_time TIMESTAMP NOT NULL,
|
||||
out_time TIMESTAMP,
|
||||
shift work_time NOT NULL,
|
||||
date_created TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
date_updated TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
"#;
|
||||
|
||||
pub const CREATE_EMPLOYEE_WEEKLY_PREFERENCES_TABLE: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS employee_weekly_preferences (
|
||||
pub const CREATE_WEEKLY_PREFERENCES_TABLE: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS weekly_preferences (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
day_of_week day_of_week NOT NULL,
|
||||
first_preference work_time NOT NULL,
|
||||
second_preference work_time NOT NULL,
|
||||
third_preference work_time NOT NULL,
|
||||
first work_time NOT NULL,
|
||||
second work_time NOT NULL,
|
||||
third work_time NOT NULL,
|
||||
date_created TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
date_updated TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, day_of_week)
|
||||
@@ -83,7 +84,15 @@ CREATE TABLE IF NOT EXISTS employee_weekly_preferences (
|
||||
|
||||
pub const ALTER_SCHEDULES_ADD_SHIFT: &str = r#"
|
||||
ALTER TABLE schedules
|
||||
ADD COLUMN IF NOT EXISTS shift work_time NOT NULL DEFAULT 'morning';
|
||||
ADD COLUMN IF NOT EXISTS shift work_time;
|
||||
"#;
|
||||
|
||||
pub const UPDATE_SCHEDULES_DEFAULT_SHIFT: &str = r#"
|
||||
UPDATE schedules SET shift = 'morning' WHERE shift IS NULL;
|
||||
"#;
|
||||
|
||||
pub const ALTER_SCHEDULES_SHIFT_SET_NOT_NULL: &str = r#"
|
||||
ALTER TABLE schedules ALTER COLUMN shift SET NOT NULL;
|
||||
"#;
|
||||
|
||||
pub const ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL: &str = r#"
|
||||
@@ -91,14 +100,16 @@ ALTER TABLE schedules
|
||||
ALTER COLUMN out_time DROP NOT NULL;
|
||||
"#;
|
||||
|
||||
pub const CREATE_SCHEMA_QUERIES: [&str; 9] = [
|
||||
pub const CREATE_SCHEMA_QUERIES: [&str; 11] = [
|
||||
CREATE_WORK_TIME_ENUM,
|
||||
CREATE_DAY_OF_WEEK_ENUM,
|
||||
CREATE_COMPANIES_TABLE,
|
||||
CREATE_USERS_TABLE,
|
||||
CREATE_COMPANY_USERS_TABLE,
|
||||
CREATE_SCHEDULES_TABLE,
|
||||
CREATE_EMPLOYEE_WEEKLY_PREFERENCES_TABLE,
|
||||
CREATE_WEEKLY_PREFERENCES_TABLE,
|
||||
ALTER_SCHEDULES_ADD_SHIFT,
|
||||
UPDATE_SCHEDULES_DEFAULT_SHIFT,
|
||||
ALTER_SCHEDULES_SHIFT_SET_NOT_NULL,
|
||||
ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL,
|
||||
];
|
||||
|
||||
+33
-10
@@ -6,6 +6,29 @@ mod router;
|
||||
|
||||
use std::{env, net::SocketAddr};
|
||||
|
||||
use database::types::{DayOfWeek, WorkTime};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AssignmentRules {
|
||||
days: [DayOfWeek; 7],
|
||||
shifts: [WorkTime; 3],
|
||||
max_shifts_per_employee_per_day: u8,
|
||||
max_days_per_employee_per_week: u8,
|
||||
min_employees_per_shift: u8,
|
||||
conflict_strategy: &'static str,
|
||||
}
|
||||
|
||||
impl AssignmentRules {
|
||||
fn is_valid(&self) -> bool {
|
||||
self.days.len() == 7
|
||||
&& self.shifts.len() == 3
|
||||
&& self.max_shifts_per_employee_per_day == 1
|
||||
&& self.max_days_per_employee_per_week == 5
|
||||
&& self.min_employees_per_shift == 2
|
||||
&& !self.conflict_strategy.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
@@ -13,6 +36,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_url = env::var("DATABASE_URL")?;
|
||||
let pool = database::connection::connect(&database_url).await?;
|
||||
database::connection::init_database(&pool).await?;
|
||||
debug_assert!(assignment_rules().is_valid());
|
||||
|
||||
let router = router::build_router(pool);
|
||||
|
||||
@@ -26,14 +50,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assignment_rules() {
|
||||
// collect employee names and preferred shifts for each day of the week
|
||||
// store employees, companies, preferences, and schedules in database tables
|
||||
// use conditionals to reject invalid schedules
|
||||
// use loops to assign shifts for every day and every turn
|
||||
// use branching to resolve conflicts when a preferred shift is unavailable
|
||||
// ensure each employee works at most one shift per day
|
||||
// ensure each employee works at most five days per week
|
||||
// ensure every company shift has at least two employees
|
||||
// if fewer than two employees are available, choose another valid employee
|
||||
fn assignment_rules() -> AssignmentRules {
|
||||
AssignmentRules {
|
||||
days: models::shift::define_day_values(),
|
||||
shifts: models::shift::define_shift_values(),
|
||||
max_shifts_per_employee_per_day: 1,
|
||||
max_days_per_employee_per_week: 5,
|
||||
min_employees_per_shift: 2,
|
||||
conflict_strategy: "try same-day alternative shifts, then next-day preferences",
|
||||
}
|
||||
}
|
||||
|
||||
+186
-20
@@ -1,28 +1,194 @@
|
||||
// import validator functions before moving an employee to another shift or day
|
||||
// import database queries to read preferences and existing schedules
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn detect_preferred_shift_conflict() {
|
||||
// check if the employee's preferred shift is already full for the selected day
|
||||
// check if assigning this employee would break one-shift-per-day rule
|
||||
// check if assigning this employee would break five-days-per-week rule
|
||||
use crate::{
|
||||
database::{
|
||||
entities::Schedule,
|
||||
queries::{
|
||||
company as company_queries, employee as employee_queries,
|
||||
preference as preference_queries, schedule as schedule_queries,
|
||||
},
|
||||
types::{DayOfWeek, WorkTime},
|
||||
},
|
||||
manager::validator,
|
||||
};
|
||||
|
||||
const SHIFTS: [WorkTime; 3] = [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening];
|
||||
const DAYS: [DayOfWeek; 7] = [
|
||||
DayOfWeek::Sunday,
|
||||
DayOfWeek::Monday,
|
||||
DayOfWeek::Tuesday,
|
||||
DayOfWeek::Wednesday,
|
||||
DayOfWeek::Thursday,
|
||||
DayOfWeek::Friday,
|
||||
DayOfWeek::Saturday,
|
||||
];
|
||||
const MAX_EMPLOYEES_PER_SHIFT: i64 = 2;
|
||||
|
||||
async fn count_shift_employees(
|
||||
pool: &PgPool,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
company_id: Option<i32>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
schedule_queries::count_by_day_shift(pool, day_of_week, shift, company_id).await
|
||||
}
|
||||
|
||||
pub fn resolve_conflict_same_day() {
|
||||
// loop over the other shifts on the same day
|
||||
// choose the first shift where the employee can legally work
|
||||
// assign the employee only if validation passes
|
||||
async fn is_shift_full(
|
||||
pool: &PgPool,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
company_id: Option<i32>,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
Ok(
|
||||
count_shift_employees(pool, day_of_week, shift, company_id).await?
|
||||
>= MAX_EMPLOYEES_PER_SHIFT,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_conflict_next_day() {
|
||||
// if same-day alternatives fail, move to the next day
|
||||
// loop through next-day shifts by preference priority
|
||||
// assign the employee only if validation passes
|
||||
fn next_day(current_day: DayOfWeek) -> DayOfWeek {
|
||||
let index = DAYS.iter().position(|day| *day == current_day).unwrap_or(0);
|
||||
DAYS[(index + 1) % DAYS.len()]
|
||||
}
|
||||
|
||||
pub fn resolve_missing_second_employee() {
|
||||
// find shifts with only one employee assigned
|
||||
// loop over available employees in the company
|
||||
// skip employees who already work that day
|
||||
// skip employees who already worked five days this week
|
||||
// assign a valid extra employee to satisfy the two-employees-per-shift rule
|
||||
fn datetime_for_day(
|
||||
day_of_week: DayOfWeek,
|
||||
reference: chrono::NaiveDateTime,
|
||||
) -> chrono::NaiveDateTime {
|
||||
let (week_start, _) = validator::week_bounds(reference);
|
||||
let offset = match day_of_week {
|
||||
DayOfWeek::Sunday => 0,
|
||||
DayOfWeek::Monday => 1,
|
||||
DayOfWeek::Tuesday => 2,
|
||||
DayOfWeek::Wednesday => 3,
|
||||
DayOfWeek::Thursday => 4,
|
||||
DayOfWeek::Friday => 5,
|
||||
DayOfWeek::Saturday => 6,
|
||||
};
|
||||
week_start + chrono::Duration::days(offset) + chrono::Duration::hours(9)
|
||||
}
|
||||
|
||||
async fn shift_preferences(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
) -> Result<Vec<WorkTime>, sqlx::Error> {
|
||||
if let Some(row) = preference_queries::get_weekly_preference(pool, user_id, day_of_week).await?
|
||||
{
|
||||
return Ok(vec![row.first, row.second, row.third]);
|
||||
}
|
||||
|
||||
Ok(SHIFTS.to_vec())
|
||||
}
|
||||
|
||||
async fn assign_shift(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
company_id: Option<i32>,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
let company_id = match company_id {
|
||||
Some(company_id) => company_id,
|
||||
None => match employee_queries::get_company_id_by_user(pool, user_id).await? {
|
||||
Some(company_id) => company_id,
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
|
||||
if is_shift_full(pool, day_of_week, shift, Some(company_id)).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let enter_time = datetime_for_day(day_of_week, now);
|
||||
if !validator::validate_employee_can_join_shift_in_week(
|
||||
pool,
|
||||
user_id,
|
||||
day_of_week,
|
||||
shift,
|
||||
enter_time,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let schedule =
|
||||
schedule_queries::add_schedule(pool, user_id, enter_time, day_of_week, None, shift).await?;
|
||||
Ok(Some(schedule))
|
||||
}
|
||||
|
||||
pub async fn detect_preferred_shift_conflict(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
preferred_shift: WorkTime,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let company_id = employee_queries::get_company_id_by_user(pool, user_id).await?;
|
||||
let now = Utc::now().naive_utc();
|
||||
Ok(
|
||||
is_shift_full(pool, day_of_week, preferred_shift, company_id).await?
|
||||
|| !validator::validate_user_has_not_worked_same_day_in_week(
|
||||
pool,
|
||||
user_id,
|
||||
day_of_week,
|
||||
now,
|
||||
)
|
||||
.await?
|
||||
|| !validator::validate_user_has_not_worked_more_than_five_days_in_week(
|
||||
pool, user_id, now,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn resolve_conflict_same_day(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
for shift in SHIFTS {
|
||||
if let Some(schedule) = assign_shift(pool, user_id, day_of_week, shift, None).await? {
|
||||
return Ok(Some(schedule));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn resolve_conflict_next_day(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
current_day: DayOfWeek,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
let next_day = next_day(current_day);
|
||||
for shift in shift_preferences(pool, user_id, next_day).await? {
|
||||
if let Some(schedule) = assign_shift(pool, user_id, next_day, shift, None).await? {
|
||||
return Ok(Some(schedule));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn resolve_missing_second_employee(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
if count_shift_employees(pool, day_of_week, shift, Some(company_id)).await?
|
||||
>= MAX_EMPLOYEES_PER_SHIFT
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for user_id in company_queries::get_user_id_by_company(pool, company_id).await? {
|
||||
if let Some(schedule) =
|
||||
assign_shift(pool, user_id, day_of_week, shift, Some(company_id)).await?
|
||||
{
|
||||
return Ok(Some(schedule));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
+172
-39
@@ -1,49 +1,182 @@
|
||||
// import database::company_queries to register users into companies when needed
|
||||
// import database::user_queries to add schedules and update enter/out times
|
||||
// import validator module to enforce assignment rules before saving a schedule
|
||||
// import conflict module to move employees when their preferred shift is not available
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn generate_weekly_schedule() {
|
||||
// load all employees for the selected company
|
||||
// load all employee preferences for all seven days
|
||||
// loop through each day of the week
|
||||
// loop through morning, afternoon, and evening shifts
|
||||
// try to assign employees based on preferences first
|
||||
// validate one-shift-per-day before each assignment
|
||||
// validate five-days-per-week before each assignment
|
||||
// validate at least two employees per shift after assignments
|
||||
// resolve conflicts by assigning another shift on the same day or next day
|
||||
// save the final schedule to the schedules table
|
||||
use crate::{
|
||||
database::{
|
||||
entities::Schedule,
|
||||
queries::{
|
||||
company as company_queries, employee as employee_queries,
|
||||
preference as preference_queries, schedule as schedule_queries,
|
||||
},
|
||||
types::{DayOfWeek, WorkTime},
|
||||
},
|
||||
manager::{conflict, validator},
|
||||
models::shift as shift_model,
|
||||
};
|
||||
|
||||
const DAYS: [DayOfWeek; 7] = [
|
||||
DayOfWeek::Sunday,
|
||||
DayOfWeek::Monday,
|
||||
DayOfWeek::Tuesday,
|
||||
DayOfWeek::Wednesday,
|
||||
DayOfWeek::Thursday,
|
||||
DayOfWeek::Friday,
|
||||
DayOfWeek::Saturday,
|
||||
];
|
||||
const SHIFTS: [WorkTime; 3] = [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening];
|
||||
|
||||
fn datetime_for_day(day_of_week: DayOfWeek, reference: NaiveDateTime) -> NaiveDateTime {
|
||||
let (week_start, _) = validator::week_bounds(reference);
|
||||
let offset = match day_of_week {
|
||||
DayOfWeek::Sunday => 0,
|
||||
DayOfWeek::Monday => 1,
|
||||
DayOfWeek::Tuesday => 2,
|
||||
DayOfWeek::Wednesday => 3,
|
||||
DayOfWeek::Thursday => 4,
|
||||
DayOfWeek::Friday => 5,
|
||||
DayOfWeek::Saturday => 6,
|
||||
};
|
||||
week_start + chrono::Duration::days(offset) + chrono::Duration::hours(9)
|
||||
}
|
||||
|
||||
pub fn assign_preferred_shift() {
|
||||
// receive employee_id, day_of_week, and preferred shift
|
||||
// call validator before inserting a schedule
|
||||
// call user_queries::add_schedule when the assignment is valid
|
||||
async fn preferences_for_day(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
) -> Result<Vec<WorkTime>, sqlx::Error> {
|
||||
if let Some(row) = preference_queries::get_weekly_preference(pool, user_id, day_of_week).await?
|
||||
{
|
||||
return Ok(vec![row.first, row.second, row.third]);
|
||||
}
|
||||
|
||||
if let Some(fallback) = employee_queries::get_preference(pool, user_id).await? {
|
||||
let mut ordered = vec![fallback];
|
||||
ordered.extend(SHIFTS.into_iter().filter(|shift| *shift != fallback));
|
||||
return Ok(ordered);
|
||||
}
|
||||
|
||||
Ok(SHIFTS.to_vec())
|
||||
}
|
||||
|
||||
pub fn fill_missing_shift_coverage() {
|
||||
// find each company shift with fewer than two employees
|
||||
// if only one employee is assigned, search for another valid employee
|
||||
// randomly choose or rotate through employees who have not worked five days
|
||||
// add the selected employee to the schedule
|
||||
pub async fn generate_weekly_schedule(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
) -> Result<Vec<Schedule>, sqlx::Error> {
|
||||
let employee_ids = company_queries::get_user_id_by_company(pool, company_id).await?;
|
||||
if employee_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let _has_enough = validator::validate_company_has_enough_employees(pool, company_id).await?;
|
||||
let _fields = crate::models::schedule::define_schedule_fields();
|
||||
|
||||
schedule_queries::delete_schedules_for_company(pool, company_id).await?;
|
||||
|
||||
let mut created = Vec::new();
|
||||
for day_of_week in DAYS {
|
||||
for user_id in &employee_ids {
|
||||
let mut assigned = None;
|
||||
for preferred_shift in preferences_for_day(pool, *user_id, day_of_week).await? {
|
||||
assigned =
|
||||
assign_preferred_shift(pool, *user_id, day_of_week, preferred_shift).await?;
|
||||
if assigned.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schedule) = assigned {
|
||||
created.push(schedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(schedule) =
|
||||
conflict::resolve_conflict_same_day(pool, *user_id, day_of_week).await?
|
||||
{
|
||||
created.push(schedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(schedule) =
|
||||
conflict::resolve_conflict_next_day(pool, *user_id, day_of_week).await?
|
||||
{
|
||||
created.push(schedule);
|
||||
}
|
||||
}
|
||||
|
||||
for shift in SHIFTS {
|
||||
let _capacity =
|
||||
shift_model::compare_shift_capacity(pool, company_id, day_of_week, shift).await?;
|
||||
if let Some(schedule) =
|
||||
fill_missing_shift_coverage(pool, company_id, day_of_week, shift).await?
|
||||
{
|
||||
created.push(schedule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub fn register_clock_in() {
|
||||
// receive user_id and current day/time from the TUI
|
||||
// reject if the user already worked the same day
|
||||
// save or update the schedule enter_time
|
||||
pub async fn assign_preferred_shift(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
preferred_shift: WorkTime,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let enter_time = datetime_for_day(day_of_week, now);
|
||||
|
||||
if !validator::validate_employee_can_join_shift_in_week(
|
||||
pool,
|
||||
user_id,
|
||||
day_of_week,
|
||||
preferred_shift,
|
||||
enter_time,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if conflict::detect_preferred_shift_conflict(pool, user_id, day_of_week, preferred_shift)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let company_id = match employee_queries::get_company_id_by_user(pool, user_id).await? {
|
||||
Some(company_id) => company_id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if validator::validate_shift_has_minimum_two_employees(
|
||||
pool,
|
||||
company_id,
|
||||
day_of_week,
|
||||
preferred_shift,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let schedule = schedule_queries::add_schedule(
|
||||
pool,
|
||||
user_id,
|
||||
enter_time,
|
||||
day_of_week,
|
||||
None,
|
||||
preferred_shift,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(schedule))
|
||||
}
|
||||
|
||||
pub fn register_clock_out() {
|
||||
// receive user_id, day_of_week, and current time from the TUI
|
||||
// call user_queries::update_day_end_by_user_and_day
|
||||
// keep the existing schedule row and update out_time only
|
||||
}
|
||||
|
||||
pub fn print_final_schedule() {
|
||||
// query final schedules with employee names
|
||||
// group output by day and shift
|
||||
// show every employee assigned to each shift
|
||||
// show warnings for shifts that still do not have two employees
|
||||
pub async fn fill_missing_shift_coverage(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
conflict::resolve_missing_second_employee(pool, company_id, day_of_week, shift).await
|
||||
}
|
||||
|
||||
+75
-24
@@ -1,34 +1,85 @@
|
||||
// import database::user_queries to read existing schedules for one user
|
||||
// import database::storage::DayOfWeek to validate one day at a time
|
||||
// import database::storage::WorkTime to validate the selected shift
|
||||
use chrono::NaiveDateTime;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn validate_user_has_not_worked_same_day() {
|
||||
// query schedules for the user and selected day
|
||||
// if a schedule already exists for that user and day, reject the new shift
|
||||
// otherwise allow the scheduler to continue
|
||||
use crate::database::{
|
||||
queries::{company as company_queries, schedule as schedule_queries, user as user_queries},
|
||||
types::{DayOfWeek, WorkTime},
|
||||
};
|
||||
|
||||
pub fn week_bounds(reference: NaiveDateTime) -> (NaiveDateTime, NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let days_from_sunday = reference.weekday().num_days_from_sunday() as i64;
|
||||
let start_date = reference.date() - chrono::Duration::days(days_from_sunday);
|
||||
let week_start = start_date.and_hms_opt(0, 0, 0).unwrap();
|
||||
let week_end = week_start + chrono::Duration::days(7);
|
||||
(week_start, week_end)
|
||||
}
|
||||
|
||||
pub fn validate_user_has_not_worked_more_than_five_days() {
|
||||
// query distinct days worked by the user for the current week
|
||||
// count the worked days using a loop
|
||||
// if the count is five or more, reject the new shift
|
||||
// otherwise allow the user to be assigned
|
||||
pub async fn validate_user_has_not_worked_same_day_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
reference: NaiveDateTime,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let (week_start, week_end) = week_bounds(reference);
|
||||
Ok(user_queries::get_user_enter_time_by_day_in_week(
|
||||
pool,
|
||||
user_id,
|
||||
day_of_week,
|
||||
week_start,
|
||||
week_end,
|
||||
)
|
||||
.await?
|
||||
.is_none())
|
||||
}
|
||||
|
||||
pub fn validate_shift_has_minimum_two_employees() {
|
||||
// query schedules for company_id, day_of_week, and shift
|
||||
// count assigned employees for that shift
|
||||
// if count is less than two, ask scheduler to add another available employee
|
||||
pub async fn validate_user_has_not_worked_more_than_five_days_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
reference: NaiveDateTime,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let (week_start, week_end) = week_bounds(reference);
|
||||
Ok(
|
||||
user_queries::get_days_worked_by_user_in_week(pool, user_id, week_start, week_end)
|
||||
.await?
|
||||
.len()
|
||||
< 5,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_employee_can_join_shift() {
|
||||
// check same-day rule first
|
||||
// check five-days-per-week rule second
|
||||
// check shift capacity and company coverage rules last
|
||||
pub async fn validate_shift_has_minimum_two_employees(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
Ok(
|
||||
schedule_queries::count_by_day_shift(pool, day_of_week, shift, Some(company_id)).await?
|
||||
>= 2,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_company_has_enough_employees() {
|
||||
// query employee names based on company_id
|
||||
// if the company has fewer than two employees, ask the TUI to register another employee
|
||||
// if enough employees exist, continue schedule generation
|
||||
pub async fn validate_employee_can_join_shift_in_week(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
_shift: WorkTime,
|
||||
reference: NaiveDateTime,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
Ok(
|
||||
validate_user_has_not_worked_same_day_in_week(pool, user_id, day_of_week, reference)
|
||||
.await?
|
||||
&& validate_user_has_not_worked_more_than_five_days_in_week(pool, user_id, reference)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn validate_company_has_enough_employees(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
Ok(company_queries::get_user_id_by_company(pool, company_id)
|
||||
.await?
|
||||
.len()
|
||||
>= 2)
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
pub mod company;
|
||||
pub mod schedule;
|
||||
pub mod shift;
|
||||
|
||||
+99
-21
@@ -1,28 +1,106 @@
|
||||
// import database::storage::Schedule for saved schedule rows
|
||||
// import database::storage::DayOfWeek and WorkTime for schedule grouping
|
||||
// import user_queries when reading and writing schedule rows
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub struct ScheduleModelGuide;
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub fn define_schedule_fields() {
|
||||
// add user_id to identify the employee
|
||||
// add company_id if schedules need to be filtered by company
|
||||
// add day_of_week for the assigned day
|
||||
// add shift value for morning, afternoon, or evening
|
||||
// add enter_time and out_time for clock-in and clock-out
|
||||
use crate::{
|
||||
database::{
|
||||
entities::Schedule,
|
||||
queries::schedule as schedule_queries,
|
||||
types::{DayOfWeek, WorkTime},
|
||||
},
|
||||
manager::validator,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScheduleFields {
|
||||
pub user_id: &'static str,
|
||||
pub company_id: &'static str,
|
||||
pub day_of_week: &'static str,
|
||||
pub shift: &'static str,
|
||||
pub enter_time: &'static str,
|
||||
pub out_time: &'static str,
|
||||
}
|
||||
|
||||
pub fn register_user_enter_and_out() {
|
||||
// check if the employee already worked on the same day
|
||||
// reject the entry if the employee already has a schedule for that day
|
||||
// check if the employee has already worked five days this week
|
||||
// save enter_time when the employee starts working
|
||||
// update out_time when the employee leaves
|
||||
pub type WeeklyScheduleGroups = BTreeMap<String, BTreeMap<String, Vec<String>>>;
|
||||
|
||||
pub fn define_schedule_fields() -> ScheduleFields {
|
||||
ScheduleFields {
|
||||
user_id: "i32",
|
||||
company_id: "i32",
|
||||
day_of_week: "DayOfWeek",
|
||||
shift: "WorkTime",
|
||||
enter_time: "NaiveDateTime",
|
||||
out_time: "Option<NaiveDateTime>",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn output_weekly_schedule() {
|
||||
// group saved schedules by day of week
|
||||
// inside each day, group schedules by shift
|
||||
// list employee names for each shift
|
||||
// mark missing coverage when a shift has fewer than two employees
|
||||
pub async fn register_user_enter_and_out(
|
||||
pool: &PgPool,
|
||||
user_id: i32,
|
||||
enter_time: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: Option<NaiveDateTime>,
|
||||
shift: WorkTime,
|
||||
) -> Result<Option<Schedule>, sqlx::Error> {
|
||||
if !validator::validate_employee_can_join_shift_in_week(
|
||||
pool,
|
||||
user_id,
|
||||
day_of_week,
|
||||
shift,
|
||||
enter_time,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(
|
||||
schedule_queries::add_schedule(pool, user_id, enter_time, day_of_week, out_time, shift)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn output_weekly_schedule(
|
||||
pool: &PgPool,
|
||||
company_id: Option<i32>,
|
||||
) -> Result<WeeklyScheduleGroups, sqlx::Error> {
|
||||
let schedules = schedule_queries::list_weekly(pool, company_id).await?;
|
||||
let mut grouped: WeeklyScheduleGroups = BTreeMap::new();
|
||||
|
||||
for schedule in schedules {
|
||||
let day = day_label(schedule.day_of_week).to_string();
|
||||
let shift = shift_label(schedule.shift).to_string();
|
||||
let name = format!("{} {}", schedule.user_name, schedule.user_last_name);
|
||||
grouped
|
||||
.entry(day)
|
||||
.or_default()
|
||||
.entry(shift)
|
||||
.or_default()
|
||||
.push(name);
|
||||
}
|
||||
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
fn day_label(day: DayOfWeek) -> &'static str {
|
||||
match day {
|
||||
DayOfWeek::Sunday => "sunday",
|
||||
DayOfWeek::Monday => "monday",
|
||||
DayOfWeek::Tuesday => "tuesday",
|
||||
DayOfWeek::Wednesday => "wednesday",
|
||||
DayOfWeek::Thursday => "thursday",
|
||||
DayOfWeek::Friday => "friday",
|
||||
DayOfWeek::Saturday => "saturday",
|
||||
}
|
||||
}
|
||||
|
||||
fn shift_label(shift: WorkTime) -> &'static str {
|
||||
match shift {
|
||||
WorkTime::Morning => "morning",
|
||||
WorkTime::Afternoon => "afternoon",
|
||||
WorkTime::Evening => "evening",
|
||||
}
|
||||
}
|
||||
|
||||
+52
-14
@@ -1,21 +1,59 @@
|
||||
// import database::types::WorkTime for morning, afternoon, and evening shifts
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub struct ShiftModelGuide;
|
||||
use crate::{
|
||||
database::{
|
||||
queries::schedule as schedule_queries,
|
||||
types::{DayOfWeek, WorkTime},
|
||||
},
|
||||
manager::validator,
|
||||
};
|
||||
|
||||
pub fn define_shift_values() {
|
||||
// use one value for morning shift
|
||||
// use one value for afternoon shift
|
||||
// use one value for evening shift
|
||||
// keep the names consistent with the database enum values
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ShiftCapacity {
|
||||
pub count: i64,
|
||||
pub has_minimum_two: bool,
|
||||
pub needs_coverage: bool,
|
||||
pub is_full: bool,
|
||||
pub should_add_employee: bool,
|
||||
pub should_resolve_conflict: bool,
|
||||
}
|
||||
|
||||
pub fn define_day_values() {
|
||||
// use Sunday through Saturday from database::storage::DayOfWeek
|
||||
// loop over all seven days when generating the weekly schedule
|
||||
pub fn define_shift_values() -> [WorkTime; 3] {
|
||||
[WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening]
|
||||
}
|
||||
|
||||
pub fn compare_shift_capacity() {
|
||||
// count how many employees are assigned to one shift on one day
|
||||
// reject or fill the shift if fewer than two employees are assigned
|
||||
// detect conflicts when a preferred shift is already full
|
||||
pub fn define_day_values() -> [DayOfWeek; 7] {
|
||||
[
|
||||
DayOfWeek::Sunday,
|
||||
DayOfWeek::Monday,
|
||||
DayOfWeek::Tuesday,
|
||||
DayOfWeek::Wednesday,
|
||||
DayOfWeek::Thursday,
|
||||
DayOfWeek::Friday,
|
||||
DayOfWeek::Saturday,
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn compare_shift_capacity(
|
||||
pool: &PgPool,
|
||||
company_id: i32,
|
||||
day_of_week: DayOfWeek,
|
||||
shift: WorkTime,
|
||||
) -> Result<ShiftCapacity, sqlx::Error> {
|
||||
let count =
|
||||
schedule_queries::count_by_day_shift(pool, day_of_week, shift, Some(company_id)).await?;
|
||||
let has_minimum_two =
|
||||
validator::validate_shift_has_minimum_two_employees(pool, company_id, day_of_week, shift)
|
||||
.await?;
|
||||
|
||||
Ok(ShiftCapacity {
|
||||
count,
|
||||
has_minimum_two,
|
||||
needs_coverage: !has_minimum_two,
|
||||
is_full: count >= 2,
|
||||
should_add_employee: count < 2,
|
||||
should_resolve_conflict: count >= 2,
|
||||
})
|
||||
}
|
||||
|
||||
+554
-104
@@ -1,10 +1,13 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
extract::{Multipart, Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use chrono::{Datelike, NaiveDateTime, Utc, Weekday};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use tower_http::cors::CorsLayer;
|
||||
@@ -12,16 +15,20 @@ use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::{
|
||||
app::{ApiResult, AppState},
|
||||
app::{ApiError, ApiResult, AppState, ErrorResponse},
|
||||
database::{
|
||||
entities::{Company, CompanyUser, Schedule, User, WeeklyPreference},
|
||||
entities::{Company, CompanyUser, Schedule},
|
||||
queries::{
|
||||
company as company_queries, employee as employee_queries,
|
||||
preference as preference_queries, schedule as schedule_queries, user as user_queries,
|
||||
},
|
||||
types::{DayOfWeek, WorkTime},
|
||||
},
|
||||
models::company::{requests::CreateCompanyRequest, service as company_service},
|
||||
manager::scheduler as schedule_manager,
|
||||
models::{
|
||||
company::{requests::CreateCompanyRequest, service as company_service},
|
||||
schedule as schedule_model,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
@@ -37,6 +44,14 @@ struct CreateEmployeeRequest {
|
||||
preference: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct UpdateEmployeeRequest {
|
||||
company_id: Option<i32>,
|
||||
name: String,
|
||||
last_name: String,
|
||||
preference: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct RegisterPreferenceRequest {
|
||||
preference: WorkTime,
|
||||
@@ -55,17 +70,89 @@ struct DayPreferenceRequest {
|
||||
third: WorkTime,
|
||||
}
|
||||
|
||||
fn deserialize_naive_datetime<'de, D>(deserializer: D) -> Result<NaiveDateTime, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if let Ok(dt) = s.parse::<DateTime<Utc>>() {
|
||||
return Ok(dt.naive_utc());
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&s) {
|
||||
return Ok(dt.with_timezone(&Utc).naive_utc());
|
||||
}
|
||||
for fmt in &[
|
||||
"%Y-%m-%dT%H:%M:%S%.3fZ",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S%.3f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S%.3f",
|
||||
] {
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(&s, fmt) {
|
||||
return Ok(dt);
|
||||
}
|
||||
}
|
||||
Err(serde::de::Error::custom(format!("invalid datetime: {s}")))
|
||||
}
|
||||
|
||||
fn deserialize_optional_naive_datetime<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<NaiveDateTime>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt = Option::<String>::deserialize(deserializer)?;
|
||||
match opt {
|
||||
None => Ok(None),
|
||||
Some(s) if s.is_empty() => Ok(None),
|
||||
Some(s) => {
|
||||
if let Ok(dt) = s.parse::<DateTime<Utc>>() {
|
||||
return Ok(Some(dt.naive_utc()));
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&s) {
|
||||
return Ok(Some(dt.with_timezone(&Utc).naive_utc()));
|
||||
}
|
||||
for fmt in &[
|
||||
"%Y-%m-%dT%H:%M:%S%.3fZ",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S%.3f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S%.3f",
|
||||
] {
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(&s, fmt) {
|
||||
return Ok(Some(dt));
|
||||
}
|
||||
}
|
||||
Err(serde::de::Error::custom(format!("invalid datetime: {s}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct CreateScheduleRequest {
|
||||
user_id: i32,
|
||||
#[serde(deserialize_with = "deserialize_naive_datetime")]
|
||||
enter_time: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: NaiveDateTime,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_naive_datetime")]
|
||||
out_time: Option<NaiveDateTime>,
|
||||
shift: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct ClockInRequest {
|
||||
day_of_week: DayOfWeek,
|
||||
#[serde(deserialize_with = "deserialize_naive_datetime")]
|
||||
enter_time: NaiveDateTime,
|
||||
shift: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct ClockOutRequest {
|
||||
day_of_week: DayOfWeek,
|
||||
#[serde(deserialize_with = "deserialize_naive_datetime")]
|
||||
out_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
@@ -89,11 +176,35 @@ struct ScheduleResponse {
|
||||
user_id: i32,
|
||||
enter_time: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: NaiveDateTime,
|
||||
out_time: Option<NaiveDateTime>,
|
||||
shift: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct WeeklyScheduleUserResponse {
|
||||
name: String,
|
||||
last_name: String,
|
||||
preference: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct WeeklyScheduleItemResponse {
|
||||
id: i32,
|
||||
user: WeeklyScheduleUserResponse,
|
||||
company: String,
|
||||
enter_time: NaiveDateTime,
|
||||
day_of_week: DayOfWeek,
|
||||
out_time: Option<NaiveDateTime>,
|
||||
shift: WorkTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct WeeklyScheduleResponse {
|
||||
schedules: Vec<WeeklyScheduleItemResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct GeneratedWeeklyScheduleResponse {
|
||||
schedules: Vec<ScheduleResponse>,
|
||||
}
|
||||
|
||||
@@ -107,17 +218,22 @@ struct EmployeeNamesResponse {
|
||||
paths(
|
||||
health_check,
|
||||
create_company,
|
||||
upload_companies,
|
||||
list_companies,
|
||||
get_company,
|
||||
delete_company,
|
||||
create_employee,
|
||||
upload_employees,
|
||||
list_employees,
|
||||
update_employee,
|
||||
patch_employee,
|
||||
assign_employee_to_company,
|
||||
delete_employee,
|
||||
register_employee_preference,
|
||||
register_weekly_employee_preferences,
|
||||
get_employee_names_by_company,
|
||||
create_schedule,
|
||||
upload_schedules,
|
||||
get_weekly_schedule,
|
||||
generate_weekly_schedule,
|
||||
clock_in,
|
||||
@@ -130,16 +246,21 @@ struct EmployeeNamesResponse {
|
||||
CreateCompanyRequest,
|
||||
HealthResponse,
|
||||
CreateEmployeeRequest,
|
||||
UpdateEmployeeRequest,
|
||||
RegisterPreferenceRequest,
|
||||
WeeklyPreferenceRequest,
|
||||
DayPreferenceRequest,
|
||||
CreateScheduleRequest,
|
||||
ClockInRequest,
|
||||
ClockOutRequest,
|
||||
GenerateWeeklyScheduleRequest,
|
||||
EmployeeResponse,
|
||||
EmployeeNamesResponse,
|
||||
ScheduleResponse,
|
||||
WeeklyScheduleResponse,
|
||||
WeeklyScheduleItemResponse,
|
||||
WeeklyScheduleUserResponse,
|
||||
GeneratedWeeklyScheduleResponse,
|
||||
WorkTime,
|
||||
DayOfWeek
|
||||
)
|
||||
@@ -160,13 +281,20 @@ pub fn build_router(pool: PgPool) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/companies", post(create_company).get(list_companies))
|
||||
.route("/companies/upload", post(upload_companies))
|
||||
.route("/companies/{id}", get(get_company).delete(delete_company))
|
||||
.route("/employees", post(create_employee).get(list_employees))
|
||||
.route("/employees/upload", post(upload_employees))
|
||||
.route(
|
||||
"/companies/{company_id}/employees/{employee_id}",
|
||||
post(assign_employee_to_company),
|
||||
)
|
||||
.route("/employees/{id}", axum::routing::delete(delete_employee))
|
||||
.route(
|
||||
"/employees/{id}",
|
||||
put(update_employee)
|
||||
.patch(patch_employee)
|
||||
.delete(delete_employee),
|
||||
)
|
||||
.route(
|
||||
"/employees/{id}/preference",
|
||||
put(register_employee_preference),
|
||||
@@ -180,6 +308,7 @@ pub fn build_router(pool: PgPool) -> Router {
|
||||
get(get_employee_names_by_company),
|
||||
)
|
||||
.route("/schedules", post(create_schedule))
|
||||
.route("/schedules/upload", post(upload_schedules))
|
||||
.route("/schedules/weekly", get(get_weekly_schedule))
|
||||
.route("/schedules/weekly/generate", post(generate_weekly_schedule))
|
||||
.route("/employees/{id}/clock-in", post(clock_in))
|
||||
@@ -189,18 +318,6 @@ pub fn build_router(pool: PgPool) -> Router {
|
||||
.with_state(AppState { pool })
|
||||
}
|
||||
|
||||
fn day_of_week_from_weekday(weekday: Weekday) -> DayOfWeek {
|
||||
match weekday {
|
||||
Weekday::Mon => DayOfWeek::Monday,
|
||||
Weekday::Tue => DayOfWeek::Tuesday,
|
||||
Weekday::Wed => DayOfWeek::Wednesday,
|
||||
Weekday::Thu => DayOfWeek::Thursday,
|
||||
Weekday::Fri => DayOfWeek::Friday,
|
||||
Weekday::Sat => DayOfWeek::Saturday,
|
||||
Weekday::Sun => DayOfWeek::Sunday,
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_response(schedule: Schedule) -> ScheduleResponse {
|
||||
ScheduleResponse {
|
||||
id: schedule.id,
|
||||
@@ -208,6 +325,7 @@ fn schedule_response(schedule: Schedule) -> ScheduleResponse {
|
||||
enter_time: schedule.enter_time,
|
||||
day_of_week: schedule.day_of_week,
|
||||
out_time: schedule.out_time,
|
||||
shift: schedule.shift,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,36 +339,140 @@ fn employee_response(employee: employee_queries::EmployeeRow) -> EmployeeRespons
|
||||
}
|
||||
}
|
||||
|
||||
fn all_days() -> [DayOfWeek; 7] {
|
||||
[
|
||||
DayOfWeek::Monday,
|
||||
DayOfWeek::Tuesday,
|
||||
DayOfWeek::Wednesday,
|
||||
DayOfWeek::Thursday,
|
||||
DayOfWeek::Friday,
|
||||
DayOfWeek::Saturday,
|
||||
DayOfWeek::Sunday,
|
||||
]
|
||||
}
|
||||
|
||||
fn all_shifts() -> [WorkTime; 3] {
|
||||
[WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening]
|
||||
}
|
||||
|
||||
fn preference_rank(
|
||||
preference: Option<&WeeklyPreference>,
|
||||
shift: WorkTime,
|
||||
fallback: WorkTime,
|
||||
) -> i32 {
|
||||
match preference {
|
||||
Some(preference) if preference.first_preference == shift => 0,
|
||||
Some(preference) if preference.second_preference == shift => 1,
|
||||
Some(preference) if preference.third_preference == shift => 2,
|
||||
_ if fallback == shift => 3,
|
||||
_ => 4,
|
||||
fn weekly_schedule_item(row: schedule_queries::WeeklyScheduleRow) -> WeeklyScheduleItemResponse {
|
||||
WeeklyScheduleItemResponse {
|
||||
id: row.id,
|
||||
user: WeeklyScheduleUserResponse {
|
||||
name: row.user_name,
|
||||
last_name: row.user_last_name,
|
||||
preference: row.user_preference,
|
||||
},
|
||||
company: row.company,
|
||||
enter_time: row.enter_time,
|
||||
day_of_week: row.day_of_week,
|
||||
out_time: row.out_time,
|
||||
shift: row.shift,
|
||||
}
|
||||
}
|
||||
|
||||
fn day_of_week_from_naive(dt: NaiveDateTime) -> DayOfWeek {
|
||||
use chrono::Datelike;
|
||||
match dt.weekday() {
|
||||
chrono::Weekday::Mon => DayOfWeek::Monday,
|
||||
chrono::Weekday::Tue => DayOfWeek::Tuesday,
|
||||
chrono::Weekday::Wed => DayOfWeek::Wednesday,
|
||||
chrono::Weekday::Thu => DayOfWeek::Thursday,
|
||||
chrono::Weekday::Fri => DayOfWeek::Friday,
|
||||
chrono::Weekday::Sat => DayOfWeek::Saturday,
|
||||
chrono::Weekday::Sun => DayOfWeek::Sunday,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_schedule_times(
|
||||
_day_of_week: DayOfWeek,
|
||||
enter_time: NaiveDateTime,
|
||||
out_time: Option<NaiveDateTime>,
|
||||
) -> Result<(), Response> {
|
||||
// day_of_week is provided as local day (e.g. Friday) while enter_time is UTC (e.g. 2026-09-19T01:39:00Z -> Saturday UTC)
|
||||
// strict UTC weekday check rejects valid local times, so only validate out_time ordering
|
||||
if let Some(out) = out_time {
|
||||
if out < enter_time {
|
||||
return Err(message_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"out_time must be after enter_time",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn message_response(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(ErrorResponse {
|
||||
message: message.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn multipart_file_text(mut multipart: Multipart) -> Result<String, Response> {
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid multipart form"))?
|
||||
{
|
||||
if field.name() == Some("file") {
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid upload file"))?;
|
||||
return String::from_utf8(bytes.to_vec()).map_err(|_| {
|
||||
message_response(StatusCode::BAD_REQUEST, "Upload file must be UTF-8")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(message_response(StatusCode::BAD_REQUEST, "Missing file"))
|
||||
}
|
||||
|
||||
fn parse_csv_rows(contents: &str) -> Vec<HashMap<String, String>> {
|
||||
let contents = contents.trim_start_matches('\u{feff}');
|
||||
let mut lines = contents.lines();
|
||||
let Some(header) = lines.next() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let headers: Vec<&str> = header.split(',').map(str::trim).collect();
|
||||
|
||||
lines
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
headers
|
||||
.iter()
|
||||
.zip(line.split(','))
|
||||
.map(|(key, value)| ((*key).to_string(), value.trim().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn required_field<'a>(row: &'a HashMap<String, String>, field: &str) -> Result<&'a str, Response> {
|
||||
row.get(field)
|
||||
.map(String::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| message_response(StatusCode::BAD_REQUEST, &format!("Missing {field}")))
|
||||
}
|
||||
|
||||
fn parse_work_time(value: &str) -> Result<WorkTime, Response> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"morning" => Ok(WorkTime::Morning),
|
||||
"afternoon" => Ok(WorkTime::Afternoon),
|
||||
"evening" => Ok(WorkTime::Evening),
|
||||
_ => Err(message_response(StatusCode::BAD_REQUEST, "Invalid shift")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_day_of_week(value: &str) -> Result<DayOfWeek, Response> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"sunday" => Ok(DayOfWeek::Sunday),
|
||||
"monday" => Ok(DayOfWeek::Monday),
|
||||
"tuesday" => Ok(DayOfWeek::Tuesday),
|
||||
"wednesday" => Ok(DayOfWeek::Wednesday),
|
||||
"thursday" => Ok(DayOfWeek::Thursday),
|
||||
"friday" => Ok(DayOfWeek::Friday),
|
||||
"saturday" => Ok(DayOfWeek::Saturday),
|
||||
_ => Err(message_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid day_of_week",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_datetime(value: &str) -> Result<NaiveDateTime, Response> {
|
||||
NaiveDateTime::parse_from_str(value.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid datetime"))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
@@ -281,6 +503,35 @@ async fn create_company(
|
||||
Ok(Json(company))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/companies/upload",
|
||||
tag = "companies",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Companies uploaded"),
|
||||
(status = 400, description = "Invalid upload"),
|
||||
(status = 500, description = "Database error")
|
||||
)
|
||||
)]
|
||||
async fn upload_companies(
|
||||
State(state): State<AppState>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let contents = multipart_file_text(multipart).await?;
|
||||
let mut data = Map::new();
|
||||
|
||||
for (idx, row) in parse_csv_rows(&contents).iter().enumerate() {
|
||||
let name = required_field(row, "name")?;
|
||||
company_queries::add_company(&state.pool, name)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
data.insert(idx.to_string(), json!(name));
|
||||
}
|
||||
|
||||
Ok(Json(Value::Object(data)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/companies",
|
||||
@@ -363,6 +614,47 @@ async fn create_employee(
|
||||
Ok(Json(employee_response(employee)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/employees/upload",
|
||||
tag = "employees",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Employees uploaded"),
|
||||
(status = 400, description = "Invalid upload"),
|
||||
(status = 500, description = "Database error")
|
||||
)
|
||||
)]
|
||||
async fn upload_employees(
|
||||
State(state): State<AppState>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let contents = multipart_file_text(multipart).await?;
|
||||
let mut data = Map::new();
|
||||
|
||||
for (idx, row) in parse_csv_rows(&contents).iter().enumerate() {
|
||||
let company_id = required_field(row, "company_id")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid company_id"))?;
|
||||
let name = required_field(row, "name")?;
|
||||
let last_name = required_field(row, "last_name")?;
|
||||
let preference = parse_work_time(required_field(row, "preference")?)?;
|
||||
|
||||
company_queries::get_company_by_id(&state.pool, company_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
let user = user_queries::add_user(&state.pool, name, last_name, preference)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
company_queries::add_company_user(&state.pool, company_id, user.id)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
data.insert(idx.to_string(), json!(name));
|
||||
}
|
||||
|
||||
Ok(Json(Value::Object(data)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/employees",
|
||||
@@ -402,6 +694,69 @@ async fn assign_employee_to_company(
|
||||
Ok(Json(company_user))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/employees/{id}",
|
||||
tag = "employees",
|
||||
params(
|
||||
("id" = i32, Path, description = "Employee id")
|
||||
),
|
||||
request_body = UpdateEmployeeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Employee updated", body = EmployeeResponse),
|
||||
(status = 404, description = "Employee or company not found"),
|
||||
(status = 500, description = "Database error")
|
||||
)
|
||||
)]
|
||||
async fn update_employee(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
Json(payload): Json<UpdateEmployeeRequest>,
|
||||
) -> ApiResult<Json<EmployeeResponse>> {
|
||||
employee_queries::get_employee(&state.pool, id).await?;
|
||||
if let Some(company_id) = payload.company_id {
|
||||
company_queries::get_company_by_id(&state.pool, company_id).await?;
|
||||
}
|
||||
|
||||
employee_queries::update_employee(
|
||||
&state.pool,
|
||||
id,
|
||||
payload.name.trim(),
|
||||
payload.last_name.trim(),
|
||||
payload.preference,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(company_id) = payload.company_id {
|
||||
employee_queries::update_company(&state.pool, id, company_id).await?;
|
||||
}
|
||||
|
||||
let employee = employee_queries::get_employee(&state.pool, id).await?;
|
||||
Ok(Json(employee_response(employee)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/employees/{id}",
|
||||
tag = "employees",
|
||||
params(
|
||||
("id" = i32, Path, description = "Employee id")
|
||||
),
|
||||
request_body = UpdateEmployeeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Employee updated", body = EmployeeResponse),
|
||||
(status = 404, description = "Employee or company not found"),
|
||||
(status = 500, description = "Database error")
|
||||
)
|
||||
)]
|
||||
async fn patch_employee(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
Json(payload): Json<UpdateEmployeeRequest>,
|
||||
) -> ApiResult<Json<EmployeeResponse>> {
|
||||
update_employee(State(state), Path(id), Json(payload)).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/employees/{id}",
|
||||
@@ -500,6 +855,7 @@ async fn get_employee_names_by_company(
|
||||
State(state): State<AppState>,
|
||||
Path(company_name): Path<String>,
|
||||
) -> ApiResult<Json<EmployeeNamesResponse>> {
|
||||
company_queries::get_company_by_name(&state.pool, &company_name).await?;
|
||||
let employees =
|
||||
employee_queries::get_employee_names_by_company_name(&state.pool, &company_name).await?;
|
||||
Ok(Json(EmployeeNamesResponse { employees }))
|
||||
@@ -518,18 +874,81 @@ async fn get_employee_names_by_company(
|
||||
async fn create_schedule(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateScheduleRequest>,
|
||||
) -> ApiResult<Json<ScheduleResponse>> {
|
||||
let schedule = schedule_queries::add_schedule(
|
||||
) -> Result<Json<ScheduleResponse>, Response> {
|
||||
validate_schedule_times(payload.day_of_week, payload.enter_time, payload.out_time)?;
|
||||
let schedule = schedule_model::register_user_enter_and_out(
|
||||
&state.pool,
|
||||
payload.user_id,
|
||||
payload.enter_time,
|
||||
payload.day_of_week,
|
||||
payload.out_time,
|
||||
payload.shift,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| ApiError::from(e).into_response())?
|
||||
.ok_or_else(|| ApiError::from(sqlx::Error::RowNotFound).into_response())?;
|
||||
Ok(Json(schedule_response(schedule)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/schedules/upload",
|
||||
tag = "schedules",
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Schedules uploaded"),
|
||||
(status = 400, description = "Invalid upload"),
|
||||
(status = 500, description = "Database error")
|
||||
)
|
||||
)]
|
||||
async fn upload_schedules(
|
||||
State(state): State<AppState>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let contents = multipart_file_text(multipart).await?;
|
||||
let mut data = Map::new();
|
||||
|
||||
for (idx, row) in parse_csv_rows(&contents).iter().enumerate() {
|
||||
let user_id = required_field(row, "user_id")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid user_id"))?;
|
||||
let enter_time = parse_datetime(required_field(row, "enter_time")?)?;
|
||||
let out_time = parse_datetime(required_field(row, "out_time")?)?;
|
||||
let day_of_week = parse_day_of_week(required_field(row, "day_of_week")?)?;
|
||||
let shift = parse_work_time(required_field(row, "shift")?)?;
|
||||
|
||||
validate_schedule_times(day_of_week, enter_time, Some(out_time))?;
|
||||
|
||||
if employee_queries::get_employee(&state.pool, user_id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let schedule = schedule_queries::add_schedule(
|
||||
&state.pool,
|
||||
user_id,
|
||||
enter_time,
|
||||
day_of_week,
|
||||
None,
|
||||
shift,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
let schedule =
|
||||
schedule_queries::update_clock_in_by_id(&state.pool, schedule.id, enter_time)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
let schedule = schedule_queries::update_clock_out_by_id(&state.pool, schedule.id, out_time)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
data.insert(idx.to_string(), json!(schedule_response(schedule)));
|
||||
}
|
||||
|
||||
Ok(Json(Value::Object(data)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/schedules/weekly",
|
||||
@@ -542,9 +961,11 @@ async fn create_schedule(
|
||||
async fn get_weekly_schedule(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<WeeklyScheduleResponse>> {
|
||||
let schedules = schedule_queries::list_schedules(&state.pool).await?;
|
||||
let _fields = schedule_model::define_schedule_fields();
|
||||
let _grouped = schedule_model::output_weekly_schedule(&state.pool, None).await?;
|
||||
let schedules = schedule_queries::list_weekly(&state.pool, None).await?;
|
||||
Ok(Json(WeeklyScheduleResponse {
|
||||
schedules: schedules.into_iter().map(schedule_response).collect(),
|
||||
schedules: schedules.into_iter().map(weekly_schedule_item).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -562,52 +983,12 @@ async fn generate_weekly_schedule(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<GenerateWeeklyScheduleRequest>,
|
||||
) -> ApiResult<Json<WeeklyScheduleResponse>> {
|
||||
let employees = company_queries::get_users_by_company(&state.pool, payload.company_id).await?;
|
||||
let preferences =
|
||||
preference_queries::get_preferences_for_company(&state.pool, payload.company_id).await?;
|
||||
let mut preferences_by_user_day = HashMap::new();
|
||||
|
||||
for preference in &preferences {
|
||||
preferences_by_user_day.insert((preference.user_id, preference.day_of_week), preference);
|
||||
}
|
||||
|
||||
schedule_queries::delete_schedules_for_company(&state.pool, payload.company_id).await?;
|
||||
|
||||
let mut assigned_days: HashMap<(i32, DayOfWeek), bool> = HashMap::new();
|
||||
let mut day_count_by_user: HashMap<i32, i32> = HashMap::new();
|
||||
let mut generated = Vec::new();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
for day in all_days() {
|
||||
for shift in all_shifts() {
|
||||
let mut candidates: Vec<&User> = employees
|
||||
.iter()
|
||||
.filter(|employee| !assigned_days.contains_key(&(employee.id, day)))
|
||||
.filter(|employee| day_count_by_user.get(&employee.id).copied().unwrap_or(0) < 5)
|
||||
.collect();
|
||||
|
||||
candidates.sort_by_key(|employee| {
|
||||
preference_rank(
|
||||
preferences_by_user_day.get(&(employee.id, day)).copied(),
|
||||
shift,
|
||||
employee.preference,
|
||||
)
|
||||
});
|
||||
|
||||
for employee in candidates.into_iter().take(2) {
|
||||
let shift_end = now + chrono::Duration::hours(8);
|
||||
let schedule =
|
||||
schedule_queries::add_schedule(&state.pool, employee.id, now, day, shift_end)
|
||||
.await?;
|
||||
assigned_days.insert((employee.id, day), true);
|
||||
*day_count_by_user.entry(employee.id).or_insert(0) += 1;
|
||||
generated.push(schedule_response(schedule));
|
||||
}
|
||||
}
|
||||
}
|
||||
company_queries::get_company_by_id(&state.pool, payload.company_id).await?;
|
||||
schedule_manager::generate_weekly_schedule(&state.pool, payload.company_id).await?;
|
||||
let schedules = schedule_queries::list_weekly(&state.pool, Some(payload.company_id)).await?;
|
||||
|
||||
Ok(Json(WeeklyScheduleResponse {
|
||||
schedules: generated,
|
||||
schedules: schedules.into_iter().map(weekly_schedule_item).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -618,20 +999,64 @@ async fn generate_weekly_schedule(
|
||||
params(
|
||||
("id" = i32, Path, description = "Employee id")
|
||||
),
|
||||
request_body = ClockInRequest,
|
||||
responses(
|
||||
(status = 200, description = "Clock-in registered", body = ScheduleResponse),
|
||||
(status = 404, description = "Employee not found"),
|
||||
(status = 409, description = "Employee schedule conflict"),
|
||||
(status = 500, description = "Database error")
|
||||
)
|
||||
)]
|
||||
async fn clock_in(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
) -> ApiResult<Json<ScheduleResponse>> {
|
||||
let now_utc = Utc::now();
|
||||
let day_of_week = day_of_week_from_weekday(now_utc.weekday());
|
||||
let schedule =
|
||||
schedule_queries::clock_in(&state.pool, id, now_utc.naive_utc(), day_of_week).await?;
|
||||
Json(payload): Json<ClockInRequest>,
|
||||
) -> Result<Json<ScheduleResponse>, Response> {
|
||||
employee_queries::get_employee(&state.pool, id)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
|
||||
validate_schedule_times(payload.day_of_week, payload.enter_time, None)?;
|
||||
|
||||
let (week_start, week_end) = crate::manager::validator::week_bounds(payload.enter_time);
|
||||
|
||||
if schedule_queries::get_by_user_and_day_in_week(
|
||||
&state.pool,
|
||||
id,
|
||||
payload.day_of_week,
|
||||
week_start,
|
||||
week_end,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?
|
||||
.is_some()
|
||||
{
|
||||
return Err(message_response(
|
||||
StatusCode::CONFLICT,
|
||||
"Employee already worked this day",
|
||||
));
|
||||
}
|
||||
|
||||
if schedule_queries::count_distinct_days_by_user_in_week(&state.pool, id, week_start, week_end)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?
|
||||
>= 5
|
||||
{
|
||||
return Err(message_response(
|
||||
StatusCode::CONFLICT,
|
||||
"employee already worked 5 days",
|
||||
));
|
||||
}
|
||||
|
||||
let schedule = schedule_queries::clock_in(
|
||||
&state.pool,
|
||||
id,
|
||||
payload.enter_time,
|
||||
payload.day_of_week,
|
||||
payload.shift,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_response())?;
|
||||
Ok(Json(schedule_response(schedule)))
|
||||
}
|
||||
|
||||
@@ -653,8 +1078,33 @@ async fn clock_out(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i32>,
|
||||
Json(payload): Json<ClockOutRequest>,
|
||||
) -> ApiResult<Json<ScheduleResponse>> {
|
||||
) -> Result<Json<ScheduleResponse>, Response> {
|
||||
// allow UTC vs local day drift for out_time as well
|
||||
let _ = day_of_week_from_naive(payload.out_time);
|
||||
|
||||
// ensure out_time is after the existing enter_time for that day/week
|
||||
let (week_start, week_end) = crate::manager::validator::week_bounds(payload.out_time);
|
||||
if let Some(existing) = schedule_queries::get_by_user_and_day_in_week(
|
||||
&state.pool,
|
||||
id,
|
||||
payload.day_of_week,
|
||||
week_start,
|
||||
week_end,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::from(e).into_response())?
|
||||
{
|
||||
if payload.out_time < existing.enter_time {
|
||||
return Err(message_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"out_time must be after enter_time",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let schedule =
|
||||
schedule_queries::clock_out(&state.pool, id, payload.day_of_week, payload.out_time).await?;
|
||||
schedule_queries::clock_out(&state.pool, id, payload.day_of_week, payload.out_time)
|
||||
.await
|
||||
.map_err(|e| ApiError::from(e).into_response())?;
|
||||
Ok(Json(schedule_response(schedule)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user