| | 1 | = Напреден апликативен развој (Трансакции, Pooling) = |
| | 2 | |
| | 3 | За да се разликуваат нарачките како ентитети во системот и трансакциите како теоретски поим во областа на датабазите, првите ќе ги нарекувам „нарачки", а вторите „трансакции во датабаза" во понатамошните спомнувања. |
| | 4 | |
| | 5 | == Pooling на конекции == |
| | 6 | |
| | 7 | Во Rust апликацијата се користи библиотеката '''sqlx''' со нејзиниот `PgPool` механизам за pooling на конекции. Pool-от се креира еднаш при стартување на апликацијата и се споделува низ сите Tauri команди преку `State<'_, PgPool>`. |
| | 8 | |
| | 9 | Конфигурацијата е дефинирана во функцијата `create_pool()`: |
| | 10 | |
| | 11 | {{{ |
| | 12 | use sqlx::{PgPool, postgres::PgPoolOptions}; |
| | 13 | |
| | 14 | pub async fn create_pool() -> PgPool { |
| | 15 | let database_url = |
| | 16 | "postgres://db_202526z_va_prj_poshoreca_owner:4040c5ddf69@localhost:5432/db_202526z_va_prj_poshoreca"; |
| | 17 | |
| | 18 | PgPoolOptions::new() |
| | 19 | .max_connections(10) |
| | 20 | .after_connect(|conn, _meta| { |
| | 21 | Box::pin(async move { |
| | 22 | sqlx::query("SET search_path TO project, public") |
| | 23 | .execute(conn) |
| | 24 | .await?; |
| | 25 | Ok(()) |
| | 26 | }) |
| | 27 | }) |
| | 28 | .connect(database_url) |
| | 29 | .await |
| | 30 | .expect("Failed to connect to PostgreSQL") |
| | 31 | } |
| | 32 | }}} |
| | 33 | |
| | 34 | Овој систем одржува до '''10 паралелни конекции''', што драстично ја намалува латенцијата. Дополнително, `after_connect` гарантира дека секоја нова конекција го има поставено `search_path` на `project` шемата. |
| | 35 | |
| | 36 | Примери за користење на конекција од pool-от низ апликацијата: |
| | 37 | |
| | 38 | * `create_order()` во `src/commands/order.rs` — користи `pool.begin()` за трансакција |
| | 39 | * `get_products()` во `src/commands/product.rs` — користи `pool.inner()` за читање |
| | 40 | * `create_payment()` во `src/commands/payment.rs` — користи `pool.inner()` за едноставен INSERT |
| | 41 | * `get_products_by_category()` во `src/commands/product.rs` — користи `pool.inner()` за филтрирано читање |
| | 42 | |
| | 43 | == Трансакции во датабаза == |
| | 44 | |
| | 45 | За секоја операција што бара повеќекратен упис или проверка пред упис, се користи `pool.begin()` блок со цел да се елиминира ризикот од race conditions и делумни податоци. Ако било кој чекор врати грешка, целиот блок прави rollback. |
| | 46 | |
| | 47 | === Пример 1: Креирање на нарачка === |
| | 48 | |
| | 49 | Сместена во `src/commands/order.rs`, функцијата `create_order` ги извршува следните чекори како една атомична целина: |
| | 50 | |
| | 51 | # Впишува нов запис во табелата `orders` со статус `АКТИВНА`. |
| | 52 | # Автоматски ја означува масата како `ЗАФАТЕНА` преку `set_table_status`. |
| | 53 | # Ако нешто не успее, целиот блок прави rollback и нарачката не се креира. |
| | 54 | |
| | 55 | {{{ |
| | 56 | #[tauri::command] |
| | 57 | pub async fn create_order( |
| | 58 | user_id: i32, |
| | 59 | table_id: i32, |
| | 60 | pool: State<'_, PgPool> |
| | 61 | ) -> Result<i32, String> { |
| | 62 | |
| | 63 | let mut tx = pool.begin() |
| | 64 | .await |
| | 65 | .map_err(|e| e.to_string())?; |
| | 66 | |
| | 67 | let order_id: i32 = sqlx::query_scalar( |
| | 68 | " |
| | 69 | INSERT INTO orders |
| | 70 | ( |
| | 71 | user_id, |
| | 72 | table_id, |
| | 73 | status |
| | 74 | ) |
| | 75 | VALUES |
| | 76 | ( |
| | 77 | $1, |
| | 78 | $2, |
| | 79 | 'АКТИВНА' |
| | 80 | ) |
| | 81 | RETURNING order_id |
| | 82 | " |
| | 83 | ) |
| | 84 | .bind(user_id) |
| | 85 | .bind(table_id) |
| | 86 | .fetch_one(&mut *tx) |
| | 87 | .await |
| | 88 | .map_err(|e| e.to_string())?; |
| | 89 | |
| | 90 | set_table_status(table_id, STATUS_OCCUPIED, &mut *tx) |
| | 91 | .await?; |
| | 92 | |
| | 93 | tx.commit() |
| | 94 | .await |
| | 95 | .map_err(|e| e.to_string())?; |
| | 96 | |
| | 97 | Ok(order_id) |
| | 98 | } |
| | 99 | }}} |
| | 100 | |
| | 101 | '''Зошто е трансакција:''' Ако нарачката се креира, но `set_table_status` не успее, нарачката не смее да остане во базата. Трансакцијата гарантира дека или '''двете''' операции успеваат, или '''ниту една'''. |
| | 102 | |
| | 103 | === Пример 2: Процесирање на плаќање === |
| | 104 | |
| | 105 | Сместена во `src/commands/order.rs`, функцијата `pay_order` ги извршува следните чекори како една трансакција: |
| | 106 | |
| | 107 | # Проверува дали нарачката е веќе платена. |
| | 108 | # Го зема `table_id` за подоцна да ја ослободи масата. |
| | 109 | # Ја пресметува вкупната сума од `order_item`. |
| | 110 | # Ако сумата е нула, ја отфрла трансакцијата. |
| | 111 | # Впишува запис во `payment`. |
| | 112 | # Ја ажурира состојбата на нарачката на `ПЛАТЕНА`. |
| | 113 | # Го намалува залихот на производите преку `update_stock`. |
| | 114 | # Ја ослободува масата преку `set_table_status`. |
| | 115 | # Генерира запис во `invoice` со број `INV-{order_id}`. |
| | 116 | # Ако било кој чекор не успее, целиот блок прави rollback. |
| | 117 | |
| | 118 | {{{ |
| | 119 | #[tauri::command] |
| | 120 | pub async fn pay_order( |
| | 121 | order_id: i32, |
| | 122 | method: String, |
| | 123 | pool: State<'_, PgPool> |
| | 124 | ) -> Result<String, String> { |
| | 125 | |
| | 126 | let mut tx = pool.begin() |
| | 127 | .await |
| | 128 | .map_err(|e| e.to_string())?; |
| | 129 | |
| | 130 | let existing: Option<i32> = sqlx::query_scalar( |
| | 131 | " |
| | 132 | SELECT payment_id |
| | 133 | FROM payment |
| | 134 | WHERE order_id = $1 |
| | 135 | " |
| | 136 | ) |
| | 137 | .bind(order_id) |
| | 138 | .fetch_optional(&mut *tx) |
| | 139 | .await |
| | 140 | .map_err(|e| e.to_string())?; |
| | 141 | |
| | 142 | if existing.is_some() { |
| | 143 | return Err("Нарачката е веќе платена".to_string()); |
| | 144 | } |
| | 145 | |
| | 146 | let table_id: i32 = sqlx::query_scalar( |
| | 147 | " |
| | 148 | SELECT table_id |
| | 149 | FROM orders |
| | 150 | WHERE order_id = $1 |
| | 151 | " |
| | 152 | ) |
| | 153 | .bind(order_id) |
| | 154 | .fetch_one(&mut *tx) |
| | 155 | .await |
| | 156 | .map_err(|e| e.to_string())?; |
| | 157 | |
| | 158 | let total: Decimal = sqlx::query_scalar( |
| | 159 | " |
| | 160 | SELECT COALESCE(SUM(quantity * unit_price), 0) |
| | 161 | FROM order_item |
| | 162 | WHERE order_id = $1 |
| | 163 | " |
| | 164 | ) |
| | 165 | .bind(order_id) |
| | 166 | .fetch_one(&mut *tx) |
| | 167 | .await |
| | 168 | .map_err(|e| e.to_string())?; |
| | 169 | |
| | 170 | if total == Decimal::ZERO { |
| | 171 | return Err("Нарачката е празна".to_string()); |
| | 172 | } |
| | 173 | |
| | 174 | let payment_id: i32 = sqlx::query_scalar( |
| | 175 | " |
| | 176 | INSERT INTO payment |
| | 177 | ( |
| | 178 | order_id, |
| | 179 | amount, |
| | 180 | method |
| | 181 | ) |
| | 182 | VALUES |
| | 183 | ( |
| | 184 | $1, |
| | 185 | $2, |
| | 186 | $3 |
| | 187 | ) |
| | 188 | RETURNING payment_id |
| | 189 | " |
| | 190 | ) |
| | 191 | .bind(order_id) |
| | 192 | .bind(total) |
| | 193 | .bind(method) |
| | 194 | .fetch_one(&mut *tx) |
| | 195 | .await |
| | 196 | .map_err(|e| e.to_string())?; |
| | 197 | |
| | 198 | sqlx::query( |
| | 199 | " |
| | 200 | UPDATE orders |
| | 201 | SET status = 'ПЛАТЕНА' |
| | 202 | WHERE order_id = $1 |
| | 203 | " |
| | 204 | ) |
| | 205 | .bind(order_id) |
| | 206 | .execute(&mut *tx) |
| | 207 | .await |
| | 208 | .map_err(|e| e.to_string())?; |
| | 209 | |
| | 210 | update_stock(order_id, &mut tx) |
| | 211 | .await?; |
| | 212 | |
| | 213 | set_table_status(table_id, STATUS_FREE, &mut *tx) |
| | 214 | .await?; |
| | 215 | |
| | 216 | let invoice_number = format!("INV-{}", order_id); |
| | 217 | |
| | 218 | sqlx::query( |
| | 219 | " |
| | 220 | INSERT INTO invoice |
| | 221 | ( |
| | 222 | payment_id, |
| | 223 | invoice_number |
| | 224 | ) |
| | 225 | VALUES |
| | 226 | ( |
| | 227 | $1, |
| | 228 | $2 |
| | 229 | ) |
| | 230 | " |
| | 231 | ) |
| | 232 | .bind(payment_id) |
| | 233 | .bind(&invoice_number) |
| | 234 | .execute(&mut *tx) |
| | 235 | .await |
| | 236 | .map_err(|e| e.to_string())?; |
| | 237 | |
| | 238 | tx.commit() |
| | 239 | .await |
| | 240 | .map_err(|e| e.to_string())?; |
| | 241 | |
| | 242 | Ok(invoice_number) |
| | 243 | } |
| | 244 | }}} |
| | 245 | |
| | 246 | '''Зошто е трансакција:''' Ова е најкомплексната операција во системот. Вклучува 6+ операции кои мора сите да успеат или ниту една: |
| | 247 | |
| | 248 | * Ако плаќањето успее, но `update_stock` не успее → залихот нема да се намали. |
| | 249 | * Ако `update_stock` успее, но `invoice` не успее → фактурата нема да се генерира. |
| | 250 | * Ако масата не се ослободи → масата останува зафатена засекогаш. |
| | 251 | |
| | 252 | Трансакцијата гарантира '''atomicity''' — сè или ништо. |
| | 253 | |
| | 254 | === Пример 3: Додавање на ставка во нарачка === |
| | 255 | |
| | 256 | Сместена во `src/commands/order_item.rs`, функцијата `add_order_item` користи трансакција за да провери дали производот веќе постои во нарачката пред да го вметне или ажурира. |
| | 257 | |
| | 258 | {{{ |
| | 259 | #[tauri::command] |
| | 260 | pub async fn add_order_item( |
| | 261 | token: String, |
| | 262 | order_id: i32, |
| | 263 | product_id: i32, |
| | 264 | quantity: i32, |
| | 265 | pool: State<'_, PgPool>, |
| | 266 | sessions: State<'_, ActiveSessions>, |
| | 267 | ) -> Result<(), String> { |
| | 268 | { |
| | 269 | let lock = sessions.0.lock().map_err(|_| "Системска грешка".to_string())?; |
| | 270 | if !lock.contains_key(&token) { |
| | 271 | return Err("Невалидна сесија. Најавете се повторно.".to_string()); |
| | 272 | } |
| | 273 | } |
| | 274 | |
| | 275 | if quantity <= 0 { |
| | 276 | return Err("Количината мора да биде поголема од 0".to_string()); |
| | 277 | } |
| | 278 | |
| | 279 | let mut tx = pool.begin().await.map_err(|e| e.to_string())?; |
| | 280 | |
| | 281 | let existing: Option<i32> = sqlx::query_scalar( |
| | 282 | " |
| | 283 | SELECT item_number |
| | 284 | FROM order_item |
| | 285 | WHERE order_id = $1 |
| | 286 | AND product_id = $2 |
| | 287 | " |
| | 288 | ) |
| | 289 | .bind(order_id) |
| | 290 | .bind(product_id) |
| | 291 | .fetch_optional(&mut *tx) |
| | 292 | .await |
| | 293 | .map_err(|e| e.to_string())?; |
| | 294 | |
| | 295 | if let Some(item_number) = existing { |
| | 296 | sqlx::query( |
| | 297 | " |
| | 298 | UPDATE order_item |
| | 299 | SET quantity = quantity + $3 |
| | 300 | WHERE order_id = $1 |
| | 301 | AND item_number = $2 |
| | 302 | " |
| | 303 | ) |
| | 304 | .bind(order_id) |
| | 305 | .bind(item_number) |
| | 306 | .bind(quantity) |
| | 307 | .execute(&mut *tx) |
| | 308 | .await |
| | 309 | .map_err(|e| e.to_string())?; |
| | 310 | } else { |
| | 311 | let next_item_number: i32 = sqlx::query_scalar( |
| | 312 | " |
| | 313 | SELECT COALESCE(MAX(item_number), 0) + 1 |
| | 314 | FROM order_item |
| | 315 | WHERE order_id = $1 |
| | 316 | " |
| | 317 | ) |
| | 318 | .bind(order_id) |
| | 319 | .fetch_one(&mut *tx) |
| | 320 | .await |
| | 321 | .map_err(|e| e.to_string())?; |
| | 322 | |
| | 323 | let unit_price: rust_decimal::Decimal = sqlx::query_scalar( |
| | 324 | "SELECT price FROM product WHERE product_id = $1" |
| | 325 | ) |
| | 326 | .bind(product_id) |
| | 327 | .fetch_one(&mut *tx) |
| | 328 | .await |
| | 329 | .map_err(|e| e.to_string())?; |
| | 330 | |
| | 331 | sqlx::query( |
| | 332 | " |
| | 333 | INSERT INTO order_item (order_id, item_number, product_id, quantity, unit_price) |
| | 334 | VALUES ($1, $2, $3, $4, $5) |
| | 335 | " |
| | 336 | ) |
| | 337 | .bind(order_id) |
| | 338 | .bind(next_item_number) |
| | 339 | .bind(product_id) |
| | 340 | .bind(quantity) |
| | 341 | .bind(unit_price) |
| | 342 | .execute(&mut *tx) |
| | 343 | .await |
| | 344 | .map_err(|e| e.to_string())?; |
| | 345 | } |
| | 346 | |
| | 347 | tx.commit().await.map_err(|e| e.to_string())?; |
| | 348 | Ok(()) |
| | 349 | } |
| | 350 | }}} |
| | 351 | |
| | 352 | === Пример 4: Затворање на смена (Z-извештај) === |
| | 353 | |
| | 354 | Сместена во `src/commands/report.rs`, функцијата `z_report` користи трансакција за атомично пресметување и впишување на Z-извештајот. |
| | 355 | |
| | 356 | {{{ |
| | 357 | #[tauri::command] |
| | 358 | pub async fn z_report( |
| | 359 | token: String, |
| | 360 | pool: State<'_, PgPool>, |
| | 361 | sessions: State<'_, ActiveSessions>, |
| | 362 | ) -> Result<serde_json::Value, String> { |
| | 363 | let session = require_admin_session(&token, sessions.inner()).await?; |
| | 364 | let admin_id = session.user_id; |
| | 365 | |
| | 366 | let mut tx = pool.begin() |
| | 367 | .await |
| | 368 | .map_err(|e| e.to_string())?; |
| | 369 | |
| | 370 | let last_close: Option<chrono::NaiveDateTime> = sqlx::query_scalar( |
| | 371 | "SELECT MAX(closed_at) FROM shift_close" |
| | 372 | ) |
| | 373 | .fetch_one(&mut *tx) |
| | 374 | .await |
| | 375 | .map_err(|e| e.to_string())?; |
| | 376 | |
| | 377 | let since = last_close.unwrap_or_else(|| { |
| | 378 | chrono::NaiveDate::from_ymd_opt(2000, 1, 1) |
| | 379 | .unwrap() |
| | 380 | .and_hms_opt(0, 0, 0) |
| | 381 | .unwrap() |
| | 382 | }); |
| | 383 | |
| | 384 | let row = sqlx::query( |
| | 385 | " |
| | 386 | SELECT |
| | 387 | COALESCE(SUM(amount), 0) as total, |
| | 388 | COUNT(*) as order_count |
| | 389 | FROM payment |
| | 390 | WHERE payment_date > $1 |
| | 391 | " |
| | 392 | ) |
| | 393 | .bind(since) |
| | 394 | .fetch_one(&mut *tx) |
| | 395 | .await |
| | 396 | .map_err(|e| e.to_string())?; |
| | 397 | |
| | 398 | let total: Decimal = row.get("total"); |
| | 399 | let order_count: i64 = row.get("order_count"); |
| | 400 | |
| | 401 | sqlx::query( |
| | 402 | " |
| | 403 | INSERT INTO shift_close (closed_by, total, order_count) |
| | 404 | VALUES ($1, $2, $3) |
| | 405 | " |
| | 406 | ) |
| | 407 | .bind(admin_id) |
| | 408 | .bind(total) |
| | 409 | .bind(order_count as i32) |
| | 410 | .execute(&mut *tx) |
| | 411 | .await |
| | 412 | .map_err(|e| e.to_string())?; |
| | 413 | |
| | 414 | tx.commit() |
| | 415 | .await |
| | 416 | .map_err(|e| e.to_string())?; |
| | 417 | |
| | 418 | Ok(serde_json::json!({ |
| | 419 | "total": total.to_string(), |
| | 420 | "order_count": order_count, |
| | 421 | "since": since.to_string() |
| | 422 | })) |
| | 423 | } |
| | 424 | }}} |
| | 425 | |
| | 426 | === Пример 5: Намалување на залиха при продажба === |
| | 427 | |
| | 428 | Сместена во `src/commands/inventory.rs`, функцијата `update_stock` се повикува **во истата трансакција** како и `pay_order`, што гарантира дека залихот е секогаш синхронизиран со продажбата. |
| | 429 | |
| | 430 | {{{ |
| | 431 | pub async fn update_stock( |
| | 432 | order_id: i32, |
| | 433 | conn: &mut sqlx::PgConnection |
| | 434 | ) -> Result<(), String> { |
| | 435 | let rows = sqlx::query( |
| | 436 | "SELECT product_id, quantity FROM order_item WHERE order_id = $1" |
| | 437 | ) |
| | 438 | .bind(order_id) |
| | 439 | .fetch_all(&mut *conn) |
| | 440 | .await |
| | 441 | .map_err(|e| e.to_string())?; |
| | 442 | |
| | 443 | for row in rows { |
| | 444 | let p_id: i32 = row.get("product_id"); |
| | 445 | let order_qty: i32 = row.get("quantity"); |
| | 446 | |
| | 447 | let recipe_row = sqlx::query( |
| | 448 | "SELECT recipe_id FROM recipe WHERE product_id = $1" |
| | 449 | ) |
| | 450 | .bind(p_id) |
| | 451 | .fetch_optional(&mut *conn) |
| | 452 | .await |
| | 453 | .map_err(|e| e.to_string())?; |
| | 454 | |
| | 455 | if let Some(r) = recipe_row { |
| | 456 | let r_id: i32 = r.get("recipe_id"); |
| | 457 | |
| | 458 | let ings = sqlx::query( |
| | 459 | "SELECT ingredient_id, quantity_needed FROM recipe_item WHERE recipe_id = $1" |
| | 460 | ) |
| | 461 | .bind(r_id) |
| | 462 | .fetch_all(&mut *conn) |
| | 463 | .await |
| | 464 | .map_err(|e| e.to_string())?; |
| | 465 | |
| | 466 | for ing in ings { |
| | 467 | let ing_id: i32 = ing.get("ingredient_id"); |
| | 468 | let qty_needed: rust_decimal::Decimal = ing.get("quantity_needed"); |
| | 469 | |
| | 470 | sqlx::query( |
| | 471 | "UPDATE ingredient SET current_stock = current_stock - ($1 * $2) WHERE ingredient_id = $3" |
| | 472 | ) |
| | 473 | .bind(qty_needed) |
| | 474 | .bind(order_qty as f64) |
| | 475 | .bind(ing_id) |
| | 476 | .execute(&mut *conn) |
| | 477 | .await |
| | 478 | .map_err(|e| e.to_string())?; |
| | 479 | } |
| | 480 | } else { |
| | 481 | sqlx::query( |
| | 482 | "INSERT INTO inventory (product_id, quantity_change, operation_type) VALUES ($1, -$2, 'ПРОДАЖБА')" |
| | 483 | ) |
| | 484 | .bind(p_id) |
| | 485 | .bind(order_qty) |
| | 486 | .execute(&mut *conn) |
| | 487 | .await |
| | 488 | .map_err(|e| e.to_string())?; |
| | 489 | } |
| | 490 | } |
| | 491 | Ok(()) |
| | 492 | } |
| | 493 | }}} |
| | 494 | |
| | 495 | === Заштитен механизам === |
| | 496 | |
| | 497 | Ако било кој чекор во `pool.begin()` блокот врати грешка (на пример: нарачката е веќе платена, сумата е нула, масата не постои), целиот блок прави '''rollback''' и извршувањето се прекинува. Ова значи дека '''ниту еден делумен податок не останува трајно зачуван''' во базата. |
| | 498 | |
| | 499 | Овој механизам е критичен за: |
| | 500 | |
| | 501 | * Да се спречи креирање на нарачка без означена маса како зафатена. |
| | 502 | * Да се спречи двојно плаќање на иста нарачка (проверка `existing.is_some()`). |
| | 503 | * Да се спречи ослободување на маса без успешно плаќање. |
| | 504 | * Да се осигура дека залихот е секогаш конзистентен со продажбата. |
| | 505 | * Да се осигура дека фактурата е секогаш генерирана со плаќањето. |
| | 506 | * Да се осигура дека Z-извештајот е секогаш конзистентен со плаќањата. |