diff --git a/Cargo.lock b/Cargo.lock index 3600dd5..4804902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -1164,6 +1164,7 @@ dependencies = [ "serde", "sqlx", "tokio", + "tokio-stream", "tower 0.5.1", "tower-http", "tower-sessions", @@ -2657,9 +2658,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index 84a493d..52c317b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ axum-extra = "0.9.4" csv = "1.3.1" ron = "0.8.1" chrono = { version = "0.4.38", features = ["serde"] } +tokio-stream = "0.1.17" [dev-dependencies] httpc-test = "0.1.10" diff --git a/src/app/adjustment.rs b/src/app/adjustment.rs new file mode 100644 index 0000000..591010c --- /dev/null +++ b/src/app/adjustment.rs @@ -0,0 +1,129 @@ +use askama::Template; +use askama_axum::Response; +use axum::extract::{Path, State}; +use axum::{debug_handler, Form}; +use axum::response::IntoResponse; +use axum_htmx::{HxEvent, HxResponseTrigger}; +use oauth2::http; +use serde::Deserialize; +use sqlx::SqlitePool; +use tracing::info; +use chrono::FixedOffset; +use crate::db::adjustment::{add_adjustment, get_item_adjustment_valuation_weighted_mean, DbAdjustmentWithValuation}; +use crate::db::adjustment::adjustment_reason::DbAdjustmentReason; +use crate::db::inventory_item::inventory_item_get_by_id_with_unit; +use crate::error::AppError; +use crate::session::SessionUser; +use crate::util::currency; +use crate::util::currency::int_cents_to_dollars_string; + + +#[derive(Template)] +#[template(path = "adjustments-table-fragment.html")] +struct AdjustmentTableTemplate { + item_id: i64, + adjustments: Vec, +} + +pub async fn get_adjustments_for_item(State(db): State, + Path(id): Path, + user: SessionUser +) -> Result { + + let timezone = user.get_timezone()?; + + let adjustments: Vec = get_item_adjustment_valuation_weighted_mean(&db, id).await?; + + + let adjustments: Vec = adjustments.into_iter() + .map(|x| AdjustmentDisplayItem::from((x, timezone))) + .collect(); + + Ok(AdjustmentTableTemplate { item_id: id, adjustments }.into_response()) +} + +#[derive(Template)] +#[template(path = "adjustments-add-form.html")] +pub struct AdjustmentAddFormTemplate { + pub item_id: i64, +} + +#[derive(Deserialize, Debug)] +pub struct AddAdjustmentFormData { + pub amount: f64, + pub reason: Option, +} + +#[debug_handler] +pub async fn adjustment_add_form_post(State(db): State, + Path(id): Path, + user: SessionUser, + form_data: Form +) -> Result { + + let trigger_events = HxResponseTrigger::normal( + std::iter::once(HxEvent::from("new-adjustment")) + ); + + let timestamp = chrono::Utc::now(); + + let adjustment_amount = if form_data.amount > 0.0 { + -1.0 * form_data.amount + } else { + form_data.amount + }; + + info!("Add adjustment form: {:?} for user {}", form_data, user.name); + + let _new_id = add_adjustment(&db, id, user.id, + timestamp, + timestamp, + adjustment_amount, + None, + DbAdjustmentReason::Sale).await?; + + + Ok((trigger_events, AdjustmentAddFormTemplate { item_id: id }.into_response()).into_response()) +} + +pub async fn adjustment_add_form_get( + Path(id): Path +) -> Result { + Ok(AdjustmentAddFormTemplate { item_id: id }.into_response()) +} + + +#[derive(Clone, Debug)] +pub struct AdjustmentDisplayItem { + pub date: String, + pub amount: String, + pub unit_value: String, + pub tally: String, + pub tally_value: String, + pub reason: String, +} + +impl From<(DbAdjustmentWithValuation, FixedOffset)> for AdjustmentDisplayItem { + fn from(item: (DbAdjustmentWithValuation, FixedOffset)) -> Self { + let db_entry = item.0; + let timezone = item.1; + let date = db_entry.adjustment.target_date.with_timezone(&timezone) + .format("%Y-%m-%d %l:%M:%S %p").to_string(); + let amount = db_entry.adjustment.amount.to_string(); + let unit_price = db_entry.adjustment.unit_price.unwrap_or_else(|| + (db_entry.value as f64 / db_entry.tally) as i64); + let unit_value = currency::int_cents_to_dollars_string(unit_price); + let tally = db_entry.tally.to_string(); + let tally_value = currency::int_cents_to_dollars_string(db_entry.value); + let reason = db_entry.adjustment.reason.into(); + + Self { + date, + amount, + unit_value, + tally, + tally_value, + reason, + } + } +} \ No newline at end of file diff --git a/src/app/history.rs b/src/app/history.rs index aa0347f..9e1036d 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,5 +1,5 @@ use crate::app::common::query_args::datetime_range::DatetimeRangeQueryArgs; -use crate::db::adjustment::{get_adjustments_target_date_range, DbAdjustment, DbAdjustmentWithUserAndItem}; +use crate::db::adjustment::{get_adjustments_target_date_range, DbAdjustment}; use crate::error::{AppError, QueryExtractor}; use crate::session::SessionUser; use crate::util::time::{tz_offset_to_string, LocalTimestampRange, UtcTimestampRange}; @@ -12,6 +12,7 @@ use chrono::prelude::*; use serde::Deserialize; use sqlx::SqlitePool; use tracing::info; +use crate::db::adjustment::adjustment_joins::DbAdjustmentWithUserAndItem; use crate::util::currency; #[derive(Template)] diff --git a/src/app/item.rs b/src/app/item.rs index dee7346..3e27edb 100644 --- a/src/app/item.rs +++ b/src/app/item.rs @@ -3,19 +3,52 @@ use askama_axum::IntoResponse; use axum::extract::{Path, State}; use sqlx::SqlitePool; use axum::response::Response; -use crate::db::inventory_item::{inventory_item_get_by_id, sum_all_adjustments_for_item, DbInventoryItem}; +use crate::app::adjustment::AdjustmentDisplayItem; +use crate::db::adjustment::{get_item_adjustment_valuation_weighted_mean, sum_all_adjustments_for_item, DbAdjustmentWithValuation}; +use crate::db::inventory_item::{inventory_item_get_by_id_with_unit, DbInventoryItemWithCount}; use crate::error::AppError; +use crate::session::SessionUser; +use crate::util::currency::int_cents_to_dollars_string; #[derive(Template)] #[template(path = "item.html")] struct ItemTemplate { - item: DbInventoryItem + pub item_id: i64, + pub item: ItemDisplayItem, } -pub async fn item(State(db): State, Path(id): Path) -> Result { - let item = inventory_item_get_by_id(&db, id).await?; +#[derive(Clone, Debug)] +struct ItemDisplayItem { + pub name: String, + pub reorder_point: f64, + pub allow_fractional_units: bool, + pub display_unit: String, + pub display_unit_short: String, + pub amount: String, +} + +impl From for ItemDisplayItem { + fn from(item: DbInventoryItemWithCount) -> Self { + let amount = item.amount.unwrap_or_default().to_string(); + let value = int_cents_to_dollars_string(0); + Self { + name: item.name, + reorder_point: item.reorder_point, + allow_fractional_units: item.allow_fractional_units, + display_unit: item.display_unit_str, + display_unit_short: item.display_unit_abbreviation, + amount, + } + } +} + +pub async fn item(State(db): State, + Path(id): Path, + user: SessionUser +) -> Result { + let mut item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into(); - Ok(ItemTemplate { item }.into_response()) + Ok(ItemTemplate { item_id: id, item }.into_response()) } diff --git a/src/app/mod.rs b/src/app/mod.rs index f681e6c..9fa42bd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,6 +16,7 @@ mod overview; mod reports; mod history; pub mod common; +pub mod adjustment; pub fn routes() -> Router { Router::new() @@ -28,6 +29,9 @@ pub fn routes() -> Router { .route("/item/:item_id", get(item::item)) .route("/item/:item_id/", get(item::item)) .route("/item/:item_id/count", get(item::item_count)) + .route("/item/:item_id/adjustments", get(adjustment::get_adjustments_for_item)) + .route("/item/:item_id/adjustment/new", post(adjustment::adjustment_add_form_post)) + .route("/item/:item_id/adjustment/new", get(adjustment::adjustment_add_form_get)) .route("/upload", get(upload::index::upload_index_handler)) .route("/upload/catalog", post(upload::catalog::catalog_import)) .route("/overview", get(overview::overview_handler)) diff --git a/src/db/adjustment.rs b/src/db/adjustment.rs deleted file mode 100644 index 4378aa0..0000000 --- a/src/db/adjustment.rs +++ /dev/null @@ -1,206 +0,0 @@ -use serde::Serialize; -use anyhow::Result; -use chrono::{DateTime, Utc}; -use sqlx::SqlitePool; -use tracing::error; - -#[derive(Clone, Debug, Serialize)] -#[derive(sqlx::FromRow)] -pub struct DbAdjustment { - pub id: i64, - pub item: i64, - pub user: i64, - pub create_date: DateTime, - pub target_date: DateTime, - pub amount: f64, - pub unit_price: Option, - #[sqlx(try_from = "i64")] - pub reason: DbAdjustmentReason, -} - - -pub async fn add_new_stock_adjustment(db: &SqlitePool, item: i64, user: i64, - create_date: DateTime, target_date: DateTime, - amount: f64, unit_price: i64) - -> Result { - let reason: i64 = DbAdjustmentReason::NewStock.into(); - let res = sqlx::query!( - r#" - INSERT INTO Adjustment ( - item, - user, - create_date, - target_date, - amount, - reason, - unit_price) - VALUES (?, ?, ?, ?, ?, ?, ?) - "#, - item, user, create_date, target_date, amount, reason, unit_price - ).execute(db).await?; - - let new_id = res.last_insert_rowid(); - - Ok(new_id) -} - -pub async fn get_adjustments_target_date_range( - db: &SqlitePool, start_date: DateTime, end_date: DateTime -) -> Result> { - - sqlx::query_as::<_, DbAdjustment>(r#" - SELECT id, item, user, - create_date, - target_date, - amount, - reason, - unit_price - FROM Adjustment - WHERE target_date >= ? AND target_date <= ? - "#,) - .bind(start_date) - .bind(end_date) - .fetch_all(db) - .await.map_err(Into::into) -} - - -#[derive(Clone, Debug, Serialize)] -#[derive(sqlx::FromRow)] -pub struct DbAdjustmentWithUserAndItem { - pub id: i64, - pub item: i64, - pub item_name: String, - pub item_unit: String, - pub item_unit_abbreviation: String, - pub user: i64, - pub user_name: String, - pub create_date: DateTime, - pub target_date: DateTime, - pub amount: f64, - pub unit_price: Option, - #[sqlx(try_from = "i64")] - pub reason: DbAdjustmentReason, -} - -impl DbAdjustmentWithUserAndItem { - pub async fn query_by_date_range(db: &SqlitePool, - start_date: DateTime, - end_date: DateTime, - page_size: i64, - page_num: i64) -> Result> { - let offset = page_size * page_num; - - sqlx::query_as::<_, DbAdjustmentWithUserAndItem>(r#" - SELECT - adj.id as id, - adj.item as item, - item.name as item_name, - unit.unit as item_unit, - unit.abbreviation as item_unit_abbreviation, - adj.user as user, - user.name as user_name, - adj.create_date as create_date, - adj.target_date as target_date, - adj.amount as amount, - adj.unit_price as unit_price, - adj.reason as reason - FROM Adjustment as adj - JOIN User as user on adj.user = user.id - JOIN InventoryItem as item on adj.item = item.id - JOIN DisplayUnit as unit on item.display_unit = unit.id - WHERE adj.target_date >= ? AND adj.target_date <= ? - LIMIT ? OFFSET ? - "#,) - .bind(start_date) - .bind(end_date) - .bind(page_size) - .bind(offset) - .fetch_all(db) - .await.map_err(Into::into) - } -} - -impl Into for DbAdjustmentWithUserAndItem { - fn into(self) -> DbAdjustment { - DbAdjustment { - id: self.id, - item: self.item, - user: self.user, - create_date: self.create_date, - target_date: self.target_date, - amount: self.amount, - unit_price: self.unit_price, - reason: self.reason, - } - } -} - -#[derive(Debug, Clone, Copy, Serialize)] -pub enum DbAdjustmentReason { - Unknown, - Sale, - Destruction, - Expiration, - Theft, - NewStock, -} - -impl Into for DbAdjustmentReason { - fn into(self) -> i64 { - match self { - Self::Unknown => 0, - Self::Sale => 10, - Self::Destruction => 20, - Self::Expiration => 25, - Self::Theft => 30, - Self::NewStock => 50, - } - } -} - -impl From for DbAdjustmentReason { - fn from(item: i64) -> Self { - match item { - 0 => Self::Unknown, - 10 => Self::Sale, - 20 => Self::Destruction, - 25 => Self::Expiration, - 30 => Self::Theft, - 50 => Self::NewStock, - _ => { - error!("unknown negative adjustment reason value: {}", item); - Self::Unknown - } - } - } -} - -impl TryFrom<&str> for DbAdjustmentReason { - type Error = anyhow::Error; - - fn try_from(value: &str) -> std::result::Result { - match value { - "unknown" => Ok(Self::Unknown), - "sale" => Ok(Self::Sale), - "destruction" => Ok(Self::Destruction), - "expiration" => Ok(Self::Expiration), - "theft" => Ok(Self::Theft), - "new-stock" => Ok(Self::NewStock), - _ => Err(anyhow::anyhow!("unknown negative adjustment reason")) - } - } -} - -impl Into for DbAdjustmentReason { - fn into(self) -> String { - match self { - Self::Unknown => { String::from("unknown") } - Self::Sale => { String::from("sale") } - Self::Destruction => { String::from("destruction") } - Self::Expiration => { String::from("expiration") } - Self::Theft => { String::from("theft") } - Self::NewStock => { String::from("new-stock") } - } - } -} \ No newline at end of file diff --git a/src/db/adjustment/adjustment_joins.rs b/src/db/adjustment/adjustment_joins.rs new file mode 100644 index 0000000..cb7ee69 --- /dev/null +++ b/src/db/adjustment/adjustment_joins.rs @@ -0,0 +1,76 @@ +use serde::Serialize; +use chrono::{DateTime, Utc}; +use sqlx::SqlitePool; +use crate::db::adjustment::adjustment_reason::DbAdjustmentReason; +use crate::db::adjustment::DbAdjustment; + +#[derive(Clone, Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbAdjustmentWithUserAndItem { + pub id: i64, + pub item: i64, + pub item_name: String, + pub item_unit: String, + pub item_unit_abbreviation: String, + pub user: i64, + pub user_name: String, + pub create_date: DateTime, + pub target_date: DateTime, + pub amount: f64, + pub unit_price: Option, + #[sqlx(try_from = "i64")] + pub reason: DbAdjustmentReason, +} + +impl DbAdjustmentWithUserAndItem { + pub async fn query_by_date_range(db: &SqlitePool, + start_date: DateTime, + end_date: DateTime, + page_size: i64, + page_num: i64) -> anyhow::Result> { + let offset = page_size * page_num; + + sqlx::query_as::<_, DbAdjustmentWithUserAndItem>(r#" + SELECT + adj.id as id, + adj.item as item, + item.name as item_name, + unit.unit as item_unit, + unit.abbreviation as item_unit_abbreviation, + adj.user as user, + user.name as user_name, + adj.create_date as create_date, + adj.target_date as target_date, + adj.amount as amount, + adj.unit_price as unit_price, + adj.reason as reason + FROM Adjustment as adj + JOIN User as user on adj.user = user.id + JOIN InventoryItem as item on adj.item = item.id + JOIN DisplayUnit as unit on item.display_unit = unit.id + WHERE adj.target_date >= ? AND adj.target_date <= ? + LIMIT ? OFFSET ? + "#,) + .bind(start_date) + .bind(end_date) + .bind(page_size) + .bind(offset) + .fetch_all(db) + .await.map_err(Into::into) + } +} + +impl Into for DbAdjustmentWithUserAndItem { + fn into(self) -> DbAdjustment { + DbAdjustment { + id: self.id, + item: self.item, + user: self.user, + create_date: self.create_date, + target_date: self.target_date, + amount: self.amount, + unit_price: self.unit_price, + reason: self.reason, + } + } +} \ No newline at end of file diff --git a/src/db/adjustment/adjustment_reason.rs b/src/db/adjustment/adjustment_reason.rs new file mode 100644 index 0000000..afdfec6 --- /dev/null +++ b/src/db/adjustment/adjustment_reason.rs @@ -0,0 +1,71 @@ +use tracing::error; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize)] +pub enum DbAdjustmentReason { + Unknown, + Sale, + Destruction, + Expiration, + Theft, + NewStock, +} + +impl Into for DbAdjustmentReason { + fn into(self) -> i64 { + match self { + Self::Unknown => 0, + Self::Sale => 10, + Self::Destruction => 20, + Self::Expiration => 25, + Self::Theft => 30, + Self::NewStock => 50, + } + } +} + +impl From for DbAdjustmentReason { + fn from(item: i64) -> Self { + match item { + 0 => Self::Unknown, + 10 => Self::Sale, + 20 => Self::Destruction, + 25 => Self::Expiration, + 30 => Self::Theft, + 50 => Self::NewStock, + _ => { + error!("unknown negative adjustment reason value: {}", item); + Self::Unknown + } + } + } +} + +impl TryFrom<&str> for DbAdjustmentReason { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + match value { + "unknown" => Ok(Self::Unknown), + "sale" => Ok(Self::Sale), + "destruction" => Ok(Self::Destruction), + "expiration" => Ok(Self::Expiration), + "theft" => Ok(Self::Theft), + "new-stock" => Ok(Self::NewStock), + _ => Err(anyhow::anyhow!("unknown negative adjustment reason")) + } + } +} + +impl Into for DbAdjustmentReason { + fn into(self) -> String { + match self { + Self::Unknown => { String::from("unknown") } + Self::Sale => { String::from("sale") } + Self::Destruction => { String::from("destruction") } + Self::Expiration => { String::from("expiration") } + Self::Theft => { String::from("theft") } + Self::NewStock => { String::from("new-stock") } + } + } +} \ No newline at end of file diff --git a/src/db/adjustment/mod.rs b/src/db/adjustment/mod.rs new file mode 100644 index 0000000..5059dec --- /dev/null +++ b/src/db/adjustment/mod.rs @@ -0,0 +1,165 @@ +pub mod adjustment_reason; +pub mod adjustment_joins; + +use serde::Serialize; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use sqlx::SqlitePool; +use adjustment_reason::DbAdjustmentReason; +use tokio_stream::StreamExt; + +#[derive(Clone, Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbAdjustment { + pub id: i64, + pub item: i64, + pub user: i64, + pub create_date: DateTime, + pub target_date: DateTime, + pub amount: f64, + pub unit_price: Option, + #[sqlx(try_from = "i64")] + pub reason: DbAdjustmentReason, +} + + +pub async fn add_adjustment_new_stock(db: &SqlitePool, item: i64, user: i64, + create_date: DateTime, target_date: DateTime, + amount: f64, unit_price: i64) + -> Result { + add_adjustment(db, item, user, create_date, target_date, amount, Some(unit_price), + DbAdjustmentReason::NewStock).await +} + +pub async fn add_adjustment(db: &SqlitePool, item: i64, user: i64, + create_date: DateTime, target_date: DateTime, + amount: f64, unit_price: Option, reason: DbAdjustmentReason) + -> Result { + let reason: i64 = reason.into(); + let res = sqlx::query( + r#" + INSERT INTO Adjustment ( + item, + user, + create_date, + target_date, + amount, + reason, + unit_price) + VALUES (?, ?, ?, ?, ?, ?, ?) + "#) + .bind(item) + .bind(user) + .bind(create_date) + .bind(target_date) + .bind(amount) + .bind(reason) + .bind(unit_price) + .execute(db).await?; + + let new_id = res.last_insert_rowid(); + + Ok(new_id) +} + +pub async fn get_adjustments_target_date_range( + db: &SqlitePool, start_date: DateTime, end_date: DateTime +) -> Result> { + + sqlx::query_as::<_, DbAdjustment>(r#" + SELECT id, item, user, + create_date, + target_date, + amount, + reason, + unit_price + FROM Adjustment + WHERE target_date >= ? AND target_date <= ? + "#,) + .bind(start_date) + .bind(end_date) + .fetch_all(db) + .await.map_err(Into::into) +} + +#[derive(Clone, Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbAdjustmentRunningTally { + #[sqlx(flatten)] + pub adjustment: DbAdjustment, + pub tally: f64, +} + +pub async fn get_tallied_adjustments_for_item( + db: &SqlitePool, item: i64 +) -> Result> { + + sqlx::query_as::<_, DbAdjustmentRunningTally>(r#" + SELECT id, item, user, + create_date, + target_date, + amount, + reason, + unit_price, + SUM(amount) OVER (partition by item order by target_date,id) as tally + FROM Adjustment + WHERE item = ? + "#,) + .bind(item) + .fetch_all(db) + .await.map_err(Into::into) +} + +pub async fn sum_all_adjustments_for_item(db: &SqlitePool, id: i64) -> Result { + let res = sqlx::query!( + r#" + SELECT TOTAL(amount) as amt FROM Adjustment WHERE item = ? + "#, + id + ) + .fetch_one(db).await?; + + let amt: f64 = res.amt.unwrap_or_default(); + + Ok(amt) +} + +#[derive(Clone, Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbAdjustmentWithValuation { + pub adjustment: DbAdjustment, + pub tally: f64, + pub value: i64, +} + +pub async fn get_item_adjustment_valuation_weighted_mean(db: &SqlitePool, item: i64) -> Result> { + let mut stream = sqlx::query_as::<_, DbAdjustmentRunningTally>(r#" + SELECT id, item, user, + create_date, + target_date, + amount, + reason, + unit_price, + SUM(amount) OVER (partition by item order by target_date,id) as tally + FROM Adjustment + WHERE item = ? + ORDER BY target_date, id + "#,) + .bind(item) + .fetch(db); + + //NOTE: I am positive that this could just be done with a pure query, but we'll just stick + // with the janky solution for now + let mut weighted_average = 0; + let mut vals = vec![]; + while let Some(val) = stream.next().await.transpose()? { + let contrib = val.adjustment.amount * val.adjustment.unit_price.unwrap_or(weighted_average) as f64; + let current_value = weighted_average as f64 * (val.tally - val.adjustment.amount); + let new_value = (contrib + current_value) as i64; + weighted_average = new_value / val.tally as i64; + + vals.push(DbAdjustmentWithValuation { adjustment: val.adjustment, tally: val.tally, value: new_value }) + } + + Ok(vals) +} \ No newline at end of file diff --git a/src/db/inventory_item.rs b/src/db/inventory_item.rs index 568fbbc..9579d98 100644 --- a/src/db/inventory_item.rs +++ b/src/db/inventory_item.rs @@ -82,21 +82,7 @@ pub async fn inventory_item_get_by_id(db: &SqlitePool, id: i64) -> Result Result { - let res = sqlx::query!( - r#" - SELECT TOTAL(amount) as amt FROM Adjustment WHERE item = ? - "#, - id - ) - .fetch_one(db).await?; - - let amt: f64 = res.amt.unwrap_or_default(); - - Ok(amt) -} - -pub async fn add_inventory_item(db: &SqlitePool, name: &str, reorder_point: f64, +pub async fn add_inventory_item(db: &SqlitePool, name: &str, reorder_point: f64, allow_fractional_units: bool, display_unit_abbreviation: &str ) -> Result { let res = sqlx::query!( @@ -111,3 +97,40 @@ pub async fn add_inventory_item(db: &SqlitePool, name: &str, reorder_point: f64, Ok(new_id) } + +#[derive(Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbInventoryItemWithCount { + pub id: i64, + pub name: String, + pub reorder_point: f64, + pub allow_fractional_units: bool, + pub display_unit: i64, + pub display_unit_str: String, + pub display_unit_abbreviation: String, + pub amount: Option, +} + +pub async fn inventory_item_get_by_id_with_unit(db: &SqlitePool, id: i64) -> Result { + sqlx::query_as!( + DbInventoryItemWithCount, + r#" + SELECT + item.id as id, + item.name as name, + item.reorder_point as reorder_point, + item.allow_fractional_units as allow_fractional_units, + item.display_unit as display_unit, + display_unit.unit as display_unit_str, + display_unit.abbreviation as display_unit_abbreviation, + (SELECT TOTAL(amount) as amt FROM Adjustment WHERE item = ?) as amount + FROM + InventoryItem as item + JOIN DisplayUnit as display_unit ON item.display_unit = display_unit.id + WHERE item.id = ? + "#, + id, id + ) + .fetch_one(db).await + .map_err(From::from) +} diff --git a/src/ingest.rs b/src/ingest.rs index 437ba14..f0e7624 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -4,7 +4,7 @@ use axum::body::Bytes; use sqlx::SqlitePool; use tracing::info; use crate::db::inventory_item::add_inventory_item; -use crate::db::adjustment::add_new_stock_adjustment; +use crate::db::adjustment::add_adjustment_new_stock; #[derive(Debug, serde::Deserialize)] struct CatalogRecord { @@ -38,7 +38,7 @@ pub async fn ingest_catalog(mut reader: csv::Reader, db: Sq record.fractional, &record.unit).await?; - let new_positive_adjustment = add_new_stock_adjustment(&db, new_entry_id, + let new_positive_adjustment = add_adjustment_new_stock(&db, new_entry_id, user_id, timestamp, timestamp, record.quantity, record.unit_price).await?; info!("Added new item: {}/{} - {}", new_entry_id, new_positive_adjustment, record.name); diff --git a/src/session.rs b/src/session.rs index 6fc9b13..2ad9bdc 100644 --- a/src/session.rs +++ b/src/session.rs @@ -8,6 +8,7 @@ use axum::http::request::Parts; use axum::response::Redirect; use serde::{Deserialize, Serialize}; use std::result; +use chrono::FixedOffset; use tokio::task::JoinHandle; use tower_sessions::cookie::SameSite; use tower_sessions::{ExpiredDeletion, Expiry, Session, SessionManagerLayer}; @@ -101,4 +102,11 @@ where Ok(user) } +} + +impl SessionUser { + pub fn get_timezone(&self) -> Result { + FixedOffset::east_opt(self.tz_offset) + .ok_or(anyhow::anyhow!("Invalid timezone")) + } } \ No newline at end of file diff --git a/templates/adjustments-add-form.html b/templates/adjustments-add-form.html new file mode 100644 index 0000000..1b81e18 --- /dev/null +++ b/templates/adjustments-add-form.html @@ -0,0 +1,12 @@ + +
+
+ + +
+
+ + +
+ +
diff --git a/templates/adjustments-table-fragment.html b/templates/adjustments-table-fragment.html new file mode 100644 index 0000000..4387b66 --- /dev/null +++ b/templates/adjustments-table-fragment.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + {% for item in adjustments %} + + + + + + + + + {% endfor %} + +
DateAmountReasonUnit ValueRunning TotalRunning Value
{{ item.date }}{{ item.amount }}{{ item.reason }}{{ item.unit_value }}{{ item.tally }}{{ item.tally_value }}
diff --git a/templates/item.html b/templates/item.html index d81c5aa..970ed94 100644 --- a/templates/item.html +++ b/templates/item.html @@ -6,5 +6,12 @@

{{item.name}}

Reorder at: {{item.reorder_point}}

+

Amount in stock: {{item.amount}} {{item.display_unit_short}}

+ +
+
+ +
+
{% endblock %} \ No newline at end of file