This commit is contained in:
parent
443b52bf38
commit
91d39a4d83
7 changed files with 29 additions and 7 deletions
189
src/core/flow.rs
Normal file
189
src/core/flow.rs
Normal file
|
@ -0,0 +1,189 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::item::{SupplyForm, item_does_not_exist_error, variant_does_not_exist_error};
|
||||
use crate::{get_itemdb, get_pg};
|
||||
use based::request::api::ToAPI;
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowInfo {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub depends: Vec<String>,
|
||||
pub next: Option<String>,
|
||||
pub produces: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ToAPI for FlowInfo {
|
||||
async fn api(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"depends": self.depends,
|
||||
"next": self.next,
|
||||
"produces": self.produces
|
||||
})
|
||||
}
|
||||
}
|
||||
/// A production flow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Flow {
|
||||
/// ID
|
||||
pub id: uuid::Uuid,
|
||||
/// Tiemstamp when the flow was started
|
||||
pub started: chrono::DateTime<chrono::Utc>,
|
||||
/// Kind of flow; ID of the describing JSON
|
||||
pub kind: String,
|
||||
/// Input transactions
|
||||
pub input: Option<Vec<uuid::Uuid>>,
|
||||
/// Timestamp when the flow was ended
|
||||
pub ended: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// The flow succedding this one
|
||||
pub next: Option<uuid::Uuid>,
|
||||
/// Transactions this flow produced
|
||||
pub produced: Option<Vec<uuid::Uuid>>,
|
||||
}
|
||||
|
||||
impl ToAPI for Flow {
|
||||
async fn api(&self) -> serde_json::Value {
|
||||
let done = if self.ended.is_some() {
|
||||
Some(json!({
|
||||
"ended": self.ended.map(|x| x.timestamp()),
|
||||
"next": self.next,
|
||||
"produced": self.produced
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": self.id,
|
||||
"started": self.started.timestamp(),
|
||||
"kind": self.kind,
|
||||
"input": self.input,
|
||||
"done": done
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
pub async fn get(id: &uuid::Uuid) -> Option<Self> {
|
||||
sqlx::query_as("SELECT * FROM flows WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn create(kind: &str, input: Option<Vec<uuid::Uuid>>) -> Self {
|
||||
sqlx::query_as("INSERT INTO flows (kind, input) VALUES ($1, $2) RETURNING *")
|
||||
.bind(kind)
|
||||
.bind(input)
|
||||
.fetch_one(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn end(self) -> Self {
|
||||
sqlx::query_as("UPDATE flows SET ended = current_timestamp RETURNING *")
|
||||
.fetch_one(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn end_with_produce(
|
||||
self,
|
||||
produced: &[SupplyForm],
|
||||
) -> Result<HashMap<String, Vec<uuid::Uuid>>, ApiError> {
|
||||
let mut ret = HashMap::new();
|
||||
let mut t_create = Vec::new();
|
||||
let mut produced_ref = Vec::with_capacity(ret.len());
|
||||
|
||||
for prod in produced {
|
||||
let t = get_itemdb!()
|
||||
.get_item(&prod.item)
|
||||
.ok_or_else(item_does_not_exist_error)?
|
||||
.variant(&prod.variant)
|
||||
.ok_or_else(variant_does_not_exist_error)?;
|
||||
|
||||
t_create.push((t, prod));
|
||||
}
|
||||
|
||||
for (item, info) in t_create {
|
||||
let t = item
|
||||
.supply(
|
||||
0.0,
|
||||
Some(&format!("flow::{}::{}", self.kind, self.id)),
|
||||
info.location.as_ref().map(|x| x.as_str()),
|
||||
info.note.as_ref().map(|x| x.as_str()),
|
||||
)
|
||||
.await;
|
||||
ret.entry(item.item_variant_id().clone())
|
||||
.or_insert(Vec::new())
|
||||
.push(t.id.clone());
|
||||
produced_ref.push(t.id);
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE transactions SET consumed_timestamp = current_timestamp, produced = $1 WHERE id = $2")
|
||||
.bind(produced_ref)
|
||||
.bind(self.id)
|
||||
.execute(get_pg!()).await.unwrap();
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
pub async fn continue_next(self, next_flow: &Flow) -> Self {
|
||||
sqlx::query_as("UPDATE transactions SET consumed_timestamp = current_timestamp, \"next\" = $1 WHERE id = $2 RETURNING *")
|
||||
.bind(next_flow.id)
|
||||
.bind(&self.id)
|
||||
.fetch_one(get_pg!()).await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// A note for a Flow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct FlowNote {
|
||||
/// ID
|
||||
pub id: uuid::Uuid,
|
||||
/// Timestamp when the note was created
|
||||
pub time: chrono::DateTime<chrono::Utc>,
|
||||
/// Note Content
|
||||
pub content: String,
|
||||
/// Associated flow
|
||||
pub on_flow: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl FlowNote {
|
||||
pub async fn create(content: &str, flow: &uuid::Uuid) -> Self {
|
||||
sqlx::query_as("INSERT INTO flow_notes (content, on_flow) VALUES ($1, $2) RETURNING *")
|
||||
.bind(content)
|
||||
.bind(flow)
|
||||
.fetch_one(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn find_of(id: &str) -> Vec<Self> {
|
||||
sqlx::query_as("SELECT * FROM flow_notes WHERE on_flow = $1 ORDER BY time DESC")
|
||||
.bind(id)
|
||||
.fetch_all(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToAPI for FlowNote {
|
||||
async fn api(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"uuid": self.id,
|
||||
"timestamp": self.time.timestamp(),
|
||||
"content": self.content,
|
||||
"on_flow": self.on_flow
|
||||
})
|
||||
}
|
||||
}
|
166
src/core/item.rs
Normal file
166
src/core/item.rs
Normal file
|
@ -0,0 +1,166 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::get_pg;
|
||||
use crate::transaction::Transaction;
|
||||
use crate::variant::Variant;
|
||||
|
||||
// ITEM
|
||||
// VARIANTS
|
||||
// QUANTIZATION
|
||||
// IMPORT / EXPORT
|
||||
// DEPENDENCIES
|
||||
// DEMAND STATS
|
||||
// SEASONAL REVIEWS
|
||||
|
||||
/// Represents a single item in a collection of physical goods.
|
||||
///
|
||||
/// This struct serves as a blueprint for describing individual items within
|
||||
/// a larger inventory or database of physical goods. It includes fields for
|
||||
/// the name of the item and its category.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
/// The ID
|
||||
pub id: String,
|
||||
/// The name of the Item
|
||||
pub name: String,
|
||||
/// Category of the Item
|
||||
pub category: Option<String>,
|
||||
/// Image
|
||||
pub image_path: Option<String>,
|
||||
/// The variants of an Item.
|
||||
/// Each key of the `HashMap<_>` is the ID of a variant and contains a `Variant`
|
||||
pub variants: HashMap<String, Variant>,
|
||||
}
|
||||
|
||||
/// Get the path of an image file in `path`
|
||||
pub fn get_image(path: &std::path::Path) -> Option<String> {
|
||||
let parent = path.parent()?;
|
||||
let file_name = path.file_stem()?.to_str()?;
|
||||
|
||||
for ext in ["jpg", "jpeg", "webp", "png"] {
|
||||
let mut img_file = parent.to_path_buf();
|
||||
img_file.push(&format!("{file_name}.{ext}"));
|
||||
|
||||
if img_file.exists() {
|
||||
return Some(img_file.display().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Creates a new `Item` from a parsed markdown document
|
||||
pub fn new(doc: &mdq::Document) -> Self {
|
||||
let path = std::path::Path::new(&doc.path);
|
||||
|
||||
let id = path.file_stem().unwrap().to_str().unwrap().to_lowercase();
|
||||
|
||||
let image_path = get_image(path);
|
||||
|
||||
let category = doc
|
||||
.frontmatter
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("category")
|
||||
.map(|x| x.as_str().unwrap().to_string());
|
||||
|
||||
let name = doc
|
||||
.frontmatter
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("name")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let mut variants = HashMap::new();
|
||||
|
||||
let variants_lst = doc
|
||||
.frontmatter
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("variants")
|
||||
.map(|x| x.as_mapping().unwrap().clone())
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!variants_lst.is_empty(),
|
||||
"Item {id} needs at least one variant."
|
||||
);
|
||||
|
||||
for (variant_id, variant) in variants_lst {
|
||||
variants.insert(
|
||||
variant_id.as_str().unwrap().to_string(),
|
||||
Variant::from_yml(&variant, variant_id.as_str().unwrap(), &id),
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
image_path,
|
||||
variants,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Get this items variant names
|
||||
pub fn get_variants(&self) -> Vec<String> {
|
||||
self.variants.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get a `Variant` of an `Item`
|
||||
pub fn variant(&self, variant: &str) -> Option<Variant> {
|
||||
self.variants.get(variant).cloned()
|
||||
}
|
||||
|
||||
/// Get all active `Transaction`s of this `Item` not yet consumed
|
||||
pub async fn inventory(&self) -> Vec<Transaction> {
|
||||
sqlx::query_as("SELECT * FROM transactions WHERE item = $1 AND consumed_timestamp IS NULL ORDER BY created DESC")
|
||||
.bind(&self.id)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
/// Get all active `Transaction`s of this `Item` not yet consumed of `origin`
|
||||
pub async fn inventory_by_origin(&self, origin: &str) -> Vec<Transaction> {
|
||||
sqlx::query_as("SELECT * FROM transactions WHERE item = $1 AND consumed_timestamp IS NULL AND origin = $2 ORDER BY created DESC")
|
||||
.bind(&self.id)
|
||||
.bind(origin)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
/// Get all active `Transaction`s of this `Item` not yet consumed of `destination`
|
||||
pub async fn consumed_by_destination(&self, destination: &str) -> Vec<Transaction> {
|
||||
sqlx::query_as("SELECT * FROM transactions WHERE item = $1 AND consumed_timestamp IS NOT NULL AND destination = $2 ORDER BY created DESC")
|
||||
.bind(&self.id)
|
||||
.bind(destination)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
pub fn api_json(&self) -> serde_json::Value {
|
||||
let variants: HashMap<String, serde_json::Value> = self
|
||||
.variants
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.api_json()))
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"uuid": self.id,
|
||||
"image": if self.image_path.is_some() {
|
||||
Some(format!("/item/{}/image", self.id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
"name": self.name,
|
||||
"category": self.category,
|
||||
"variants": variants
|
||||
})
|
||||
}
|
||||
}
|
110
src/core/location.rs
Normal file
110
src/core/location.rs
Normal file
|
@ -0,0 +1,110 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{config::get_config, get_locations};
|
||||
use based::request::api::ToAPI;
|
||||
|
||||
/// A Storage Location
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Location {
|
||||
/// UUID
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// Name
|
||||
pub name: String,
|
||||
/// Parent
|
||||
pub parent: Option<String>,
|
||||
/// Storage Conditions
|
||||
pub conditions: Option<StorageConditions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StorageConditions {
|
||||
/// Median temperature
|
||||
pub temperature: f64,
|
||||
/// Accurate temperature sensor reading
|
||||
pub temperature_sensor: Option<String>,
|
||||
}
|
||||
|
||||
impl StorageConditions {
|
||||
/// Get a accurate temperature from a sensor endpoint
|
||||
pub async fn accurate_temperature(&self) -> Option<f64> {
|
||||
let conf = get_config();
|
||||
let client = reqwest::Client::new();
|
||||
let res: serde_json::Value = client
|
||||
.get(self.temperature_sensor.clone()?)
|
||||
.bearer_auth(conf.home_assistant?)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
res.as_object()?.get("state")?.as_f64()
|
||||
}
|
||||
}
|
||||
|
||||
impl Location {
|
||||
/// Recursively get the conditions of a location. This inherits from parent locations.
|
||||
pub fn conditions_rec(&self) -> Option<StorageConditions> {
|
||||
let locations = get_locations!();
|
||||
|
||||
if let Some(cond) = &self.conditions {
|
||||
return Some(cond.clone());
|
||||
}
|
||||
|
||||
if let Some(parent) = &self.parent {
|
||||
if let Some(parent_loc) = locations.get(parent) {
|
||||
if let Some(cond) = parent_loc.conditions_rec() {
|
||||
return Some(cond);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// Get direct children
|
||||
pub fn children_direct(&self) -> Vec<Location> {
|
||||
let mut ret = Vec::new();
|
||||
|
||||
let locations = get_locations!();
|
||||
for loc in locations.keys() {
|
||||
let loc = locations.get(loc).unwrap();
|
||||
if let Some(parent) = &loc.parent {
|
||||
if *parent == self.id {
|
||||
ret.push(loc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
// Get all children locations
|
||||
pub fn children_recursive(&self) -> Vec<Location> {
|
||||
let mut all = Vec::new();
|
||||
|
||||
let direct = self.children_direct();
|
||||
all.extend_from_slice(&direct);
|
||||
|
||||
for loc in direct {
|
||||
let sub = loc.children_recursive();
|
||||
all.extend_from_slice(&sub);
|
||||
}
|
||||
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl ToAPI for Location {
|
||||
async fn api(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"parent": self.parent,
|
||||
"conditions": self.conditions_rec()
|
||||
})
|
||||
}
|
||||
}
|
5
src/core/mod.rs
Normal file
5
src/core/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod flow;
|
||||
pub mod item;
|
||||
pub mod location;
|
||||
pub mod transaction;
|
||||
pub mod variant;
|
212
src/core/transaction.rs
Normal file
212
src/core/transaction.rs
Normal file
|
@ -0,0 +1,212 @@
|
|||
use based::request::api::ToAPI;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::prelude::FromRow;
|
||||
|
||||
use crate::{get_itemdb, get_locations, get_pg, item::Item, variant::Variant};
|
||||
|
||||
// todo : produced / consumed by flow field?
|
||||
|
||||
/// A Transaction of an Item Variant
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Transaction {
|
||||
/// UUID
|
||||
pub id: uuid::Uuid,
|
||||
/// Associated Item
|
||||
pub item: String,
|
||||
/// Associated Variant
|
||||
pub variant: String,
|
||||
/// Price of obtaining the Item
|
||||
pub price: Option<f64>,
|
||||
/// Origin of the Item
|
||||
pub origin: Option<String>,
|
||||
/// The location of the Item
|
||||
pub location: Option<String>,
|
||||
/// Notes on Transaction
|
||||
pub note: Option<String>,
|
||||
/// Timestamp of the Transaction
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
/// Destination of the Item or who consumed it
|
||||
pub destination: Option<String>,
|
||||
/// Price the Item was exported or consumed at
|
||||
pub consumed_price: Option<f64>,
|
||||
/// Timestamp of Consumption
|
||||
pub consumed_timestamp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl Transaction {
|
||||
pub async fn new(
|
||||
item: &str,
|
||||
variant: &str,
|
||||
price: f64,
|
||||
origin: Option<&str>,
|
||||
location: Option<&str>,
|
||||
note: Option<&str>,
|
||||
) -> Self {
|
||||
sqlx::query_as("INSERT INTO transactions (item, variant, price, origin, location, note) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *")
|
||||
.bind(item)
|
||||
.bind(variant)
|
||||
.bind(price)
|
||||
.bind(origin)
|
||||
.bind(location)
|
||||
.bind(note)
|
||||
.fetch_one(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
/// Get a reference to the `Item` of the `Transaction`
|
||||
pub async fn item(&self) -> Option<&Item> {
|
||||
get_itemdb!().get_item(&self.item)
|
||||
}
|
||||
|
||||
/// Get a reference to the `Variant` of the `Item` of the `Transaction`
|
||||
pub async fn item_variant(&self) -> Option<Variant> {
|
||||
get_itemdb!().get_item(&self.item)?.variant(&self.variant)
|
||||
}
|
||||
|
||||
/// Get a `Transaction` via `id`
|
||||
pub async fn get(id: &uuid::Uuid) -> Option<Self> {
|
||||
sqlx::query_as("SELECT * FROM transactions WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Consumes the Item with `price` and `destination`
|
||||
pub async fn consume(self, price: f64, destination: &str) -> Self {
|
||||
sqlx::query_as(
|
||||
"UPDATE transactions SET consumed_timestamp = current_timestamp, consumed_price = $1, destination = $2 WHERE id = $3 RETURNING *")
|
||||
.bind(price)
|
||||
.bind(destination)
|
||||
.bind(&self.id)
|
||||
.fetch_one(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn is_expired_at(&self, time: i64) -> bool {
|
||||
if let Some(expiry) = get_itemdb!()
|
||||
.get_item(&self.item)
|
||||
.unwrap()
|
||||
.variant(&self.variant)
|
||||
.unwrap()
|
||||
.expiry
|
||||
{
|
||||
let date_added = self.created.timestamp();
|
||||
|
||||
let expiration_ts = expiry * 24 * 60 * 60;
|
||||
|
||||
return (date_added + expiration_ts) < time;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn is_expired_in_days(&self, days: i64) -> bool {
|
||||
let current_time = chrono::Utc::now().timestamp();
|
||||
self.is_expired_at(current_time + (days * 24 * 60 * 60))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_expired(&self) -> bool {
|
||||
if self.consumed_timestamp.is_some() {
|
||||
if let Some(expiry) = get_itemdb!()
|
||||
.get_item(&self.item)
|
||||
.unwrap()
|
||||
.variant(&self.variant)
|
||||
.unwrap()
|
||||
.expiry
|
||||
{
|
||||
let time_around =
|
||||
self.created.timestamp() - self.consumed_timestamp.unwrap().timestamp();
|
||||
let expiration_ts = expiry * 24 * 60 * 60;
|
||||
return time_around > expiration_ts;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let current_time = chrono::Utc::now().timestamp();
|
||||
self.is_expired_at(current_time).await
|
||||
}
|
||||
|
||||
pub async fn in_location(l: &str) -> Vec<Self> {
|
||||
sqlx::query_as(
|
||||
"SELECT * FROM transactions WHERE location = $1 AND consumed_timestamp IS NULL",
|
||||
)
|
||||
.bind(l)
|
||||
.fetch_all(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn in_location_recursive(l: &str) -> Option<Vec<Self>> {
|
||||
// get the children of this location
|
||||
let locations = get_locations!().get(l)?.children_recursive();
|
||||
|
||||
let mut transactions = Self::in_location(l).await;
|
||||
|
||||
for loc in locations {
|
||||
transactions.extend(Self::in_location(&loc.id).await);
|
||||
}
|
||||
|
||||
Some(transactions)
|
||||
}
|
||||
|
||||
/// Get all Transactions which are not consumed and are expired
|
||||
pub async fn active_expired(days: Option<i64>) -> Vec<Self> {
|
||||
let items: Vec<Self> = sqlx::query_as(
|
||||
"SELECT * FROM transactions WHERE consumed_timestamp IS NULL ORDER BY created DESC",
|
||||
)
|
||||
.fetch_all(get_pg!())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let expired_items: Vec<_> = futures::stream::iter(items)
|
||||
.filter_map(|item| async move {
|
||||
let expired = if let Some(days) = days {
|
||||
item.is_expired_in_days(days).await
|
||||
} else {
|
||||
item.is_expired().await
|
||||
};
|
||||
|
||||
if expired { Some(item) } else { None }
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
expired_items
|
||||
}
|
||||
}
|
||||
|
||||
impl ToAPI for Transaction {
|
||||
async fn api(&self) -> serde_json::Value {
|
||||
let location = if let Some(loc) = &self.location {
|
||||
Some(get_locations!().get(loc).unwrap().api().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let consumed = if self.consumed_timestamp.is_some() {
|
||||
Some(json!({
|
||||
"destination": self.destination,
|
||||
"price": self.consumed_price,
|
||||
"timestamp": self.consumed_timestamp.unwrap().timestamp()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
json!({
|
||||
"uuid": self.id,
|
||||
"item": self.item,
|
||||
"variant": self.variant,
|
||||
"price": self.price,
|
||||
"origin": self.origin,
|
||||
"location": location,
|
||||
"timestamp": self.created.timestamp(),
|
||||
"consumed": consumed,
|
||||
"note": self.note,
|
||||
"expired": self.is_expired().await,
|
||||
})
|
||||
}
|
||||
}
|
361
src/core/variant.rs
Normal file
361
src/core/variant.rs
Normal file
|
@ -0,0 +1,361 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{get_pg, location::StorageConditions, transaction::Transaction};
|
||||
|
||||
pub fn timestamp_range(year: i32, month: u32) -> (i64, i64) {
|
||||
let d = chrono::NaiveDate::from_ymd_opt(year, month, 0).unwrap();
|
||||
let t = chrono::NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap();
|
||||
let start = chrono::NaiveDateTime::new(d, t).and_utc().timestamp();
|
||||
|
||||
assert!(month <= 12);
|
||||
|
||||
let end = if month == 12 {
|
||||
let d = chrono::NaiveDate::from_ymd_opt(year + 1, month, 0).unwrap();
|
||||
let t = chrono::NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap();
|
||||
chrono::NaiveDateTime::new(d, t).and_utc().timestamp()
|
||||
} else {
|
||||
let d = chrono::NaiveDate::from_ymd_opt(year, month + 1, 0).unwrap();
|
||||
let t = chrono::NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap();
|
||||
chrono::NaiveDateTime::new(d, t).and_utc().timestamp()
|
||||
};
|
||||
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// Represents a specific instance of an item with potential variations.
|
||||
///
|
||||
/// This struct is used to describe a particular variation or instance of an item
|
||||
/// in the real world. It may include attributes or properties that deviate from
|
||||
/// the standard definition of the item. For example, different colors, sizes, or
|
||||
/// configurations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Variant {
|
||||
/// Associated Item
|
||||
pub item: String,
|
||||
/// Variant ID
|
||||
pub variant: String,
|
||||
/// Variant Name
|
||||
pub name: String,
|
||||
/// Minimum amount
|
||||
pub min: Option<i64>,
|
||||
/// Days until expiry
|
||||
pub expiry: Option<i64>,
|
||||
/// Associated barcodes
|
||||
pub barcodes: Option<Vec<i64>>,
|
||||
/// Variant Need Conditions
|
||||
pub needs: Option<VariantNeedCondition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VariantNeedCondition {
|
||||
/// Required temperature range (min-max)
|
||||
pub temperature: [f64; 2],
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// Create variant from itemdb yaml
|
||||
pub fn from_yml(json: &serde_yaml::Value, variant: &str, item: &str) -> Self {
|
||||
Self {
|
||||
item: item.to_string(),
|
||||
variant: variant.to_string(),
|
||||
name: json
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("name")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
min: json
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("min")
|
||||
.map(|x| x.as_i64().unwrap()),
|
||||
expiry: json
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("expiry")
|
||||
.map(|x| x.as_i64().unwrap()),
|
||||
barcodes: json.as_mapping().unwrap().get("barcodes").map(|x| {
|
||||
x.as_sequence()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|x| x.as_i64().unwrap())
|
||||
.collect()
|
||||
}),
|
||||
needs: json.as_mapping().unwrap().get("needs").map(|x| {
|
||||
let temp_range = x
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.get("temperature")
|
||||
.unwrap()
|
||||
.as_sequence()
|
||||
.unwrap();
|
||||
VariantNeedCondition {
|
||||
temperature: [
|
||||
temp_range.get(0).unwrap().as_f64().unwrap(),
|
||||
temp_range.get(1).unwrap().as_f64().unwrap(),
|
||||
],
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a API id for this Item Variant.
|
||||
/// The ID has the format: `<ITEM>::<VARIANT>`
|
||||
pub fn item_variant_id(&self) -> String {
|
||||
format!("{}::{}", self.item, self.variant)
|
||||
}
|
||||
|
||||
/// Returns the IDs of Transactions from this Item Variant.
|
||||
pub async fn supply_log(&self) -> Vec<String> {
|
||||
let res: Vec<(uuid::Uuid,)> = sqlx::query_as(
|
||||
"SELECT id FROM transactions WHERE item = $1 AND variant = $2 ORDER BY created DESC",
|
||||
)
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
res.into_iter().map(|x| x.0.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Returns the active Transaction of this Item Variant which are not yet consumed.
|
||||
pub async fn inventory(&self) -> Vec<Transaction> {
|
||||
sqlx::query_as("SELECT * FROM transactions WHERE item = $1 AND variant = $2 AND consumed_timestamp IS NULL ORDER BY created DESC")
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
/// Returns the IDs of the Transactions from this Item Variant which are consumed.
|
||||
pub async fn demand_log(&self, destination: Option<&str>) -> Vec<String> {
|
||||
let res: Vec<(uuid::Uuid,)> = if let Some(destination) = destination {
|
||||
sqlx::query_as(
|
||||
"SELECT id FROM transactions WHERE item = $1 AND variant = $2 AND consumed_timestamp IS NOT NULL AND destination = $3 ORDER BY created DESC"
|
||||
)
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.bind(destination)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id FROM transactions WHERE item = $1 AND variant = $2 AND consumed_timestamp IS NOT NULL ORDER BY created DESC"
|
||||
)
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
};
|
||||
res.into_iter().map(|x| x.0.to_string()).collect()
|
||||
}
|
||||
|
||||
pub async fn demand(uuid: &uuid::Uuid, price: f64, destination: &str) -> Option<Transaction> {
|
||||
// check if transaction exists
|
||||
let t = Transaction::get(uuid).await?;
|
||||
Some(t.consume(price, destination).await)
|
||||
}
|
||||
|
||||
/// Records a supply transaction in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `price` - The price of the supplied items.
|
||||
/// * `origin` - The origin or source of the supplied items.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a UUID string representing the transaction.
|
||||
pub async fn supply(
|
||||
&self,
|
||||
price: f64,
|
||||
origin: Option<&str>,
|
||||
location: Option<&str>,
|
||||
note: Option<&str>,
|
||||
) -> Transaction {
|
||||
Transaction::new(&self.item, &self.variant, price, origin, location, note).await
|
||||
}
|
||||
|
||||
/// Returns all Transactions of this Item Variant
|
||||
pub async fn get_all_transactions(&self) -> Vec<Transaction> {
|
||||
sqlx::query_as(
|
||||
"SELECT * FROM transactions WHERE item = $1 AND variant = $2 ORDER BY created DESC",
|
||||
)
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Get Transactions within `year` and `month`
|
||||
pub async fn get_transaction_timeslice(&self, year: i32, month: u32) -> Vec<Transaction> {
|
||||
let (start, end) = timestamp_range(year, month);
|
||||
|
||||
sqlx::query_as("SELECT * FROM transactions WHERE created BETWEEN to_timestamp($1) AND to_timestamp($2) ORDER BY created DESC")
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_all(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
/// Get all unique origins
|
||||
pub async fn get_unique_origins(&self) -> Vec<String> {
|
||||
let res: Vec<(String,)> = sqlx::query_as("SELECT DISTINCT(origin) FROM transactions WHERE origin NOT LIKE 'flow::%' AND item = $1 AND variant = $2")
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!()).await.unwrap();
|
||||
res.into_iter().map(|x| x.0).collect()
|
||||
}
|
||||
|
||||
/// Get all unique destinations
|
||||
pub async fn get_unique_destinations(&self) -> Vec<String> {
|
||||
let res: Vec<(String,)> = sqlx::query_as("SELECT DISTINCT(destination) FROM transactions WHERE destination NOT LIKE 'flow::%' AND item = $1 AND variant = $2")
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!()).await.unwrap();
|
||||
res.into_iter().map(|x| x.0).collect()
|
||||
}
|
||||
|
||||
/// Get the last `limit` price values for `origin`
|
||||
pub async fn price_history_by_origin(&self, origin: &str, limit: Option<i64>) -> Vec<f64> {
|
||||
let res: Vec<(f64,)> = sqlx::query_as(
|
||||
&format!("SELECT price FROM transactions WHERE item = $1 AND variant = $2 AND origin = $3 ORDER BY created DESC {}", if let Some(limit) = limit {
|
||||
format!("LIMIT {limit}")
|
||||
} else { String::new() })
|
||||
)
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.bind(origin)
|
||||
.fetch_all(get_pg!()).await.unwrap();
|
||||
res.into_iter().map(|x| x.0).collect()
|
||||
}
|
||||
|
||||
/// Get the latest price for `origin`
|
||||
pub async fn get_latest_price(&self, origin: Option<String>) -> f64 {
|
||||
if let Some(origin) = origin {
|
||||
let res: (f64,) = sqlx::query_as("SELECT price FROM transactions WHERE item = $1 AND variant = $2 AND origin = $3 ORDER BY created DESC LIMIT 1")
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.bind(origin)
|
||||
.fetch_one(get_pg!()).await.unwrap();
|
||||
res.0
|
||||
} else {
|
||||
let res: (f64,) = sqlx::query_as("SELECT price FROM transactions WHERE item = $1 AND variant = $2 ORDER BY created DESC LIMIT 1")
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.bind(origin)
|
||||
.fetch_one(get_pg!()).await.unwrap();
|
||||
res.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if item variant is below minimum. Returns if this is the case and the number needed to fulfill minimum
|
||||
pub async fn is_below_min(&self) -> (bool, i64) {
|
||||
if let Some(min) = self.min {
|
||||
let amount = self.inventory().await.len() as i64;
|
||||
if amount < min {
|
||||
return (true, min - amount);
|
||||
}
|
||||
}
|
||||
|
||||
(false, 0)
|
||||
}
|
||||
|
||||
/// Check if the `Variant` satisfies `StorageConditions`
|
||||
pub async fn satisfy_condition(&self, conditions: &StorageConditions) -> bool {
|
||||
if let Some(needs) = &self.needs {
|
||||
if let Some(room_temp) = conditions.accurate_temperature().await {
|
||||
if needs.temperature[0] < room_temp && room_temp < needs.temperature[1] {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
log::warn!("Could not get temperature for location");
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn stat(&self, full: bool) -> serde_json::Value {
|
||||
let active_transactions = self.inventory().await;
|
||||
|
||||
let total_price: f64 = active_transactions
|
||||
.iter()
|
||||
.map(|x| x.price.unwrap_or_default())
|
||||
.sum();
|
||||
|
||||
if !full {
|
||||
return json!({
|
||||
"amount": active_transactions.len(),
|
||||
"total_price": total_price
|
||||
});
|
||||
}
|
||||
|
||||
let all_transactions: Vec<Transaction> = sqlx::query_as(
|
||||
"SELECT * FROM transactions WHERE item = $1 AND variant = $2 ORDER BY created DESC",
|
||||
)
|
||||
.bind(&self.item)
|
||||
.bind(&self.variant)
|
||||
.fetch_all(get_pg!())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut expired_count = 0.0;
|
||||
|
||||
for t in &all_transactions {
|
||||
if t.is_expired().await {
|
||||
expired_count += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
let expiry_rate = expired_count / all_transactions.len() as f64;
|
||||
let mut origin_stat = HashMap::new();
|
||||
|
||||
for origin in self.get_unique_origins().await {
|
||||
let transactions_from_origin = active_transactions
|
||||
.iter()
|
||||
.filter(|x| x.origin.as_ref().map(|x| *x == origin).unwrap_or(false))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let prices = self
|
||||
.price_history_by_origin(&origin, None)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|x| x)
|
||||
.collect::<Vec<_>>();
|
||||
let prices_len = prices.len() as f64;
|
||||
let prices_summed = prices.into_iter().reduce(|acc, e| acc + e).unwrap_or(0.0);
|
||||
|
||||
let stat_json = json!({
|
||||
"average_price": prices_summed / prices_len,
|
||||
"inventory": transactions_from_origin.len()
|
||||
});
|
||||
|
||||
origin_stat.insert(origin, stat_json);
|
||||
}
|
||||
|
||||
json!({
|
||||
"amount": active_transactions.len(),
|
||||
"total_price": total_price,
|
||||
"expiry_rate": expiry_rate,
|
||||
"origins": origin_stat
|
||||
})
|
||||
}
|
||||
|
||||
pub fn api_json(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"item": self.item,
|
||||
"variant": self.variant,
|
||||
"name": self.name,
|
||||
"min": self.min,
|
||||
"expiry": self.expiry,
|
||||
"barcodes": self.barcodes,
|
||||
"needs": self.needs
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue