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>, pub capacity_units: Option<f64>, pub capacity_weight_kg: Option<f64>, 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 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 #[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 #[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#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
731pub struct PickCreateInput {
732 pub order_reference: String,
734 pub items: Vec<Value>,
736 pub default_location: String,
738 pub assigned_to: Option<String>,
740}
741
742#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
743pub struct PickConfirmInput {
744 pub pick_id: String,
746 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 pub category: String,
757 pub target_zone: String,
759 pub priority: Option<i32>,
761}
762
763#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
764pub struct CycleCountInput {
765 pub location_id: String,
767 pub scheduled_date: String,
769}
770
771#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
772pub struct CycleCountCompleteInput {
773 pub cycle_count_id: String,
775 pub counted_by: String,
777 pub counts: Vec<Value>,
779}
780
781#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
782pub struct WaveCreateInput {
783 pub name: String,
785 pub pick_ids: Vec<String>,
787 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 pub barcode_type: String,
798 pub entity_id: String,
800 pub barcode_format: Option<String>,
802 pub extra_text: Option<Vec<String>>,
804}
805
806#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
807pub struct LabelBatchInput {
808 pub barcode_type: String,
810 pub entity_ids: Vec<String>,
812 pub barcode_format: Option<String>,
814}
815
816#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
819pub struct SerialRegisterInput {
820 pub serial_number: String,
822 pub sku: String,
824 pub location_id: String,
826 pub lot_number: Option<String>,
828 pub manufacture_date: Option<String>,
830 pub expiry_date: Option<String>,
832 pub rfid_tag: Option<String>,
834}
835
836#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
837pub struct SerialMoveInput {
838 pub serial_number: String,
840 pub to_location: String,
842 pub actor: String,
844 pub event_type: Option<String>,
846 pub reference: Option<String>,
848}
849
850#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
851pub struct SerialQueryInput {
852 pub serial_number: String,
854}
855
856#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
857pub struct SerialScanInput {
858 pub location_id: String,
860}
861
862#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
863pub struct RfidRegisterInput {
864 pub epc: String,
866 pub serial_number: Option<String>,
868 pub sku: Option<String>,
870 pub location_id: String,
872}
873
874#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
875pub struct RfidReadInput {
876 pub location_id: String,
878 pub epcs: Vec<String>,
880}
881
882#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
883pub struct RfidQueryInput {
884 pub epc: String,
886}
887
888#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
889pub struct QrGenerateInput {
890 pub entity_type: String,
892 pub entity_id: String,
894 pub extra_data: Option<Value>,
896}
897
898#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
901pub struct GrocySyncInput {
902 pub direction: String,
904 pub sku: Option<String>,
906}
907
908#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
909pub struct ShopifySyncInput {
910 pub direction: String,
912 pub sku: Option<String>,
914 pub location_id: Option<String>,
916}
917
918#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
919pub struct PancakeSyncInput {
920 pub direction: String,
922 pub warehouse_id: Option<String>,
924}