pispas_order_kitchen/
main.rs

1#![windows_subsystem = "windows"]
2//! # `pispas_order_kitchen` — kitchen display server
3//!
4//! Standalone HTTP server (actix-web) that a kitchen display (a browser on
5//! a cheap Android tablet, typically) points to. Each active order is
6//! represented by a `Pedido` (`canal`, `numero`, `estado` =
7//! `en_preparacion` / `listo` / `recogido`) and the list persists to
8//! `comandas.json` so restarts don't lose state.
9//!
10//! Orders arrive via POST from the `order_kitchen` module inside
11//! [`pispas_modules`](../pispas_modules/index.html#modules) when a ticket
12//! with kitchen-bound items is rung up. The display polls this server and
13//! shows pending / ready orders on a TV.
14//!
15//! ```text
16//!   pispas-modules  --POST pedido-->  pispas_order_kitchen.exe
17//!                                              |
18//!                                              | GET list
19//!                                              v
20//!                                       kitchen display (browser)
21//! ```
22//!
23//! This binary runs on the same machine as `pispas-modules` and is
24//! orchestrated by the service; end users never launch it manually.
25
26use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
27use serde::{Deserialize, Serialize};
28use std::fs;
29use std::sync::Mutex;
30use easy_trace::prelude::error;
31
32const COMANDAS_FILE: &str = "comandas.json";
33
34#[derive(Clone, Serialize, Deserialize)]
35struct Pedido {
36    canal: String,
37    numero: String,
38    estado: String, // "en_preparacion", "listo", "recogido"
39}
40
41#[derive(Clone, Serialize, Deserialize)]
42struct PedidoRequest {
43    canal: String,
44    numero: String,
45    estado: Option<String>,
46}
47
48#[derive(Clone, Serialize, Deserialize)]
49struct ModoConfig {
50    modo: String,
51}
52
53struct AppState {
54    pedidos: Mutex<Vec<Pedido>>,
55    modo: Mutex<String>,
56}
57
58fn guardar_comandas(pedidos: &Vec<Pedido>) -> Result<(), std::io::Error> {
59    let json = serde_json::to_string_pretty(pedidos)?;
60    fs::write(COMANDAS_FILE, json)?;
61    Ok(())
62}
63
64fn cargar_comandas() -> Vec<Pedido> {
65    if let Ok(data) = fs::read_to_string(COMANDAS_FILE) {
66        if let Ok(pedidos) = serde_json::from_str::<Vec<Pedido>>(&data) {
67            return pedidos;
68        }
69    }
70    Vec::new()
71}
72
73#[post("/api/pedido")]
74async fn set_pedido(data: web::Data<AppState>, json: web::Json<PedidoRequest>) -> impl Responder {
75    let mut pedidos = data.pedidos.lock().unwrap();
76    let nuevo_pedido = Pedido {
77        canal: json.canal.clone(),
78        numero: json.numero.clone(),
79        estado: json.estado.clone().unwrap_or_else(|| "en_preparacion".to_string()),
80    };
81    pedidos.push(nuevo_pedido);
82
83    // Guardar con manejo de errores
84    if let Err(e) = guardar_comandas(&pedidos) {
85        error!("Error guardando comandas: {}", e);
86        return HttpResponse::InternalServerError().body("Error guardando datos");
87    }
88
89    HttpResponse::Ok().body("Pedido creado")
90}
91
92#[get("/api/pedidos")]
93async fn get_pedidos(data: web::Data<AppState>) -> impl Responder {
94    let pedidos = data.pedidos.lock().unwrap();
95    // Solo devolver pedidos que no estén recogidos
96    let pedidos_activos: Vec<Pedido> = pedidos.iter()
97        .filter(|p| p.estado != "recogido")
98        .cloned()
99        .collect();
100    HttpResponse::Ok().json(pedidos_activos)
101}
102
103#[post("/api/preparado")]
104async fn marcar_preparado(data: web::Data<AppState>, json: web::Json<PedidoRequest>) -> impl Responder {
105    let mut pedidos = data.pedidos.lock().unwrap();
106
107    let mut encontrado = false;
108    for pedido in pedidos.iter_mut() {
109        if pedido.canal == json.canal && pedido.numero == json.numero && pedido.estado == "en_preparacion" {
110            pedido.estado = "listo".to_string();
111            encontrado = true;
112            break;
113        }
114    }
115
116    if !encontrado {
117        return HttpResponse::NotFound().body("Pedido no encontrado o ya procesado");
118    }
119
120    // Guardar con manejo de errores
121    if let Err(e) = guardar_comandas(&pedidos) {
122        error!("Error guardando comandas: {}", e);
123        return HttpResponse::InternalServerError().body("Error guardando datos");
124    }
125
126    HttpResponse::Ok().body("Pedido marcado como preparado")
127}
128
129#[post("/api/archivar")]
130async fn archivar(data: web::Data<AppState>, json: web::Json<PedidoRequest>) -> impl Responder {
131    let mut pedidos = data.pedidos.lock().unwrap();
132
133    let mut encontrado = false;
134    for pedido in pedidos.iter_mut() {
135        if pedido.canal == json.canal && pedido.numero == json.numero && pedido.estado == "listo" {
136            pedido.estado = "recogido".to_string();
137            encontrado = true;
138            break;
139        }
140    }
141
142    if !encontrado {
143        return HttpResponse::NotFound().body("Pedido no encontrado o ya procesado");
144    }
145
146    // Guardar con manejo de errores
147    if let Err(e) = guardar_comandas(&pedidos) {
148        error!("Error guardando comandas: {}", e);
149        return HttpResponse::InternalServerError().body("Error guardando datos");
150    }
151
152    HttpResponse::Ok().body("Pedido archivado")
153}
154
155#[post("/api/modo")]
156async fn set_modo(data: web::Data<AppState>, json: web::Json<ModoConfig>) -> impl Responder {
157    let mut modo = data.modo.lock().unwrap();
158    *modo = json.modo.clone();
159    HttpResponse::Ok()
160}
161
162#[get("/api/modo")]
163async fn get_modo(data: web::Data<AppState>) -> impl Responder {
164    let modo = data.modo.lock().unwrap();
165    HttpResponse::Ok().json(ModoConfig { modo: modo.clone() })
166}
167
168const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico");
169const LOGO_DARK_PNG: &[u8] = include_bytes!("../static/LOGO_DARK.png");
170const LOGO_WHITE_PNG: &[u8] = include_bytes!("../static/LOGO_WHITE.png");
171const INDEX_HTML: &str = include_str!("../static/index.html");
172
173#[get("/")]
174async fn index() -> impl Responder {
175    HttpResponse::Ok()
176        .content_type("text/html; charset=utf-8")
177        .body(INDEX_HTML)
178}
179
180const KITCHEN_HTML: &str = include_str!("../static/kitchen.html");
181
182#[get("/kitchen")]
183async fn cocina() -> impl Responder {
184    HttpResponse::Ok()
185        .content_type("text/html; charset=utf-8")
186        .body(KITCHEN_HTML)
187}
188pub const ORDER_KITCHEN_LOG: &str = "order-kitchen.log";
189
190#[actix_web::main]
191async fn main() -> std::io::Result<()> {
192    easy_trace::init(
193        sharing::paths::user_log_dir().to_str().unwrap_or_default(),
194        ORDER_KITCHEN_LOG,
195    );
196    
197    fs::create_dir_all("static").ok();
198
199    let state = web::Data::new(AppState {
200        pedidos: Mutex::new(cargar_comandas()), // ¡Cargar al inicio!
201        modo: Mutex::new(String::from("light")),
202    });
203
204    HttpServer::new(move || {
205        App::new()
206            .app_data(state.clone())
207            .service(set_pedido)
208            .service(get_pedidos)
209            .service(marcar_preparado)
210            .service(archivar)
211            .service(set_modo)
212            .service(get_modo)
213            .service(index)
214            .service(cocina)
215            .route(
216                "/favicon.ico",
217                web::get().to(|| async {
218                    HttpResponse::Ok()
219                        .content_type("image/x-icon")
220                        .body(FAVICON_ICO)
221                }),
222            )    
223            .route(
224            "/LOGO_DARK.png",
225            web::get().to(|| async {
226                HttpResponse::Ok()
227                    .content_type("image/png")
228                    .body(LOGO_DARK_PNG)
229            }),            
230            )
231            .route(
232                "/LOGO_WHITE.png",
233                web::get().to(|| async {
234                    HttpResponse::Ok()
235                        .content_type("image/png")
236                        .body(LOGO_WHITE_PNG)
237                }),
238            )
239            .service(actix_files::Files::new("/static", "static").show_files_listing())
240    })
241        .bind(("0.0.0.0", 8000))?
242        .run()
243        .await
244}