Skip to main content

mcp_inventory/
server.rs

1use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router};
2use serde_json::{json, Value};
3use reqwest::Client;
4use crate::types::*;
5use crate::store::Store;
6
7fn now() -> String { chrono::Utc::now().to_rfc3339() }
8
9#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
10pub struct ItemInput { pub sku: String, pub name: String, pub category: String, pub unit: Option<String>, pub reorder_point: Option<f64>, pub reorder_qty: Option<f64>, pub cost: Option<f64>, pub currency: Option<String> }
11
12#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
13pub struct LocationInput { pub name: String, pub location_type: String, pub parent_id: Option<String>, pub address: Option<String>, /// Max units capacity
14    pub capacity_units: Option<f64>, /// Max weight in kg
15    pub capacity_weight_kg: Option<f64>, /// Max volume in m³
16    pub capacity_volume_m3: Option<f64> }
17
18#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
19pub struct ReceiveInput { pub sku: String, pub quantity: f64, pub location_id: String, pub reference: Option<String>, pub actor: String, pub lot_number: Option<String> }
20
21#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
22pub struct IssueInput { pub sku: String, pub quantity: f64, pub location_id: String, pub reference: Option<String>, pub actor: String }
23
24#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
25pub struct TransferInput { pub sku: String, pub quantity: f64, pub from_location: String, pub to_location: String, pub actor: String }
26
27#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
28pub struct AdjustInput { pub sku: String, pub location_id: String, pub new_quantity: f64, pub reason: String, pub actor: String }
29
30#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
31pub struct StockQuery { pub sku: String, pub location_id: Option<String> }
32
33#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
34pub struct ReserveInput { pub sku: String, pub location_id: String, pub quantity: f64, pub reference: String, pub expires_hours: Option<u32> }
35
36#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
37pub struct ReserveIdInput { pub reservation_id: String }
38
39#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
40pub struct BomInput { pub parent_sku: String, pub components: Vec<Value> }
41
42#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
43pub struct BomCheckInput { pub parent_sku: String, pub quantity: f64, pub location_id: String }
44
45#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
46pub struct SkuInput { pub sku: String }
47
48#[derive(Clone)]
49pub struct InventoryServer {
50    pub store: Store,
51    pub client: Client,
52    pub grocy_url: Option<String>,
53    pub grocy_key: Option<String>,
54    pub shopify_store: Option<String>,
55    pub shopify_token: Option<String>,
56    pub pancake_key: Option<String>,
57    pub pancake_shop: Option<String>,
58}
59impl InventoryServer {
60    pub fn new() -> Self {
61        Self {
62            store: Store::new(),
63            client: Client::builder().build().unwrap_or_default(),
64            grocy_url: std::env::var("GROCY_URL").ok(),
65            grocy_key: std::env::var("GROCY_API_KEY").ok(),
66            shopify_store: std::env::var("SHOPIFY_STORE").ok(),
67            shopify_token: std::env::var("SHOPIFY_ACCESS_TOKEN").ok(),
68            pancake_key: std::env::var("PANCAKE_POS_API_KEY").ok(),
69            pancake_shop: std::env::var("PANCAKE_POS_SHOP_ID").ok(),
70        }
71    }
72}
73
74#[tool_router(server_handler)]
75impl InventoryServer {
76    #[tool(description = "Add or update an item in the inventory catalog (SKU, name, category, unit, reorder point).")]
77    async fn item_upsert(&self, Parameters(input): Parameters<ItemInput>) -> String {
78        let item = Item { sku: input.sku.clone(), name: input.name, category: input.category, unit: input.unit.unwrap_or_else(|| "each".into()), reorder_point: input.reorder_point.unwrap_or(10.0), reorder_qty: input.reorder_qty.unwrap_or(50.0), cost: input.cost.unwrap_or(0.0), currency: input.currency.unwrap_or_else(|| "USD".into()), attributes: json!({}) };
79        self.store.items.lock().unwrap().insert(input.sku.clone(), item);
80        json!({"status": "ok", "sku": input.sku}).to_string()
81    }
82
83    #[tool(description = "List all items in the inventory catalog.")]
84    async fn item_list(&self) -> String {
85        let items: Vec<_> = self.store.items.lock().unwrap().values().cloned().collect();
86        json!({"count": items.len(), "items": items}).to_string()
87    }
88
89    #[tool(description = "Create a warehouse/zone/aisle/rack/bin location with optional capacity limits (units, weight, volume).")]
90    async fn location_create(&self, Parameters(input): Parameters<LocationInput>) -> String {
91        let id = Store::new_location_id();
92        let loc = Location { id: id.clone(), name: input.name, location_type: input.location_type, parent_id: input.parent_id, address: input.address, capacity_units: input.capacity_units, capacity_weight_kg: input.capacity_weight_kg, capacity_volume_m3: input.capacity_volume_m3, used_units: 0.0, used_weight_kg: 0.0, used_volume_m3: 0.0 };
93        self.store.locations.lock().unwrap().insert(id.clone(), loc);
94        json!({"status": "created", "location_id": id}).to_string()
95    }
96
97    #[tool(description = "List all locations.")]
98    async fn location_list(&self) -> String {
99        let locs: Vec<_> = self.store.locations.lock().unwrap().values().cloned().collect();
100        json!({"count": locs.len(), "locations": locs}).to_string()
101    }
102
103    #[tool(description = "Receive stock into a location (goods receipt from supplier, production, or return).")]
104    async fn stock_receive(&self, Parameters(input): Parameters<ReceiveInput>) -> String {
105        let m = StockMovement { id: Store::new_movement_id(), movement_type: "receive".into(), sku: input.sku.clone(), quantity: input.quantity, from_location: None, to_location: Some(input.location_id.clone()), reference: input.reference.unwrap_or_default(), actor: input.actor, lot_number: input.lot_number, timestamp: now() };
106        self.store.record_movement(m);
107        json!({"status": "received", "sku": input.sku, "quantity": input.quantity, "location": input.location_id}).to_string()
108    }
109
110    #[tool(description = "Issue stock from a location (sales, consumption, dispatch).")]
111    async fn stock_issue(&self, Parameters(input): Parameters<IssueInput>) -> String {
112        let avail = self.store.available_qty(&input.sku, &input.location_id);
113        if avail < input.quantity { return json!({"error": "INSUFFICIENT_STOCK", "available": avail, "requested": input.quantity}).to_string(); }
114        let m = StockMovement { id: Store::new_movement_id(), movement_type: "issue".into(), sku: input.sku.clone(), quantity: input.quantity, from_location: Some(input.location_id.clone()), to_location: None, reference: input.reference.unwrap_or_default(), actor: input.actor, lot_number: None, timestamp: now() };
115        self.store.record_movement(m);
116        json!({"status": "issued", "sku": input.sku, "quantity": input.quantity, "remaining": avail - input.quantity}).to_string()
117    }
118
119    #[tool(description = "Transfer stock between locations.")]
120    async fn stock_transfer(&self, Parameters(input): Parameters<TransferInput>) -> String {
121        let avail = self.store.available_qty(&input.sku, &input.from_location);
122        if avail < input.quantity { return json!({"error": "INSUFFICIENT_STOCK", "available": avail}).to_string(); }
123        let m = StockMovement { id: Store::new_movement_id(), movement_type: "transfer".into(), sku: input.sku.clone(), quantity: input.quantity, from_location: Some(input.from_location.clone()), to_location: Some(input.to_location.clone()), reference: String::new(), actor: input.actor, lot_number: None, timestamp: now() };
124        self.store.record_movement(m);
125        json!({"status": "transferred", "sku": input.sku, "quantity": input.quantity, "from": input.from_location, "to": input.to_location}).to_string()
126    }
127
128    #[tool(description = "Adjust stock quantity (cycle count correction, damage write-off, etc.).")]
129    async fn stock_adjust(&self, Parameters(input): Parameters<AdjustInput>) -> String {
130        let mut stock = self.store.stock.lock().unwrap();
131        if let Some(s) = stock.iter_mut().find(|s| s.sku == input.sku && s.location_id == input.location_id) {
132            let old = s.quantity;
133            s.quantity = input.new_quantity;
134            s.updated_at = now();
135            json!({"status": "adjusted", "sku": input.sku, "old_qty": old, "new_qty": input.new_quantity, "reason": input.reason}).to_string()
136        } else {
137            stock.push(StockLevel { sku: input.sku.clone(), location_id: input.location_id.clone(), quantity: input.new_quantity, reserved: 0.0, lot_number: None, expiry_date: None, updated_at: now() });
138            json!({"status": "created", "sku": input.sku, "quantity": input.new_quantity}).to_string()
139        }
140    }
141
142    #[tool(description = "Check stock level for a SKU (optionally at a specific location). Shows available (total - reserved).")]
143    async fn stock_check(&self, Parameters(input): Parameters<StockQuery>) -> String {
144        let levels = self.store.get_stock(&input.sku, input.location_id.as_deref());
145        let total_qty: f64 = levels.iter().map(|s| s.quantity).sum();
146        let total_reserved: f64 = levels.iter().map(|s| s.reserved).sum();
147        let item = self.store.items.lock().unwrap().get(&input.sku).cloned();
148        let below_reorder = item.as_ref().map_or(false, |i| total_qty - total_reserved <= i.reorder_point);
149        json!({"sku": input.sku, "total_quantity": total_qty, "reserved": total_reserved, "available": total_qty - total_reserved, "below_reorder_point": below_reorder, "locations": levels}).to_string()
150    }
151
152    #[tool(description = "Get items below reorder point (reorder alerts).")]
153    async fn reorder_alerts(&self) -> String {
154        let items = self.store.items.lock().unwrap().clone();
155        let stock = self.store.stock.lock().unwrap().clone();
156        let mut alerts = Vec::new();
157        for item in items.values() {
158            let total: f64 = stock.iter().filter(|s| s.sku == item.sku).map(|s| s.quantity - s.reserved).sum();
159            if total <= item.reorder_point {
160                alerts.push(json!({"sku": item.sku, "name": item.name, "available": total, "reorder_point": item.reorder_point, "suggested_order_qty": item.reorder_qty}));
161            }
162        }
163        json!({"alerts": alerts.len(), "items": alerts}).to_string()
164    }
165
166    #[tool(description = "Reserve stock for an order (reduces available without reducing quantity). Prevents overselling.")]
167    async fn stock_reserve(&self, Parameters(input): Parameters<ReserveInput>) -> String {
168        let avail = self.store.available_qty(&input.sku, &input.location_id);
169        if avail < input.quantity { return json!({"error": "INSUFFICIENT_STOCK", "available": avail}).to_string(); }
170        let mut stock = self.store.stock.lock().unwrap();
171        if let Some(s) = stock.iter_mut().find(|s| s.sku == input.sku && s.location_id == input.location_id) {
172            s.reserved += input.quantity;
173        }
174        drop(stock);
175        let expires = input.expires_hours.map(|h| (chrono::Utc::now() + chrono::Duration::hours(h as i64)).to_rfc3339());
176        let id = Store::new_reservation_id();
177        self.store.reservations.lock().unwrap().push(Reservation { id: id.clone(), sku: input.sku, location_id: input.location_id, quantity: input.quantity, reference: input.reference, expires_at: expires, created_at: now() });
178        json!({"status": "reserved", "reservation_id": id}).to_string()
179    }
180
181    #[tool(description = "Release a stock reservation (cancel order, reservation expired).")]
182    async fn stock_release(&self, Parameters(input): Parameters<ReserveIdInput>) -> String {
183        let mut reservations = self.store.reservations.lock().unwrap();
184        if let Some(idx) = reservations.iter().position(|r| r.id == input.reservation_id) {
185            let res = reservations.remove(idx);
186            let mut stock = self.store.stock.lock().unwrap();
187            if let Some(s) = stock.iter_mut().find(|s| s.sku == res.sku && s.location_id == res.location_id) {
188                s.reserved -= res.quantity;
189            }
190            json!({"status": "released", "sku": res.sku, "quantity": res.quantity}).to_string()
191        } else {
192            json!({"error": "RESERVATION_NOT_FOUND"}).to_string()
193        }
194    }
195
196    #[tool(description = "Define a Bill of Materials (BOM) — components needed to build a parent item.")]
197    async fn bom_set(&self, Parameters(input): Parameters<BomInput>) -> String {
198        let mut bom = self.store.bom.lock().unwrap();
199        bom.retain(|b| b.parent_sku != input.parent_sku);
200        for c in &input.components {
201            if let (Some(sku), Some(qty)) = (c["sku"].as_str(), c["quantity"].as_f64()) {
202                bom.push(BomEntry { parent_sku: input.parent_sku.clone(), component_sku: sku.into(), quantity: qty });
203            }
204        }
205        json!({"status": "ok", "parent_sku": input.parent_sku, "components": input.components.len()}).to_string()
206    }
207
208    #[tool(description = "Check BOM availability — can we build N units of a parent item with current stock?")]
209    async fn bom_check(&self, Parameters(input): Parameters<BomCheckInput>) -> String {
210        let bom: Vec<_> = self.store.bom.lock().unwrap().iter().filter(|b| b.parent_sku == input.parent_sku).cloned().collect();
211        if bom.is_empty() { return json!({"error": "BOM_NOT_FOUND", "parent_sku": input.parent_sku}).to_string(); }
212        let mut can_build = true;
213        let mut shortages = Vec::new();
214        for entry in &bom {
215            let needed = entry.quantity * input.quantity;
216            let avail = self.store.available_qty(&entry.component_sku, &input.location_id);
217            if avail < needed {
218                can_build = false;
219                shortages.push(json!({"sku": entry.component_sku, "needed": needed, "available": avail, "shortage": needed - avail}));
220            }
221        }
222        json!({"parent_sku": input.parent_sku, "quantity": input.quantity, "can_build": can_build, "shortages": shortages}).to_string()
223    }
224
225    #[tool(description = "Get stock movement history for a SKU.")]
226    async fn movement_history(&self, Parameters(input): Parameters<SkuInput>) -> String {
227        let movements: Vec<_> = self.store.movements.lock().unwrap().iter().filter(|m| m.sku == input.sku).cloned().collect();
228        json!({"sku": input.sku, "count": movements.len(), "movements": movements}).to_string()
229    }
230
231    // === Pick/Pack/Ship ===
232
233    #[tool(description = "Create a pick order for fulfillment. Allocates stock from locations and creates pick lines.")]
234    async fn pick_create(&self, Parameters(input): Parameters<PickCreateInput>) -> String {
235        let mut lines = Vec::new();
236        for item in &input.items {
237            let sku = item["sku"].as_str().unwrap_or_default();
238            let qty = item["quantity"].as_f64().unwrap_or(1.0);
239            let loc = item["location_id"].as_str().unwrap_or(&input.default_location);
240            lines.push(PickLine { sku: sku.into(), quantity: qty, from_location: loc.into(), picked_qty: 0.0, status: "pending".into() });
241        }
242        let id = format!("pick_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
243        let order = PickOrder { id: id.clone(), status: "pending".into(), order_reference: input.order_reference, lines, assigned_to: input.assigned_to, wave_id: None, created_at: now(), updated_at: now() };
244        self.store.pick_orders.lock().unwrap().insert(id.clone(), order);
245        json!({"status": "created", "pick_id": id}).to_string()
246    }
247
248    #[tool(description = "Confirm pick (mark items as picked). Moves status to 'picking' then 'packed'.")]
249    async fn pick_confirm(&self, Parameters(input): Parameters<PickConfirmInput>) -> String {
250        let mut picks = self.store.pick_orders.lock().unwrap();
251        match picks.get_mut(&input.pick_id) {
252            Some(p) => {
253                for line in &mut p.lines {
254                    if let Some(picked) = input.picked_skus.iter().find(|s| s["sku"].as_str() == Some(&line.sku)) {
255                        line.picked_qty = picked["quantity"].as_f64().unwrap_or(line.quantity);
256                        line.status = if line.picked_qty >= line.quantity { "picked".into() } else { "short".into() };
257                    }
258                }
259                p.status = "packed".into();
260                p.updated_at = now();
261                json!({"status": "packed", "pick_id": input.pick_id}).to_string()
262            }
263            None => json!({"error": "PICK_NOT_FOUND"}).to_string(),
264        }
265    }
266
267    #[tool(description = "Ship a pick order (mark as shipped, issues stock from locations).")]
268    async fn pick_ship(&self, Parameters(input): Parameters<PickIdInput>) -> String {
269        let mut picks = self.store.pick_orders.lock().unwrap();
270        match picks.get_mut(&input.pick_id) {
271            Some(p) => {
272                p.status = "shipped".into();
273                p.updated_at = now();
274                // Issue stock for each picked line
275                for line in &p.lines {
276                    if line.picked_qty > 0.0 {
277                        let mut stock = self.store.stock.lock().unwrap();
278                        if let Some(s) = stock.iter_mut().find(|s| s.sku == line.sku && s.location_id == line.from_location) {
279                            s.quantity -= line.picked_qty;
280                            s.updated_at = now();
281                        }
282                    }
283                }
284                json!({"status": "shipped", "pick_id": input.pick_id}).to_string()
285            }
286            None => json!({"error": "PICK_NOT_FOUND"}).to_string(),
287        }
288    }
289
290    #[tool(description = "List pick orders (optionally filter by status: pending, picking, packed, shipped).")]
291    async fn pick_list(&self) -> String {
292        let picks: Vec<_> = self.store.pick_orders.lock().unwrap().values().cloned().collect();
293        json!({"count": picks.len(), "pick_orders": picks}).to_string()
294    }
295
296    // === Putaway ===
297
298    #[tool(description = "Create a putaway rule (assign preferred location for items by category on receipt).")]
299    async fn putaway_rule_create(&self, Parameters(input): Parameters<PutawayRuleInput>) -> String {
300        let id = format!("put_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
301        self.store.putaway_rules.lock().unwrap().push(PutawayRule { id: id.clone(), category: input.category, target_zone: input.target_zone, priority: input.priority.unwrap_or(100) });
302        json!({"status": "created", "rule_id": id}).to_string()
303    }
304
305    #[tool(description = "Suggest putaway location for an item based on category rules and available space.")]
306    async fn putaway_suggest(&self, Parameters(input): Parameters<SkuInput>) -> String {
307        let item = self.store.items.lock().unwrap().get(&input.sku).cloned();
308        let category = item.map(|i| i.category).unwrap_or_default();
309        let rules = self.store.putaway_rules.lock().unwrap().clone();
310        let mut suggestions: Vec<_> = rules.iter().filter(|r| r.category == category || r.category == "*").collect();
311        suggestions.sort_by_key(|r| r.priority);
312        let locations = self.store.locations.lock().unwrap().clone();
313        let suggested: Vec<_> = suggestions.iter().filter_map(|r| {
314            locations.values().find(|l| l.id == r.target_zone || l.name == r.target_zone).map(|l| {
315                let utilization = l.capacity_units.map(|cap| if cap > 0.0 { l.used_units / cap * 100.0 } else { 0.0 }).unwrap_or(0.0);
316                json!({"location_id": l.id, "name": l.name, "type": l.location_type, "utilization_pct": utilization})
317            })
318        }).collect();
319        json!({"sku": input.sku, "category": category, "suggestions": suggested}).to_string()
320    }
321
322    // === Cycle Counts ===
323
324    #[tool(description = "Schedule a cycle count for a location.")]
325    async fn cycle_count_schedule(&self, Parameters(input): Parameters<CycleCountInput>) -> String {
326        let id = format!("cc_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
327        self.store.cycle_counts.lock().unwrap().push(CycleCount { id: id.clone(), location_id: input.location_id, status: "scheduled".into(), scheduled_date: input.scheduled_date, counted_by: None, discrepancies: vec![], completed_at: None });
328        json!({"status": "scheduled", "cycle_count_id": id}).to_string()
329    }
330
331    #[tool(description = "Complete a cycle count — submit actual counts and detect discrepancies.")]
332    async fn cycle_count_complete(&self, Parameters(input): Parameters<CycleCountCompleteInput>) -> String {
333        let mut counts = self.store.cycle_counts.lock().unwrap();
334        if let Some(cc) = counts.iter_mut().find(|c| c.id == input.cycle_count_id) {
335            cc.status = "completed".into();
336            cc.counted_by = Some(input.counted_by);
337            cc.completed_at = Some(now());
338            // Check discrepancies
339            let stock = self.store.stock.lock().unwrap();
340            let mut discreps = Vec::new();
341            for count in &input.counts {
342                let sku = count["sku"].as_str().unwrap_or_default();
343                let actual = count["actual_qty"].as_f64().unwrap_or(0.0);
344                let system_qty: f64 = stock.iter().filter(|s| s.sku == sku && s.location_id == cc.location_id).map(|s| s.quantity).sum();
345                if (actual - system_qty).abs() > 0.01 {
346                    discreps.push(json!({"sku": sku, "system_qty": system_qty, "actual_qty": actual, "variance": actual - system_qty}));
347                }
348            }
349            cc.discrepancies = discreps.clone();
350            json!({"status": "completed", "cycle_count_id": input.cycle_count_id, "discrepancies": discreps.len(), "details": discreps}).to_string()
351        } else {
352            json!({"error": "CYCLE_COUNT_NOT_FOUND"}).to_string()
353        }
354    }
355
356    // === Space Management ===
357
358    #[tool(description = "Get space utilization for a location (or all locations). Shows capacity vs used for units, weight, and volume.")]
359    async fn space_utilization(&self) -> String {
360        let locations = self.store.locations.lock().unwrap().clone();
361        let stock = self.store.stock.lock().unwrap().clone();
362        let mut report: Vec<Value> = Vec::new();
363        for loc in locations.values() {
364            let total_units: f64 = stock.iter().filter(|s| s.location_id == loc.id).map(|s| s.quantity).sum();
365            let unit_util = loc.capacity_units.map(|c| if c > 0.0 { total_units / c * 100.0 } else { 0.0 });
366            report.push(json!({
367                "location_id": loc.id, "name": loc.name, "type": loc.location_type,
368                "units_used": total_units, "capacity_units": loc.capacity_units, "utilization_pct": unit_util,
369                "capacity_weight_kg": loc.capacity_weight_kg, "capacity_volume_m3": loc.capacity_volume_m3
370            }));
371        }
372        json!({"locations": report.len(), "report": report}).to_string()
373    }
374
375    // === Wave Planning ===
376
377    #[tool(description = "Create a wave (batch multiple pick orders together for efficient warehouse picking).")]
378    async fn wave_create(&self, Parameters(input): Parameters<WaveCreateInput>) -> String {
379        let id = format!("wave_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
380        let wave = Wave { id: id.clone(), name: input.name, status: "planning".into(), pick_ids: input.pick_ids.clone(), priority: input.priority.unwrap_or_else(|| "medium".into()), created_at: now(), released_at: None, completed_at: None };
381        // Link picks to wave
382        let mut picks = self.store.pick_orders.lock().unwrap();
383        for pid in &input.pick_ids {
384            if let Some(p) = picks.get_mut(pid) { p.wave_id = Some(id.clone()); }
385        }
386        drop(picks);
387        self.store.waves.lock().unwrap().insert(id.clone(), wave);
388        json!({"status": "created", "wave_id": id, "pick_orders": input.pick_ids.len()}).to_string()
389    }
390
391    #[tool(description = "Release a wave (moves all pick orders in the wave to 'picking' status, assigns to pickers).")]
392    async fn wave_release(&self, Parameters(input): Parameters<WaveIdInput>) -> String {
393        let mut waves = self.store.waves.lock().unwrap();
394        match waves.get_mut(&input.wave_id) {
395            Some(w) => {
396                w.status = "in_progress".into();
397                w.released_at = Some(now());
398                let pick_ids = w.pick_ids.clone();
399                drop(waves);
400                let mut picks = self.store.pick_orders.lock().unwrap();
401                for pid in &pick_ids {
402                    if let Some(p) = picks.get_mut(pid) { p.status = "picking".into(); }
403                }
404                json!({"status": "released", "wave_id": input.wave_id, "picks_released": pick_ids.len()}).to_string()
405            }
406            None => json!({"error": "WAVE_NOT_FOUND"}).to_string(),
407        }
408    }
409
410    #[tool(description = "Complete a wave (marks wave as completed when all picks are shipped).")]
411    async fn wave_complete(&self, Parameters(input): Parameters<WaveIdInput>) -> String {
412        let mut waves = self.store.waves.lock().unwrap();
413        match waves.get_mut(&input.wave_id) {
414            Some(w) => {
415                w.status = "completed".into();
416                w.completed_at = Some(now());
417                json!({"status": "completed", "wave_id": input.wave_id}).to_string()
418            }
419            None => json!({"error": "WAVE_NOT_FOUND"}).to_string(),
420        }
421    }
422
423    #[tool(description = "List waves with their status and pick order counts.")]
424    async fn wave_list(&self) -> String {
425        let waves: Vec<_> = self.store.waves.lock().unwrap().values().cloned().collect();
426        json!({"count": waves.len(), "waves": waves}).to_string()
427    }
428
429    // === Barcode / Label Generation ===
430
431    #[tool(description = "Generate a barcode label for a SKU, location, lot, shipment, or receipt. Returns barcode value and label text for printing.")]
432    async fn label_generate(&self, Parameters(input): Parameters<LabelInput>) -> String {
433        let barcode_value = match input.barcode_type.as_str() {
434            "sku" => format!("SKU-{}", input.entity_id),
435            "location" => format!("LOC-{}", input.entity_id),
436            "lot" => format!("LOT-{}", input.entity_id),
437            "shipment" => format!("SHP-{}", input.entity_id),
438            "receipt" => format!("RCV-{}", input.entity_id),
439            _ => format!("ID-{}", input.entity_id),
440        };
441        let format = input.barcode_format.unwrap_or_else(|| "code128".into());
442        let mut label_text = vec![barcode_value.clone()];
443        // Add context info
444        if input.barcode_type == "sku" {
445            if let Some(item) = self.store.items.lock().unwrap().get(&input.entity_id) {
446                label_text.push(item.name.clone());
447                label_text.push(format!("Cat: {}", item.category));
448            }
449        } else if input.barcode_type == "location" {
450            if let Some(loc) = self.store.locations.lock().unwrap().get(&input.entity_id) {
451                label_text.push(loc.name.clone());
452                label_text.push(format!("Type: {}", loc.location_type));
453            }
454        }
455        if let Some(ref extra) = input.extra_text { label_text.extend(extra.clone()); }
456
457        let id = format!("lbl_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
458        let label = BarcodeLabel { id: id.clone(), barcode_type: input.barcode_type, entity_id: input.entity_id, barcode_format: format.clone(), barcode_value: barcode_value.clone(), label_text: label_text.clone(), generated_at: now() };
459        self.store.labels.lock().unwrap().push(label);
460        json!({"label_id": id, "barcode_value": barcode_value, "barcode_format": format, "label_text": label_text, "printable": true}).to_string()
461    }
462
463    #[tool(description = "Generate labels in batch (multiple SKUs, locations, or shipments at once).")]
464    async fn label_batch(&self, Parameters(input): Parameters<LabelBatchInput>) -> String {
465        let format = input.barcode_format.unwrap_or_else(|| "code128".into());
466        let mut labels = Vec::new();
467        for entity_id in &input.entity_ids {
468            let barcode_value = match input.barcode_type.as_str() {
469                "sku" => format!("SKU-{}", entity_id),
470                "location" => format!("LOC-{}", entity_id),
471                "lot" => format!("LOT-{}", entity_id),
472                _ => format!("ID-{}", entity_id),
473            };
474            let id = format!("lbl_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
475            let label = BarcodeLabel { id: id.clone(), barcode_type: input.barcode_type.clone(), entity_id: entity_id.clone(), barcode_format: format.clone(), barcode_value: barcode_value.clone(), label_text: vec![barcode_value.clone()], generated_at: now() };
476            labels.push(json!({"label_id": id, "entity_id": entity_id, "barcode_value": barcode_value}));
477            self.store.labels.lock().unwrap().push(label);
478        }
479        json!({"count": labels.len(), "barcode_format": format, "labels": labels}).to_string()
480    }
481
482    // === Serialized Warehousing ===
483
484    #[tool(description = "Register a serialized item (individual unit tracking by serial number). Optionally link RFID tag.")]
485    async fn serial_register(&self, Parameters(input): Parameters<SerialRegisterInput>) -> String {
486        let qr = format!("QR:SN={}&SKU={}&LOC={}", input.serial_number, input.sku, input.location_id);
487        let item = SerializedItem {
488            serial_number: input.serial_number.clone(), sku: input.sku, status: "in_stock".into(),
489            location_id: input.location_id.clone(), lot_number: input.lot_number,
490            manufacture_date: input.manufacture_date, expiry_date: input.expiry_date,
491            rfid_tag: input.rfid_tag.clone(), qr_code: Some(qr.clone()),
492            history: vec![SerialEvent { event_type: "received".into(), location: input.location_id, actor: "system".into(), timestamp: now(), reference: None }],
493            metadata: json!({}),
494        };
495        self.store.serialized.lock().unwrap().insert(input.serial_number.clone(), item);
496        // If RFID tag provided, register it too
497        if let Some(epc) = input.rfid_tag {
498            self.store.rfid_tags.lock().unwrap().insert(epc.clone(), RfidTag { epc, serial_number: Some(input.serial_number.clone()), sku: None, location_id: String::new(), last_read_at: now(), read_count: 0, status: "active".into() });
499        }
500        json!({"status": "registered", "serial_number": input.serial_number, "qr_code": qr}).to_string()
501    }
502
503    #[tool(description = "Move a serialized item to a new location (tracks full chain of custody).")]
504    async fn serial_move(&self, Parameters(input): Parameters<SerialMoveInput>) -> String {
505        let mut items = self.store.serialized.lock().unwrap();
506        match items.get_mut(&input.serial_number) {
507            Some(item) => {
508                let event_type = input.event_type.unwrap_or_else(|| "moved".into());
509                item.location_id = input.to_location.clone();
510                item.status = match event_type.as_str() { "shipped" => "shipped", "scrapped" => "scrapped", "returned" => "in_stock", _ => "in_stock" }.into();
511                item.history.push(SerialEvent { event_type: event_type.clone(), location: input.to_location, actor: input.actor, timestamp: now(), reference: input.reference });
512                json!({"status": "moved", "serial_number": input.serial_number, "event": event_type, "history_length": item.history.len()}).to_string()
513            }
514            None => json!({"error": "SERIAL_NOT_FOUND"}).to_string(),
515        }
516    }
517
518    #[tool(description = "Look up a serialized item by serial number (full history, location, status).")]
519    async fn serial_lookup(&self, Parameters(input): Parameters<SerialQueryInput>) -> String {
520        match self.store.serialized.lock().unwrap().get(&input.serial_number) {
521            Some(item) => serde_json::to_string_pretty(item).unwrap_or_default(),
522            None => json!({"error": "SERIAL_NOT_FOUND"}).to_string(),
523        }
524    }
525
526    #[tool(description = "List all serialized items at a location.")]
527    async fn serial_scan_location(&self, Parameters(input): Parameters<SerialScanInput>) -> String {
528        let items: Vec<_> = self.store.serialized.lock().unwrap().values().filter(|i| i.location_id == input.location_id).cloned().collect();
529        json!({"location_id": input.location_id, "count": items.len(), "items": items.iter().map(|i| json!({"serial": i.serial_number, "sku": i.sku, "status": i.status, "rfid": i.rfid_tag, "lot": i.lot_number})).collect::<Vec<_>>()}).to_string()
530    }
531
532    // === RFID ===
533
534    #[tool(description = "Register an RFID tag (EPC) and link it to a serial number or SKU.")]
535    async fn rfid_register(&self, Parameters(input): Parameters<RfidRegisterInput>) -> String {
536        let tag = RfidTag { epc: input.epc.clone(), serial_number: input.serial_number, sku: input.sku, location_id: input.location_id, last_read_at: now(), read_count: 0, status: "active".into() };
537        self.store.rfid_tags.lock().unwrap().insert(input.epc.clone(), tag);
538        json!({"status": "registered", "epc": input.epc}).to_string()
539    }
540
541    #[tool(description = "Process RFID reader scan — bulk update tag locations and detect missing/unexpected tags.")]
542    async fn rfid_bulk_read(&self, Parameters(input): Parameters<RfidReadInput>) -> String {
543        let mut tags = self.store.rfid_tags.lock().unwrap();
544        let mut found = Vec::new();
545        let mut unknown = Vec::new();
546        for epc in &input.epcs {
547            if let Some(tag) = tags.get_mut(epc) {
548                tag.location_id = input.location_id.clone();
549                tag.last_read_at = now();
550                tag.read_count += 1;
551                found.push(json!({"epc": epc, "serial": tag.serial_number, "sku": tag.sku}));
552            } else {
553                unknown.push(epc.clone());
554            }
555        }
556        // Detect missing — tags expected at this location but not scanned
557        let missing: Vec<_> = tags.values().filter(|t| t.location_id == input.location_id && t.status == "active" && !input.epcs.contains(&t.epc)).map(|t| json!({"epc": t.epc, "serial": t.serial_number})).collect();
558        json!({"location_id": input.location_id, "scanned": input.epcs.len(), "found": found.len(), "unknown": unknown.len(), "missing": missing.len(), "found_tags": found, "unknown_epcs": unknown, "missing_tags": missing}).to_string()
559    }
560
561    #[tool(description = "Look up an RFID tag by EPC.")]
562    async fn rfid_lookup(&self, Parameters(input): Parameters<RfidQueryInput>) -> String {
563        match self.store.rfid_tags.lock().unwrap().get(&input.epc) {
564            Some(tag) => serde_json::to_string_pretty(tag).unwrap_or_default(),
565            None => json!({"error": "RFID_NOT_FOUND", "epc": input.epc}).to_string(),
566        }
567    }
568
569    // === QR Code ===
570
571    #[tool(description = "Generate a QR code payload for a serial, SKU, location, or shipment. Returns the encoded data string.")]
572    async fn qr_generate(&self, Parameters(input): Parameters<QrGenerateInput>) -> String {
573        let base = match input.entity_type.as_str() {
574            "serial" => {
575                let item = self.store.serialized.lock().unwrap().get(&input.entity_id).cloned();
576                match item {
577                    Some(i) => format!("SN={}&SKU={}&LOT={}&LOC={}", i.serial_number, i.sku, i.lot_number.unwrap_or_default(), i.location_id),
578                    None => format!("SN={}", input.entity_id),
579                }
580            }
581            "sku" => format!("SKU={}", input.entity_id),
582            "location" => format!("LOC={}", input.entity_id),
583            "shipment" => format!("SHP={}", input.entity_id),
584            _ => format!("ID={}", input.entity_id),
585        };
586        let payload = if let Some(extra) = input.extra_data { format!("{}&DATA={}", base, extra) } else { base };
587        let label_id = format!("lbl_{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
588        self.store.labels.lock().unwrap().push(BarcodeLabel { id: label_id.clone(), barcode_type: input.entity_type.clone(), entity_id: input.entity_id, barcode_format: "qr".into(), barcode_value: payload.clone(), label_text: vec![payload.clone()], generated_at: now() });
589        json!({"label_id": label_id, "format": "qr", "payload": payload, "printable": true}).to_string()
590    }
591
592    // === External Backend Sync ===
593
594    #[tool(description = "Sync inventory with Grocy (self-hosted grocery/inventory manager). Pull imports Grocy stock into local store. Push exports local stock to Grocy. Requires GROCY_URL, GROCY_API_KEY env vars.")]
595    async fn sync_grocy(&self, Parameters(input): Parameters<GrocySyncInput>) -> String {
596        let (Some(url), Some(key)) = (&self.grocy_url, &self.grocy_key) else {
597            return json!({"error": "GROCY_NOT_CONFIGURED", "message": "Set GROCY_URL and GROCY_API_KEY"}).to_string();
598        };
599        match input.direction.as_str() {
600            "pull" => {
601                let endpoint = format!("{}/api/stock", url);
602                match self.client.get(&endpoint).header("GROCY-API-KEY", key.as_str()).send().await {
603                    Ok(resp) => match resp.json::<Vec<Value>>().await {
604                        Ok(items) => {
605                            let mut synced = 0;
606                            for item in &items {
607                                let sku = item["product"]["name"].as_str().unwrap_or_default();
608                                if input.sku.as_ref().map_or(true, |s| s == sku) {
609                                    let qty = item["amount"].as_f64().unwrap_or(0.0);
610                                    self.store.items.lock().unwrap().entry(sku.into()).or_insert_with(|| Item { sku: sku.into(), name: sku.into(), category: "grocy".into(), unit: "each".into(), reorder_point: item["product"]["min_stock_amount"].as_f64().unwrap_or(0.0), reorder_qty: 10.0, cost: 0.0, currency: "USD".into(), attributes: json!({}) });
611                                    let mut stock = self.store.stock.lock().unwrap();
612                                    if let Some(s) = stock.iter_mut().find(|s| s.sku == sku && s.location_id == "grocy") {
613                                        s.quantity = qty; s.updated_at = now();
614                                    } else {
615                                        stock.push(StockLevel { sku: sku.into(), location_id: "grocy".into(), quantity: qty, reserved: 0.0, lot_number: None, expiry_date: item["best_before_date"].as_str().map(String::from), updated_at: now() });
616                                    }
617                                    synced += 1;
618                                }
619                            }
620                            json!({"status": "pulled", "source": "grocy", "items_synced": synced}).to_string()
621                        }
622                        Err(e) => json!({"error": e.to_string()}).to_string(),
623                    },
624                    Err(e) => json!({"error": e.to_string()}).to_string(),
625                }
626            }
627            "push" => {
628                json!({"status": "push_not_yet_implemented", "message": "Grocy push requires product ID mapping. Use pull to import first."}).to_string()
629            }
630            _ => json!({"error": "Invalid direction. Use 'pull' or 'push'"}).to_string(),
631        }
632    }
633
634    #[tool(description = "Sync inventory with Shopify. Pull imports Shopify inventory levels. Push updates Shopify stock from local. Requires SHOPIFY_STORE, SHOPIFY_ACCESS_TOKEN env vars.")]
635    async fn sync_shopify(&self, Parameters(input): Parameters<ShopifySyncInput>) -> String {
636        let (Some(store), Some(token)) = (&self.shopify_store, &self.shopify_token) else {
637            return json!({"error": "SHOPIFY_NOT_CONFIGURED", "message": "Set SHOPIFY_STORE and SHOPIFY_ACCESS_TOKEN"}).to_string();
638        };
639        let base = format!("https://{}.myshopify.com/admin/api/2024-01", store);
640        match input.direction.as_str() {
641            "pull" => {
642                let url = format!("{}/inventory_levels.json?limit=50", base);
643                match self.client.get(&url).header("X-Shopify-Access-Token", token.as_str()).send().await {
644                    Ok(resp) => match resp.json::<Value>().await {
645                        Ok(data) => {
646                            let levels = data["inventory_levels"].as_array().unwrap_or(&vec![]).clone();
647                            let mut synced = 0;
648                            for level in &levels {
649                                let item_id = level["inventory_item_id"].to_string();
650                                let qty = level["available"].as_f64().unwrap_or(0.0);
651                                let loc = level["location_id"].to_string();
652                                let mut stock = self.store.stock.lock().unwrap();
653                                if let Some(s) = stock.iter_mut().find(|s| s.sku == item_id && s.location_id == format!("shopify_{}", loc)) {
654                                    s.quantity = qty; s.updated_at = now();
655                                } else {
656                                    stock.push(StockLevel { sku: item_id, location_id: format!("shopify_{}", loc), quantity: qty, reserved: 0.0, lot_number: None, expiry_date: None, updated_at: now() });
657                                }
658                                synced += 1;
659                            }
660                            json!({"status": "pulled", "source": "shopify", "levels_synced": synced}).to_string()
661                        }
662                        Err(e) => json!({"error": e.to_string()}).to_string(),
663                    },
664                    Err(e) => json!({"error": e.to_string()}).to_string(),
665                }
666            }
667            "push" => {
668                let Some(ref sku) = input.sku else {
669                    return json!({"error": "SKU required for push"}).to_string();
670                };
671                let loc_id = input.location_id.as_deref().unwrap_or("default");
672                let qty: f64 = self.store.stock.lock().unwrap().iter().filter(|s| s.sku == *sku).map(|s| s.quantity - s.reserved).sum();
673                let url = format!("{}/inventory_levels/set.json", base);
674                let body = json!({"location_id": loc_id, "inventory_item_id": sku, "available": qty as i64});
675                match self.client.post(&url).header("X-Shopify-Access-Token", token.as_str()).json(&body).send().await {
676                    Ok(resp) => {
677                        let status = resp.status().as_u16();
678                        json!({"status": if status < 400 { "pushed" } else { "failed" }, "sku": sku, "quantity": qty, "http_status": status}).to_string()
679                    }
680                    Err(e) => json!({"error": e.to_string()}).to_string(),
681                }
682            }
683            _ => json!({"error": "Invalid direction. Use 'pull' or 'push'"}).to_string(),
684        }
685    }
686
687    #[tool(description = "Sync inventory with Pancake POS. Pull imports warehouse/inventory data. Push exports stock levels. Requires PANCAKE_POS_API_KEY, PANCAKE_POS_SHOP_ID env vars.")]
688    async fn sync_pancake(&self, Parameters(input): Parameters<PancakeSyncInput>) -> String {
689        let (Some(key), Some(shop)) = (&self.pancake_key, &self.pancake_shop) else {
690            return json!({"error": "PANCAKE_NOT_CONFIGURED", "message": "Set PANCAKE_POS_API_KEY and PANCAKE_POS_SHOP_ID"}).to_string();
691        };
692        let base = format!("https://pos.pages.fm/api/v1/shops/{}", shop);
693        match input.direction.as_str() {
694            "pull" => {
695                let url = format!("{}/inventory?warehouse_id={}", base, input.warehouse_id.as_deref().unwrap_or("all"));
696                match self.client.get(&url).header("Authorization", format!("Bearer {}", key)).send().await {
697                    Ok(resp) => match resp.json::<Value>().await {
698                        Ok(data) => {
699                            let items = data["data"].as_array().unwrap_or(&vec![]).clone();
700                            let mut synced = 0;
701                            for item in &items {
702                                let sku = item["product_id"].to_string();
703                                let qty = item["quantity"].as_f64().unwrap_or(0.0);
704                                let wh = item["warehouse_id"].to_string();
705                                let mut stock = self.store.stock.lock().unwrap();
706                                if let Some(s) = stock.iter_mut().find(|s| s.sku == sku && s.location_id == format!("pancake_{}", wh)) {
707                                    s.quantity = qty; s.updated_at = now();
708                                } else {
709                                    stock.push(StockLevel { sku, location_id: format!("pancake_{}", wh), quantity: qty, reserved: 0.0, lot_number: None, expiry_date: None, updated_at: now() });
710                                }
711                                synced += 1;
712                            }
713                            json!({"status": "pulled", "source": "pancake_pos", "items_synced": synced}).to_string()
714                        }
715                        Err(e) => json!({"error": e.to_string()}).to_string(),
716                    },
717                    Err(e) => json!({"error": e.to_string()}).to_string(),
718                }
719            }
720            "push" => {
721                json!({"status": "push_planned", "message": "Pancake POS push requires product mapping. Use pull first to establish SKU links."}).to_string()
722            }
723            _ => json!({"error": "Invalid direction. Use 'pull' or 'push'"}).to_string(),
724        }
725    }
726}
727
728// --- Additional input types ---
729
730#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
731pub struct PickCreateInput {
732    /// Order reference (e.g. "ORD-1234")
733    pub order_reference: String,
734    /// Items to pick: [{"sku": "...", "quantity": N, "location_id": "..."}]
735    pub items: Vec<Value>,
736    /// Default location if not specified per item
737    pub default_location: String,
738    /// Assign to picker
739    pub assigned_to: Option<String>,
740}
741
742#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
743pub struct PickConfirmInput {
744    /// Pick order ID
745    pub pick_id: String,
746    /// Picked items: [{"sku": "...", "quantity": N}]
747    pub picked_skus: Vec<Value>,
748}
749
750#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
751pub struct PickIdInput { pub pick_id: String }
752
753#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
754pub struct PutawayRuleInput {
755    /// Item category this rule applies to (or "*" for all)
756    pub category: String,
757    /// Target zone/location ID or name
758    pub target_zone: String,
759    /// Priority (lower = preferred)
760    pub priority: Option<i32>,
761}
762
763#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
764pub struct CycleCountInput {
765    /// Location to count
766    pub location_id: String,
767    /// Scheduled date (ISO)
768    pub scheduled_date: String,
769}
770
771#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
772pub struct CycleCountCompleteInput {
773    /// Cycle count ID
774    pub cycle_count_id: String,
775    /// Who performed the count
776    pub counted_by: String,
777    /// Actual counts: [{"sku": "...", "actual_qty": N}]
778    pub counts: Vec<Value>,
779}
780
781#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
782pub struct WaveCreateInput {
783    /// Wave name (e.g. "Morning Wave", "Priority Rush")
784    pub name: String,
785    /// Pick order IDs to include in this wave
786    pub pick_ids: Vec<String>,
787    /// Priority: low, medium, high, critical
788    pub priority: Option<String>,
789}
790
791#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
792pub struct WaveIdInput { pub wave_id: String }
793
794#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
795pub struct LabelInput {
796    /// Type: sku, location, lot, shipment, receipt
797    pub barcode_type: String,
798    /// Entity ID (SKU code, location ID, lot number, etc.)
799    pub entity_id: String,
800    /// Barcode format: code128, ean13, qr, datamatrix (default: code128)
801    pub barcode_format: Option<String>,
802    /// Extra text lines to print on label
803    pub extra_text: Option<Vec<String>>,
804}
805
806#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
807pub struct LabelBatchInput {
808    /// Type: sku, location, lot, shipment
809    pub barcode_type: String,
810    /// List of entity IDs to generate labels for
811    pub entity_ids: Vec<String>,
812    /// Barcode format (default: code128)
813    pub barcode_format: Option<String>,
814}
815
816// === Serialized / RFID / QR Input Types ===
817
818#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
819pub struct SerialRegisterInput {
820    /// Serial number (unique identifier for this individual unit)
821    pub serial_number: String,
822    /// SKU this serial belongs to
823    pub sku: String,
824    /// Location where item is stored
825    pub location_id: String,
826    /// Lot/batch number
827    pub lot_number: Option<String>,
828    /// Manufacture date
829    pub manufacture_date: Option<String>,
830    /// Expiry date
831    pub expiry_date: Option<String>,
832    /// RFID EPC tag (if tagged)
833    pub rfid_tag: Option<String>,
834}
835
836#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
837pub struct SerialMoveInput {
838    /// Serial number
839    pub serial_number: String,
840    /// New location
841    pub to_location: String,
842    /// Actor
843    pub actor: String,
844    /// Event type: moved, picked, shipped, returned, scrapped
845    pub event_type: Option<String>,
846    /// Reference (order ID, etc.)
847    pub reference: Option<String>,
848}
849
850#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
851pub struct SerialQueryInput {
852    /// Serial number to look up
853    pub serial_number: String,
854}
855
856#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
857pub struct SerialScanInput {
858    /// Location ID to scan
859    pub location_id: String,
860}
861
862#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
863pub struct RfidRegisterInput {
864    /// EPC (Electronic Product Code, 96-bit hex string)
865    pub epc: String,
866    /// Serial number to link (optional)
867    pub serial_number: Option<String>,
868    /// SKU to link (optional)
869    pub sku: Option<String>,
870    /// Location where tag is
871    pub location_id: String,
872}
873
874#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
875pub struct RfidReadInput {
876    /// Location ID where RFID reader is scanning
877    pub location_id: String,
878    /// EPCs detected by the reader
879    pub epcs: Vec<String>,
880}
881
882#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
883pub struct RfidQueryInput {
884    /// EPC to look up
885    pub epc: String,
886}
887
888#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
889pub struct QrGenerateInput {
890    /// Entity type: serial, sku, location, shipment
891    pub entity_type: String,
892    /// Entity ID
893    pub entity_id: String,
894    /// Extra data to encode in QR (JSON string)
895    pub extra_data: Option<Value>,
896}
897
898// === Sync Backend Input Types ===
899
900#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
901pub struct GrocySyncInput {
902    /// Direction: pull (Grocy→local) or push (local→Grocy)
903    pub direction: String,
904    /// SKU filter (optional, sync all if omitted)
905    pub sku: Option<String>,
906}
907
908#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
909pub struct ShopifySyncInput {
910    /// Direction: pull (Shopify→local) or push (local→Shopify)
911    pub direction: String,
912    /// SKU filter (optional)
913    pub sku: Option<String>,
914    /// Shopify location ID (required for push)
915    pub location_id: Option<String>,
916}
917
918#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
919pub struct PancakeSyncInput {
920    /// Direction: pull (Pancake→local) or push (local→Pancake)
921    pub direction: String,
922    /// Warehouse ID filter (optional)
923    pub warehouse_id: Option<String>,
924}