inventory.rs (9480B)
1 //! Inventory/stock management API handlers 2 //! 3 //! This module contains the HTTP request handlers for inventory-related endpoints. 4 //! Database operations are delegated to the repository layer in `db::repository`. 5 6 use axum::{ 7 extract::{Path, Query, State}, 8 http::StatusCode, 9 response::{IntoResponse, Json}, 10 }; 11 use opentelemetry::KeyValue; 12 use sqlx::PgPool; 13 use std::time::Instant; 14 use tracing::{error, info, instrument, warn}; 15 use uuid::Uuid; 16 17 use crate::db; 18 use crate::metrics::metrics; 19 use crate::models::{ 20 ConfirmSaleRequest, InventoryQueryParams, InventoryResponse, ReleaseStockRequest, 21 ReserveStockRequest, StockOperationResponse, UpdateStockRequest, 22 }; 23 use crate::utils::{calculate_pagination, calculate_total_pages, internal_error, not_found_error}; 24 25 /// List inventory with pagination and filtering 26 /// 27 /// # Endpoint 28 /// `GET /inventory` 29 /// 30 /// # Query Parameters 31 /// - `page` (default: 1) - Page number (1-indexed) 32 /// - `page_size` (default: 20, max: 100) - Items per page 33 /// - `stock_status` - Filter by status ('in_stock', 'low_stock', 'out_of_stock') 34 /// - `product_uuid` - Filter by specific product 35 /// - `min_stock` / `max_stock` - Filter by available quantity range 36 pub async fn list_inventory( 37 State(pool): State<PgPool>, 38 Query(params): Query<InventoryQueryParams>, 39 ) -> impl IntoResponse { 40 // Apply pagination defaults and constraints 41 let (page, page_size, offset) = calculate_pagination(params.page, params.page_size); 42 43 // Delegate to repository layer for database operations 44 let (inventory, total_count) = 45 match db::list_inventory(&pool, ¶ms, page, page_size, offset).await { 46 Ok(result) => result, 47 Err(e) => { 48 return internal_error("Failed to fetch inventory", e.to_string()); 49 } 50 }; 51 52 let total_pages = calculate_total_pages(total_count, page_size); 53 54 let response = InventoryResponse { 55 inventory, 56 total_count, 57 page, 58 page_size, 59 total_pages, 60 }; 61 62 (StatusCode::OK, Json(response)).into_response() 63 } 64 65 /// Get inventory for a specific product by UUID 66 /// 67 /// # Endpoint 68 /// `GET /inventory/{product_uuid}` 69 pub async fn get_inventory_by_product( 70 State(pool): State<PgPool>, 71 Path(product_uuid): Path<Uuid>, 72 ) -> impl IntoResponse { 73 // Delegate to repository layer for database lookup 74 match db::get_inventory_by_product(&pool, product_uuid).await { 75 Ok(Some(inventory)) => (StatusCode::OK, Json(inventory)).into_response(), 76 Ok(None) => not_found_error( 77 "Inventory not found for product", 78 serde_json::json!({"product_uuid": product_uuid.to_string()}), 79 ), 80 Err(e) => internal_error("Failed to fetch inventory", e.to_string()), 81 } 82 } 83 84 /// Update stock quantity for a product 85 /// 86 /// # Endpoint 87 /// `PUT /inventory/{product_uuid}` 88 pub async fn update_stock( 89 State(pool): State<PgPool>, 90 Path(product_uuid): Path<Uuid>, 91 Json(request): Json<UpdateStockRequest>, 92 ) -> impl IntoResponse { 93 // Delegate to repository layer for stock update 94 match db::update_stock( 95 &pool, 96 product_uuid, 97 request.quantity, 98 request.reorder_level, 99 request.reorder_quantity, 100 ) 101 .await 102 { 103 Ok(Some(available_quantity)) => ( 104 StatusCode::OK, 105 Json(StockOperationResponse { 106 success: true, 107 message: "Stock updated successfully".to_string(), 108 product_uuid, 109 available_quantity: Some(available_quantity), 110 }), 111 ) 112 .into_response(), 113 Ok(None) => not_found_error( 114 "Product not found in inventory", 115 serde_json::json!({"product_uuid": product_uuid.to_string()}), 116 ), 117 Err(e) => internal_error("Failed to update stock", e.to_string()), 118 } 119 } 120 121 /// Reserve stock for an order 122 /// 123 /// # Endpoint 124 /// `POST /inventory/reserve` 125 #[instrument( 126 name = "reserve_stock", 127 skip(pool), 128 fields( 129 otel.kind = "client", 130 db.system.name = "postgresql", 131 db.namespace = "inventory", 132 product.uuid = %request.product_uuid, 133 operation.result = tracing::field::Empty, 134 ) 135 )] 136 pub async fn reserve_stock( 137 State(pool): State<PgPool>, 138 Json(request): Json<ReserveStockRequest>, 139 ) -> impl IntoResponse { 140 // Start timing and record a reservation attempt 141 let start = Instant::now(); 142 metrics().reservation_attempts.add(1, &[]); 143 144 // Delegate to repository layer for stock reservation 145 match db::reserve_stock(&pool, request.product_uuid, request.quantity).await { 146 Ok(success) => { 147 let duration = start.elapsed().as_secs_f64(); 148 149 if success { 150 // Record successful reservation logs and metrics 151 152 tracing::Span::current().record("operation.result", "success"); 153 // also on the log record 154 info!(operation.result = "success", "Stock reserved successfully"); 155 156 metrics() 157 .reservation_duration 158 .record(duration, &[KeyValue::new("outcome", "success")]); 159 metrics() 160 .reserved_quantity 161 .add(request.quantity as u64, &[]); 162 163 ( 164 StatusCode::OK, 165 Json(StockOperationResponse { 166 success: true, 167 message: format!("Reserved {} units", request.quantity), 168 product_uuid: request.product_uuid, 169 available_quantity: None, 170 }), 171 ) 172 .into_response() 173 } else { 174 // Insufficient stock — record failure logs and metrics 175 tracing::Span::current().record("operation.result", "insufficient_stock"); 176 warn!( 177 operation.result = "insufficient_stock", 178 quantity.requested = request.quantity, 179 "Insufficient stock" 180 ); 181 182 metrics() 183 .reservation_failures 184 .add(1, &[KeyValue::new("failure.reason", "insufficient_stock")]); 185 metrics().reservation_duration.record( 186 duration, 187 &[ 188 KeyValue::new("outcome", "failure"), 189 KeyValue::new("failure.reason", "insufficient_stock"), 190 ], 191 ); 192 193 ( 194 StatusCode::CONFLICT, 195 Json(StockOperationResponse { 196 success: false, 197 message: "Insufficient stock available".to_string(), 198 product_uuid: request.product_uuid, 199 available_quantity: None, 200 }), 201 ) 202 .into_response() 203 } 204 } 205 Err(e) => { 206 // Database error — record failure logs and metrics 207 tracing::Span::current().record("operation.result", "error"); 208 error!(operation.result = "error", error.r#type = "database", error.message = %e, "Database error during stock reservation"); 209 210 let duration = start.elapsed().as_secs_f64(); 211 metrics() 212 .reservation_failures 213 .add(1, &[KeyValue::new("failure.reason", "database_error")]); 214 metrics().reservation_duration.record( 215 duration, 216 &[ 217 KeyValue::new("outcome", "failure"), 218 KeyValue::new("failure.reason", "database_error"), 219 ], 220 ); 221 222 internal_error("Failed to reserve stock", e.to_string()) 223 } 224 } 225 } 226 227 /// Release reserved stock 228 /// 229 /// # Endpoint 230 /// `POST /inventory/release` 231 pub async fn release_stock( 232 State(pool): State<PgPool>, 233 Json(request): Json<ReleaseStockRequest>, 234 ) -> impl IntoResponse { 235 // Delegate to repository layer for stock release 236 match db::release_stock(&pool, request.product_uuid, request.quantity).await { 237 Ok(_) => ( 238 StatusCode::OK, 239 Json(StockOperationResponse { 240 success: true, 241 message: format!("Released {} units", request.quantity), 242 product_uuid: request.product_uuid, 243 available_quantity: None, 244 }), 245 ) 246 .into_response(), 247 Err(e) => internal_error("Failed to release stock", e.to_string()), 248 } 249 } 250 251 /// Confirm a sale and decrease stock 252 /// 253 /// # Endpoint 254 /// `POST /inventory/confirm-sale` 255 pub async fn confirm_sale( 256 State(pool): State<PgPool>, 257 Json(request): Json<ConfirmSaleRequest>, 258 ) -> impl IntoResponse { 259 // Delegate to repository layer for sale confirmation 260 match db::confirm_sale( 261 &pool, 262 request.product_uuid, 263 request.quantity, 264 request.order_uuid, 265 ) 266 .await 267 { 268 Ok(_) => ( 269 StatusCode::OK, 270 Json(StockOperationResponse { 271 success: true, 272 message: format!("Confirmed sale of {} units", request.quantity), 273 product_uuid: request.product_uuid, 274 available_quantity: None, 275 }), 276 ) 277 .into_response(), 278 Err(e) => internal_error("Failed to confirm sale", e.to_string()), 279 } 280 }