parent
a22a052c99
commit
9124e66e28
@ -1,229 +0,0 @@
|
|||||||
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::{does_inventory_item_allow_fractional_units, inventory_item_get_by_id_with_unit};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::session::SessionUser;
|
|
||||||
use crate::util::currency;
|
|
||||||
use crate::util::currency::{dollars_string_to_int_cents, int_cents_to_dollars_string};
|
|
||||||
use crate::util::formatting::format_either;
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "adjustments-table-fragment.html")]
|
|
||||||
struct AdjustmentTableTemplate {
|
|
||||||
item_id: i64,
|
|
||||||
adjustments: Vec<AdjustmentDisplayItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_adjustments_for_item(State(db): State<SqlitePool>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
user: SessionUser
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
|
|
||||||
let timezone = user.get_timezone()?;
|
|
||||||
|
|
||||||
let allow_fractional =
|
|
||||||
does_inventory_item_allow_fractional_units(&db, id).await?;
|
|
||||||
|
|
||||||
let adjustments: Vec<DbAdjustmentWithValuation> = get_item_adjustment_valuation_weighted_mean(&db, id).await?;
|
|
||||||
|
|
||||||
|
|
||||||
let adjustments: Vec<AdjustmentDisplayItem> = adjustments.into_iter()
|
|
||||||
.map(|x| AdjustmentDisplayItem::from_db_item(x, timezone, allow_fractional))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(AdjustmentTableTemplate { item_id: id, adjustments }.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "adjustment-sale-form.html")]
|
|
||||||
pub struct AdjustmentSaleFormTemplate {
|
|
||||||
pub item_id: i64,
|
|
||||||
pub amount_error: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
pub struct AdjustmentSaleFormData {
|
|
||||||
pub amount: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
pub async fn adjustment_sale_form_post(State(db): State<SqlitePool>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
user: SessionUser,
|
|
||||||
form_data: Form<AdjustmentSaleFormData>
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
|
|
||||||
let adjustment_amount = if form_data.amount > 0.0 {
|
|
||||||
-1.0 * form_data.amount
|
|
||||||
} else {
|
|
||||||
form_data.amount
|
|
||||||
};
|
|
||||||
|
|
||||||
let fractional_units_allowed =
|
|
||||||
does_inventory_item_allow_fractional_units(&db, id).await?;
|
|
||||||
|
|
||||||
if adjustment_amount == 0.0 {
|
|
||||||
return Ok(AdjustmentSaleFormTemplate {
|
|
||||||
item_id: id,
|
|
||||||
amount_error: "Please input a non-zero amount"
|
|
||||||
}.into_response())
|
|
||||||
} else if !(fractional_units_allowed || adjustment_amount.fract() == 0.0) {
|
|
||||||
return Ok(AdjustmentSaleFormTemplate {
|
|
||||||
item_id: id,
|
|
||||||
amount_error: "Please input a whole number"
|
|
||||||
}.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
let trigger_events = HxResponseTrigger::normal(
|
|
||||||
std::iter::once(HxEvent::from("new-adjustment"))
|
|
||||||
);
|
|
||||||
|
|
||||||
let timestamp = chrono::Utc::now();
|
|
||||||
|
|
||||||
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, AdjustmentSaleFormTemplate { item_id: id, amount_error: "" }.into_response()).into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn adjustment_sale_form_get(
|
|
||||||
Path(id): Path<i64>
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
Ok(AdjustmentSaleFormTemplate { item_id: id, amount_error: "" }.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "adjustment-new-stock-form.html")]
|
|
||||||
pub struct AdjustmentNewStockFormTemplate {
|
|
||||||
pub item_id: i64,
|
|
||||||
pub amount_error: &'static str,
|
|
||||||
pub price_error: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
pub struct AdjustmentNewStockFormData {
|
|
||||||
pub amount: f64,
|
|
||||||
pub price: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
pub async fn adjustment_new_stock_form_post(State(db): State<SqlitePool>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
user: SessionUser,
|
|
||||||
form_data: Form<AdjustmentNewStockFormData>
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
|
|
||||||
let fractional_units_allowed =
|
|
||||||
does_inventory_item_allow_fractional_units(&db, id).await?;
|
|
||||||
|
|
||||||
let amount_error = if form_data.amount == 0.0 {
|
|
||||||
"Please input a non-zero amount"
|
|
||||||
} else if !(fractional_units_allowed || form_data.amount.fract() == 0.0) {
|
|
||||||
"Please input a whole number"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
let price = dollars_string_to_int_cents(&form_data.price);
|
|
||||||
let price_error = if price.is_err() {
|
|
||||||
"Please input a dollar amount"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
if !(amount_error.is_empty() && price_error.is_empty()) {
|
|
||||||
return Ok(AdjustmentNewStockFormTemplate {
|
|
||||||
item_id: id,
|
|
||||||
amount_error,
|
|
||||||
price_error,
|
|
||||||
}.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
let price = price?;
|
|
||||||
let unit_price = (price as f64 / form_data.amount) as i64;
|
|
||||||
|
|
||||||
let trigger_events = HxResponseTrigger::normal(
|
|
||||||
std::iter::once(HxEvent::from("new-adjustment"))
|
|
||||||
);
|
|
||||||
|
|
||||||
let timestamp = chrono::Utc::now();
|
|
||||||
|
|
||||||
let adjustment_amount = 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,
|
|
||||||
Some(unit_price),
|
|
||||||
DbAdjustmentReason::NewStock).await?;
|
|
||||||
|
|
||||||
|
|
||||||
Ok((trigger_events, AdjustmentNewStockFormTemplate { item_id: id,
|
|
||||||
amount_error: "", price_error: "" }.into_response()).into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn adjustment_new_stock_form_get(
|
|
||||||
Path(id): Path<i64>
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
Ok(AdjustmentNewStockFormTemplate { item_id: id,
|
|
||||||
amount_error: "", price_error: "" }.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 AdjustmentDisplayItem {
|
|
||||||
pub fn from_db_item(db_entry: DbAdjustmentWithValuation, timezone: FixedOffset
|
|
||||||
, allow_fractional: bool) -> Self {
|
|
||||||
let precision : usize = if allow_fractional { 2 } else { 0 };
|
|
||||||
let date = db_entry.adjustment.target_date.with_timezone(&timezone)
|
|
||||||
.format("%Y-%m-%d %l:%M:%S %p").to_string();
|
|
||||||
let amount = format!("{:.*}", precision, db_entry.adjustment.amount);
|
|
||||||
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 = format!("{:.*}", precision, db_entry.tally);
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
use anyhow::anyhow;
|
|
||||||
use askama::Template;
|
|
||||||
use askama_axum::IntoResponse;
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use axum::response::Response;
|
|
||||||
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, inventory_item_get_unit_abbreviation, DbInventoryItemWithCount};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::session::SessionUser;
|
|
||||||
use crate::util::currency::int_cents_to_dollars_string;
|
|
||||||
use crate::util::formatting::format_either;
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "item.html")]
|
|
||||||
struct ItemTemplate {
|
|
||||||
pub item_id: i64,
|
|
||||||
pub item: ItemDisplayItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct ItemDisplayItem {
|
|
||||||
pub name: String,
|
|
||||||
pub reorder_point: f64,
|
|
||||||
pub allow_fractional_units: bool,
|
|
||||||
pub active: bool,
|
|
||||||
pub display_unit: String,
|
|
||||||
pub display_unit_short: String,
|
|
||||||
pub amount: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DbInventoryItemWithCount> for ItemDisplayItem {
|
|
||||||
fn from(item: DbInventoryItemWithCount) -> Self {
|
|
||||||
let precision = if item.allow_fractional_units { 2 } else { 0 };
|
|
||||||
let amount = format!("{:.*}", precision, item.amount.unwrap_or_default());
|
|
||||||
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,
|
|
||||||
active: item.active,
|
|
||||||
amount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn item(State(db): State<SqlitePool>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
user: SessionUser
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into();
|
|
||||||
|
|
||||||
Ok(ItemTemplate { item_id: id, item }.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub async fn item_count(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
|
||||||
let count = sum_all_adjustments_for_item(&db, id).await?;
|
|
||||||
|
|
||||||
Ok(count.to_string().into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "item-stats-fragment.html")]
|
|
||||||
struct ItemStatsTemplate {
|
|
||||||
pub item_id: i64,
|
|
||||||
pub item: ItemDisplayItem,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
pub async fn item_stats(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
|
||||||
//TODO This is pretty chatty with the database. Might could cut down the
|
|
||||||
// number of queries
|
|
||||||
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into();
|
|
||||||
|
|
||||||
let value = get_item_adjustment_valuation_weighted_mean(&db, id)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.last().ok_or_else(|| anyhow!("No value"))?
|
|
||||||
.value;
|
|
||||||
|
|
||||||
let value = int_cents_to_dollars_string(value);
|
|
||||||
|
|
||||||
Ok(ItemStatsTemplate { item_id: id, item, value }.into_response())
|
|
||||||
}
|
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
pub mod table;
|
||||||
|
pub mod negative;
|
||||||
|
pub mod positive;
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
use crate::db::adjustment::add_adjustment;
|
||||||
|
use crate::db::adjustment::adjustment_reason::DbAdjustmentReason;
|
||||||
|
use crate::db::inventory_item::does_inventory_item_allow_fractional_units;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::session::SessionUser;
|
||||||
|
use askama::Template;
|
||||||
|
use askama_axum::{IntoResponse, Response};
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::{debug_handler, Form};
|
||||||
|
use axum_htmx::{HxEvent, HxResponseTrigger};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "item/adjustment/negative-adjustment-form.html")]
|
||||||
|
pub struct NegativeAdjustmentFormTemplate {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub amount_error: &'static str,
|
||||||
|
pub reason_error: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct NegativeAdjustmentFormData {
|
||||||
|
pub amount: f64,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn negative_adjustment_form_post(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
user: SessionUser,
|
||||||
|
mut form_data: Form<NegativeAdjustmentFormData>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let adjustment_amount = if form_data.amount > 0.0 {
|
||||||
|
-1.0 * form_data.amount
|
||||||
|
} else {
|
||||||
|
form_data.amount
|
||||||
|
};
|
||||||
|
|
||||||
|
let fractional_units_allowed = does_inventory_item_allow_fractional_units(&db, id).await?;
|
||||||
|
|
||||||
|
let reason = form_data
|
||||||
|
.reason
|
||||||
|
.take()
|
||||||
|
.and_then(|s| DbAdjustmentReason::try_from(s.as_str()).ok())
|
||||||
|
.unwrap_or_else(|| DbAdjustmentReason::Unknown);
|
||||||
|
|
||||||
|
let amount_error = if adjustment_amount == 0.0 {
|
||||||
|
"Please input a non-zero amount"
|
||||||
|
} else if !(fractional_units_allowed || adjustment_amount.fract() == 0.0) {
|
||||||
|
"Please input a whole number"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let reason_error = if reason == DbAdjustmentReason::Unknown {
|
||||||
|
"Unknown adjustment reason"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
if !(amount_error.is_empty() && reason_error.is_empty()) {
|
||||||
|
return Ok(NegativeAdjustmentFormTemplate {
|
||||||
|
item_id: id,
|
||||||
|
amount_error,
|
||||||
|
reason_error,
|
||||||
|
}
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let trigger_events =
|
||||||
|
HxResponseTrigger::normal(std::iter::once(HxEvent::from("new-adjustment")));
|
||||||
|
|
||||||
|
let timestamp = chrono::Utc::now();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Add adjustment form: (Amount {}, Reason {:?}, for user {}",
|
||||||
|
form_data.amount, reason, user.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let _new_id = add_adjustment(
|
||||||
|
&db,
|
||||||
|
id,
|
||||||
|
user.id,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
adjustment_amount,
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
trigger_events,
|
||||||
|
NegativeAdjustmentFormTemplate {
|
||||||
|
item_id: id,
|
||||||
|
amount_error: "",
|
||||||
|
reason_error: "",
|
||||||
|
}
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn negative_adjustment_form_get(Path(id): Path<i64>) -> Result<Response, AppError> {
|
||||||
|
Ok(NegativeAdjustmentFormTemplate {
|
||||||
|
item_id: id,
|
||||||
|
amount_error: "",
|
||||||
|
reason_error: "",
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
use crate::db::adjustment::add_adjustment;
|
||||||
|
use crate::db::adjustment::adjustment_reason::DbAdjustmentReason;
|
||||||
|
use crate::db::inventory_item::does_inventory_item_allow_fractional_units;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::session::SessionUser;
|
||||||
|
use crate::util::currency::dollars_string_to_int_cents;
|
||||||
|
use askama::Template;
|
||||||
|
use askama_axum::{IntoResponse, Response};
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::{debug_handler, Form};
|
||||||
|
use axum_htmx::{HxEvent, HxResponseTrigger};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "item/adjustment/positive-adjustment-form.html")]
|
||||||
|
pub struct PositiveAdjustmentFormTemplate {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub amount_error: &'static str,
|
||||||
|
pub price_error: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct PositiveAdjustmentFormData {
|
||||||
|
pub amount: f64,
|
||||||
|
pub price: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn positive_adjustment_form_post(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
user: SessionUser,
|
||||||
|
form_data: Form<PositiveAdjustmentFormData>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let fractional_units_allowed = does_inventory_item_allow_fractional_units(&db, id).await?;
|
||||||
|
|
||||||
|
let amount_error = if form_data.amount == 0.0 {
|
||||||
|
"Please input a non-zero amount"
|
||||||
|
} else if !(fractional_units_allowed || form_data.amount.fract() == 0.0) {
|
||||||
|
"Please input a whole number"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let price = dollars_string_to_int_cents(&form_data.price);
|
||||||
|
let price_error = if price.is_err() {
|
||||||
|
"Please input a dollar amount"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
if !(amount_error.is_empty() && price_error.is_empty()) {
|
||||||
|
return Ok(PositiveAdjustmentFormTemplate {
|
||||||
|
item_id: id,
|
||||||
|
amount_error,
|
||||||
|
price_error,
|
||||||
|
}
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let price = price?;
|
||||||
|
let unit_price = (price as f64 / form_data.amount) as i64;
|
||||||
|
|
||||||
|
let trigger_events =
|
||||||
|
HxResponseTrigger::normal(std::iter::once(HxEvent::from("new-adjustment")));
|
||||||
|
|
||||||
|
let timestamp = chrono::Utc::now();
|
||||||
|
|
||||||
|
let adjustment_amount = 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,
|
||||||
|
Some(unit_price),
|
||||||
|
DbAdjustmentReason::NewStock,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
trigger_events,
|
||||||
|
PositiveAdjustmentFormTemplate {
|
||||||
|
item_id: id,
|
||||||
|
amount_error: "",
|
||||||
|
price_error: "",
|
||||||
|
}
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn positive_adjustment_form_get(Path(id): Path<i64>) -> Result<Response, AppError> {
|
||||||
|
Ok(PositiveAdjustmentFormTemplate {
|
||||||
|
item_id: id,
|
||||||
|
amount_error: "",
|
||||||
|
price_error: "",
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
use crate::db::adjustment::{
|
||||||
|
get_item_adjustment_valuation_weighted_mean, DbAdjustmentWithValuation,
|
||||||
|
};
|
||||||
|
use crate::db::inventory_item::does_inventory_item_allow_fractional_units;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::session::SessionUser;
|
||||||
|
use crate::util::currency;
|
||||||
|
use askama::Template;
|
||||||
|
use askama_axum::{IntoResponse, Response};
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use chrono::FixedOffset;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "item/adjustment/adjustment-table.html")]
|
||||||
|
struct AdjustmentTableTemplate {
|
||||||
|
item_id: i64,
|
||||||
|
adjustments: Vec<AdjustmentDisplayItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_adjustments_table(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
user: SessionUser,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let timezone = user.get_timezone()?;
|
||||||
|
|
||||||
|
let allow_fractional = does_inventory_item_allow_fractional_units(&db, id).await?;
|
||||||
|
|
||||||
|
let adjustments: Vec<DbAdjustmentWithValuation> =
|
||||||
|
get_item_adjustment_valuation_weighted_mean(&db, id).await?;
|
||||||
|
|
||||||
|
let adjustments: Vec<AdjustmentDisplayItem> = adjustments
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| AdjustmentDisplayItem::from_db_item(x, timezone, allow_fractional))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(AdjustmentTableTemplate {
|
||||||
|
item_id: id,
|
||||||
|
adjustments,
|
||||||
|
}
|
||||||
|
.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 AdjustmentDisplayItem {
|
||||||
|
pub fn from_db_item(
|
||||||
|
db_entry: DbAdjustmentWithValuation,
|
||||||
|
timezone: FixedOffset,
|
||||||
|
allow_fractional: bool,
|
||||||
|
) -> Self {
|
||||||
|
let precision: usize = if allow_fractional { 2 } else { 0 };
|
||||||
|
let date = db_entry
|
||||||
|
.adjustment
|
||||||
|
.target_date
|
||||||
|
.with_timezone(&timezone)
|
||||||
|
.format("%Y-%m-%d %l:%M:%S %p")
|
||||||
|
.to_string();
|
||||||
|
let amount = format!("{:.*}", precision, db_entry.adjustment.amount);
|
||||||
|
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 = format!("{:.*}", precision, db_entry.tally);
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
use crate::db::adjustment::sum_all_adjustments_for_item;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use askama_axum::{IntoResponse, Response};
|
||||||
|
use axum::debug_handler;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn item_count(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let count = sum_all_adjustments_for_item(&db, id).await?;
|
||||||
|
|
||||||
|
Ok(count.to_string().into_response())
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
use crate::db::inventory_item::{inventory_item_get_by_id_with_unit, DbInventoryItemWithCount};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use askama::Template;
|
||||||
|
use askama_axum::{IntoResponse, Response};
|
||||||
|
use axum::debug_handler;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "item/item.html")]
|
||||||
|
struct ItemTemplate {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub item: ItemDisplayItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn item(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
||||||
|
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into();
|
||||||
|
|
||||||
|
Ok(ItemTemplate { item_id: id, item }.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ItemDisplayItem {
|
||||||
|
pub name: String,
|
||||||
|
pub reorder_point: f64,
|
||||||
|
pub allow_fractional_units: bool,
|
||||||
|
pub active: bool,
|
||||||
|
pub display_unit: String,
|
||||||
|
pub display_unit_short: String,
|
||||||
|
pub amount: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DbInventoryItemWithCount> for ItemDisplayItem {
|
||||||
|
fn from(item: DbInventoryItemWithCount) -> Self {
|
||||||
|
let precision = if item.allow_fractional_units { 2 } else { 0 };
|
||||||
|
let amount = format!("{:.*}", precision, item.amount.unwrap_or_default());
|
||||||
|
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,
|
||||||
|
active: item.active,
|
||||||
|
amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
pub mod adjustment;
|
||||||
|
pub mod item;
|
||||||
|
pub mod stats;
|
||||||
|
pub mod count;
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use askama_axum::{IntoResponse, Response};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use axum::debug_handler;
|
||||||
|
use crate::app::item::item::ItemDisplayItem;
|
||||||
|
use crate::db::adjustment::get_item_adjustment_valuation_weighted_mean;
|
||||||
|
use crate::db::inventory_item::inventory_item_get_by_id_with_unit;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::util::currency::int_cents_to_dollars_string;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "item/stats-fragment.html")]
|
||||||
|
struct ItemStatsTemplate {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub item: ItemDisplayItem,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn item_stats(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
||||||
|
//TODO This is pretty chatty with the database. Might could cut down the
|
||||||
|
// number of queries
|
||||||
|
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into();
|
||||||
|
|
||||||
|
let value = get_item_adjustment_valuation_weighted_mean(&db, id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.last().ok_or_else(|| anyhow!("No value"))?
|
||||||
|
.value;
|
||||||
|
|
||||||
|
let value = int_cents_to_dollars_string(value);
|
||||||
|
|
||||||
|
Ok(ItemStatsTemplate { item_id: id, item, value }.into_response())
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
use crate::app::{catalog, history, home, item, overview, reports, upload};
|
||||||
|
use crate::session::SessionUser;
|
||||||
|
use axum::extract::FromRef;
|
||||||
|
use axum::middleware::from_extractor;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use axum_htmx::AutoVaryLayer;
|
||||||
|
use oauth2::basic::BasicClient;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(home::home))
|
||||||
|
.route("/index.html", get(home::home))
|
||||||
|
.route("/home", get(home::home))
|
||||||
|
.route("/home/search", get(home::home))
|
||||||
|
.route("/catalog", get(catalog::catalog))
|
||||||
|
.route("/catalog/", get(catalog::catalog))
|
||||||
|
.route("/item/:item_id", get(item::item::item))
|
||||||
|
.route("/item/:item_id/", get(item::item::item))
|
||||||
|
.route("/item/:item_id/count", get(item::count::item_count))
|
||||||
|
.route("/item/:item_id/stats", get(item::stats::item_stats))
|
||||||
|
.route(
|
||||||
|
"/item/:item_id/adjustments",
|
||||||
|
get(item::adjustment::table::get_adjustments_table),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/item/:item_id/adjustment/negative",
|
||||||
|
get(item::adjustment::negative::negative_adjustment_form_get)
|
||||||
|
.post(item::adjustment::negative::negative_adjustment_form_post),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/item/:item_id/adjustment/positive",
|
||||||
|
get(item::adjustment::positive::positive_adjustment_form_get)
|
||||||
|
.post(item::adjustment::positive::positive_adjustment_form_post),
|
||||||
|
)
|
||||||
|
.route("/upload", get(upload::index::upload_index_handler))
|
||||||
|
.route("/upload/catalog", post(upload::catalog::catalog_import))
|
||||||
|
.route("/overview", get(overview::overview_handler))
|
||||||
|
.route("/reports", get(reports::reports_handler))
|
||||||
|
.route("/history", get(history::history_log_handler))
|
||||||
|
// Ensure that all routes here require an authenticated user
|
||||||
|
// whether explicitly asked or not
|
||||||
|
.route_layer(from_extractor::<SessionUser>())
|
||||||
|
.layer(AutoVaryLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App state. Pretty basic stuff. Gets passed around by the server to the handlers and whatnot
|
||||||
|
// Use in a handler with the state enum:
|
||||||
|
// async fn handler(State(my_app_state): State<AppState>)
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: SqlitePool,
|
||||||
|
pub oauth_client: BasicClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axum extractors for app state. These allow the handler to just use
|
||||||
|
// pieces of the App state
|
||||||
|
// async fn handler(State(my_db): State<SqlitePool>)
|
||||||
|
impl FromRef<AppState> for SqlitePool {
|
||||||
|
fn from_ref(input: &AppState) -> Self {
|
||||||
|
input.db.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for BasicClient {
|
||||||
|
fn from_ref(input: &AppState) -> Self {
|
||||||
|
input.oauth_client.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,27 +0,0 @@
|
|||||||
|
|
||||||
<form hx-post="/item/{{item_id}}/adjustment/sale" hx-target="this" hx-swap="outerHTML" >
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<!--<label for="amount" class="form-label">Amount</label>-->
|
|
||||||
<input type="number"
|
|
||||||
id="amount"
|
|
||||||
name="amount"
|
|
||||||
step="0.01"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Amount"
|
|
||||||
aria-label="amount"
|
|
||||||
required
|
|
||||||
{% if !amount_error.is_empty() -%}
|
|
||||||
aria-invalid="true"
|
|
||||||
aria-describedby="invalid-amount"
|
|
||||||
{% endif -%}
|
|
||||||
/>
|
|
||||||
{% if !amount_error.is_empty() -%}
|
|
||||||
<small id="invalid-amount" class="invalid-feedback">{{ amount_error }}</small>
|
|
||||||
{% endif -%}
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<button class="btn btn-primary">Record Sale</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<form hx-post="/item/{{item_id}}/adjustment/negative"
|
||||||
|
hx-target="this" hx-swap="outerHTML"
|
||||||
|
x-ref="formNegativeAdjustment"
|
||||||
|
x-data="{ reason: 'Sale' }">
|
||||||
|
<input id="reason" name="reason" type="hidden" x-model="reason"/>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<!--<label for="amount" class="form-label">Amount</label>-->
|
||||||
|
<input type="number"
|
||||||
|
id="amount"
|
||||||
|
name="amount"
|
||||||
|
step="0.01"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Amount"
|
||||||
|
aria-label="amount"
|
||||||
|
required
|
||||||
|
{% if !amount_error.is_empty() -%}
|
||||||
|
aria-invalid="true"
|
||||||
|
aria-describedby="invalid-amount"
|
||||||
|
{% endif -%}
|
||||||
|
/>
|
||||||
|
{% if !amount_error.is_empty() -%}
|
||||||
|
<small id="invalid-amount" class="invalid-feedback">{{ amount_error }}</small>
|
||||||
|
{% endif -%}
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button class="btn btn-primary" x-text="reason">Sale</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<span class="visually-hidden">Other</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
@click="reason = 'Sale'">
|
||||||
|
Sale
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
@click="reason = 'Destruction'">
|
||||||
|
Destruction
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
@click="reason = 'Expiration'">
|
||||||
|
Expiration
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
@click="reason = 'Theft'">
|
||||||
|
Theft
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
<form hx-post="/item/{{item_id}}/adjustment/new-stock" hx-target="this" hx-swap="outerHTML" >
|
<form hx-post="/item/{{item_id}}/adjustment/positive" hx-target="this" hx-swap="outerHTML" >
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<input type="number"
|
<input type="number"
|
||||||
Loading…
Reference in new issue