use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lot {
pub id: Uuid,
pub lot_number: String,
pub sku: String,
pub status: LotStatus,
pub quantity_produced: Decimal,
pub quantity_remaining: Decimal,
pub quantity_reserved: Decimal,
pub quantity_quarantined: Decimal,
pub production_date: DateTime<Utc>,
pub expiration_date: Option<DateTime<Utc>>,
pub best_before_date: Option<DateTime<Utc>>,
pub supplier_lot: Option<String>,
pub supplier_id: Option<Uuid>,
pub work_order_id: Option<Uuid>,
pub purchase_order_id: Option<Uuid>,
pub cost_per_unit: Option<Decimal>,
pub attributes: serde_json::Value,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LotStatus {
Active,
Quarantine,
Expired,
Consumed,
OnHold,
Recalled,
Scrapped,
}
impl Default for LotStatus {
fn default() -> Self {
Self::Active
}
}
impl std::str::FromStr for LotStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"active" => Ok(Self::Active),
"quarantine" => Ok(Self::Quarantine),
"expired" => Ok(Self::Expired),
"consumed" => Ok(Self::Consumed),
"on_hold" => Ok(Self::OnHold),
"recalled" => Ok(Self::Recalled),
"scrapped" => Ok(Self::Scrapped),
_ => Err(format!("Unknown lot status: {}", s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LotTransaction {
pub id: Uuid,
pub lot_id: Uuid,
pub transaction_type: LotTransactionType,
pub quantity: Decimal,
pub reference_type: String,
pub reference_id: Uuid,
pub from_location_id: Option<i32>,
pub to_location_id: Option<i32>,
pub reason: Option<String>,
pub performed_by: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LotTransactionType {
Received,
Consumed,
Adjusted,
Reserved,
Released,
Quarantined,
QuarantineReleased,
Transferred,
Scrapped,
Returned,
Split,
Merged,
}
impl Default for LotTransactionType {
fn default() -> Self {
Self::Received
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LotCertificate {
pub id: Uuid,
pub lot_id: Uuid,
pub certificate_type: CertificateType,
pub certificate_number: Option<String>,
pub document_url: Option<String>,
pub issued_by: Option<String>,
pub issued_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CertificateType {
Coa,
Coc,
Msds,
Sds,
TestReport,
InspectionReport,
CountryOfOrigin,
Other,
}
impl Default for CertificateType {
fn default() -> Self {
Self::Coa
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LotLocation {
pub lot_id: Uuid,
pub location_id: i32,
pub quantity: Decimal,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceabilityResult {
pub lot: Lot,
pub upstream: Vec<TraceNode>,
pub downstream: Vec<TraceNode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceNode {
pub node_type: TraceNodeType,
pub node_id: Uuid,
pub reference_number: Option<String>,
pub lot_number: Option<String>,
pub serial_number: Option<String>,
pub quantity: Decimal,
pub timestamp: DateTime<Utc>,
pub entity_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TraceNodeType {
PurchaseOrder,
Receipt,
WorkOrder,
Order,
Shipment,
Return,
Transfer,
Adjustment,
}
impl std::fmt::Display for TraceNodeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PurchaseOrder => write!(f, "purchase_order"),
Self::Receipt => write!(f, "receipt"),
Self::WorkOrder => write!(f, "work_order"),
Self::Order => write!(f, "order"),
Self::Shipment => write!(f, "shipment"),
Self::Return => write!(f, "return"),
Self::Transfer => write!(f, "transfer"),
Self::Adjustment => write!(f, "adjustment"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateLot {
pub lot_number: Option<String>,
pub sku: String,
pub quantity: Decimal,
pub production_date: Option<DateTime<Utc>>,
pub expiration_date: Option<DateTime<Utc>>,
pub best_before_date: Option<DateTime<Utc>>,
pub supplier_lot: Option<String>,
pub supplier_id: Option<Uuid>,
pub work_order_id: Option<Uuid>,
pub purchase_order_id: Option<Uuid>,
pub cost_per_unit: Option<Decimal>,
pub attributes: Option<serde_json::Value>,
pub notes: Option<String>,
pub initial_location_id: Option<i32>,
}
impl Default for CreateLot {
fn default() -> Self {
Self {
lot_number: None,
sku: String::new(),
quantity: Decimal::ZERO,
production_date: None,
expiration_date: None,
best_before_date: None,
supplier_lot: None,
supplier_id: None,
work_order_id: None,
purchase_order_id: None,
cost_per_unit: None,
attributes: None,
notes: None,
initial_location_id: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateLot {
pub status: Option<LotStatus>,
pub expiration_date: Option<DateTime<Utc>>,
pub best_before_date: Option<DateTime<Utc>>,
pub cost_per_unit: Option<Decimal>,
pub attributes: Option<serde_json::Value>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdjustLot {
pub lot_id: Uuid,
pub quantity_change: Decimal,
pub reason: String,
pub reference_type: Option<String>,
pub reference_id: Option<Uuid>,
pub location_id: Option<i32>,
pub performed_by: Option<String>,
}
impl Default for AdjustLot {
fn default() -> Self {
Self {
lot_id: Uuid::nil(),
quantity_change: Decimal::ZERO,
reason: String::new(),
reference_type: None,
reference_id: None,
location_id: None,
performed_by: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumeLot {
pub lot_id: Uuid,
pub quantity: Decimal,
pub reference_type: String,
pub reference_id: Uuid,
pub location_id: Option<i32>,
pub performed_by: Option<String>,
}
impl Default for ConsumeLot {
fn default() -> Self {
Self {
lot_id: Uuid::nil(),
quantity: Decimal::ZERO,
reference_type: String::new(),
reference_id: Uuid::nil(),
location_id: None,
performed_by: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReserveLot {
pub lot_id: Uuid,
pub quantity: Decimal,
pub reference_type: String,
pub reference_id: Uuid,
pub expires_in_seconds: Option<i64>,
}
impl Default for ReserveLot {
fn default() -> Self {
Self {
lot_id: Uuid::nil(),
quantity: Decimal::ZERO,
reference_type: String::new(),
reference_id: Uuid::nil(),
expires_in_seconds: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferLot {
pub lot_id: Uuid,
pub quantity: Decimal,
pub from_location_id: i32,
pub to_location_id: i32,
pub reason: Option<String>,
pub performed_by: Option<String>,
}
impl Default for TransferLot {
fn default() -> Self {
Self {
lot_id: Uuid::nil(),
quantity: Decimal::ZERO,
from_location_id: 0,
to_location_id: 0,
reason: None,
performed_by: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SplitLot {
pub lot_id: Uuid,
pub quantity: Decimal,
pub new_lot_number: Option<String>,
pub reason: Option<String>,
}
impl Default for SplitLot {
fn default() -> Self {
Self { lot_id: Uuid::nil(), quantity: Decimal::ZERO, new_lot_number: None, reason: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MergeLots {
pub source_lot_ids: Vec<Uuid>,
pub target_lot_number: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LotFilter {
pub sku: Option<String>,
pub lot_number: Option<String>,
pub status: Option<LotStatus>,
pub supplier_id: Option<Uuid>,
pub work_order_id: Option<Uuid>,
pub purchase_order_id: Option<Uuid>,
pub expiring_before: Option<DateTime<Utc>>,
pub expiring_after: Option<DateTime<Utc>>,
pub has_quantity: Option<bool>,
pub location_id: Option<i32>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddLotCertificate {
pub lot_id: Uuid,
pub certificate_type: CertificateType,
pub certificate_number: Option<String>,
pub document_url: Option<String>,
pub issued_by: Option<String>,
pub issued_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
impl Default for AddLotCertificate {
fn default() -> Self {
Self {
lot_id: Uuid::nil(),
certificate_type: CertificateType::default(),
certificate_number: None,
document_url: None,
issued_by: None,
issued_at: None,
expires_at: None,
notes: None,
}
}
}
impl Lot {
pub fn has_available(&self) -> bool {
self.quantity_available() > Decimal::ZERO
}
pub fn quantity_available(&self) -> Decimal {
self.quantity_remaining - self.quantity_reserved - self.quantity_quarantined
}
pub fn is_expired(&self) -> bool {
if let Some(exp) = self.expiration_date { Utc::now() > exp } else { false }
}
pub fn is_expiring_soon(&self, days: i64) -> bool {
if let Some(exp) = self.expiration_date {
let threshold = Utc::now() + chrono::Duration::days(days);
exp <= threshold && !self.is_expired()
} else {
false
}
}
pub fn can_consume(&self, quantity: Decimal) -> bool {
self.status == LotStatus::Active && self.quantity_available() >= quantity
}
pub fn can_reserve(&self, quantity: Decimal) -> bool {
self.status == LotStatus::Active && self.quantity_available() >= quantity
}
pub fn days_until_expiration(&self) -> Option<i64> {
self.expiration_date.map(|exp| (exp - Utc::now()).num_days())
}
pub fn shelf_life_remaining(&self) -> Option<Decimal> {
if let Some(exp) = self.expiration_date {
let total_days = (exp - self.production_date).num_days();
if total_days > 0 {
let remaining_days = (exp - Utc::now()).num_days();
Some(
Decimal::from(remaining_days.max(0)) / Decimal::from(total_days)
* Decimal::from(100),
)
} else {
None
}
} else {
None
}
}
}