@@ -243,6 +243,253 @@ async def get_inventory_items(context: Context) -> dict:
243243 return response .model_dump ()
244244
245245
246+ # ============================================================================
247+ # Resource 2: katana://inventory/stock-movements
248+ # ============================================================================
249+
250+
251+ class StockMovementsSummary (BaseModel ):
252+ """Summary statistics for stock movements."""
253+
254+ total_movements : int = Field (..., description = "Total number of movements" )
255+ movements_in_response : int = Field (
256+ ..., description = "Number of movements in this response"
257+ )
258+ movement_types : dict [str , int ] = Field (
259+ ..., description = "Count of movements by type (transfer, adjustment)"
260+ )
261+
262+
263+ class StockMovementsResource (BaseModel ):
264+ """Response structure for stock movements resource."""
265+
266+ generated_at : str = Field (
267+ ..., description = "ISO timestamp when resource was generated"
268+ )
269+ summary : StockMovementsSummary = Field (..., description = "Summary statistics" )
270+ movements : list [dict ] = Field (
271+ ..., description = "List of recent stock movements (transfers and adjustments)"
272+ )
273+ next_actions : list [str ] = Field (
274+ default_factory = list , description = "Suggested next actions"
275+ )
276+
277+
278+ async def _get_stock_movements_impl (context : Context ) -> StockMovementsResource :
279+ """Implementation of stock movements resource.
280+
281+ Fetches recent stock transfers and adjustments from Katana and aggregates
282+ them into a unified view of inventory movements.
283+
284+ Args:
285+ context: FastMCP context for accessing the Katana client
286+
287+ Returns:
288+ Structured stock movements data with summary and movements list
289+
290+ Raises:
291+ Exception: If API calls fail
292+ """
293+ logger .info ("stock_movements_resource_started" )
294+ start_time = time .monotonic ()
295+
296+ try :
297+ services = get_services (context )
298+
299+ # Import the generated API functions
300+ from katana_public_api_client .api .stock_adjustment import (
301+ get_all_stock_adjustments ,
302+ )
303+ from katana_public_api_client .api .stock_transfer import (
304+ get_all_stock_transfers ,
305+ )
306+
307+ # Fetch recent stock transfers and adjustments
308+ # TODO: Consider parallelizing with asyncio.gather() for better performance
309+ transfers_response = await get_all_stock_transfers .asyncio_detailed (
310+ client = services .client , limit = 50
311+ )
312+ adjustments_response = await get_all_stock_adjustments .asyncio_detailed (
313+ client = services .client , limit = 50
314+ )
315+
316+ # Parse responses - extract data from Response objects
317+ transfers = transfers_response .parsed if transfers_response .parsed else []
318+ adjustments = adjustments_response .parsed if adjustments_response .parsed else []
319+
320+ # Aggregate into unified movements list
321+ movements = []
322+
323+ # Add transfers
324+ for transfer in transfers :
325+ movements .append (
326+ {
327+ "id" : transfer .id if hasattr (transfer , "id" ) else None ,
328+ "timestamp" : (
329+ transfer .transfer_date .isoformat ()
330+ if hasattr (transfer , "transfer_date" ) and transfer .transfer_date
331+ else (
332+ transfer .updated_at .isoformat ()
333+ if hasattr (transfer , "updated_at" ) and transfer .updated_at
334+ else None
335+ )
336+ ),
337+ "type" : "transfer" ,
338+ "number" : (
339+ transfer .stock_transfer_number
340+ if hasattr (transfer , "stock_transfer_number" )
341+ else None
342+ ),
343+ "source_location_id" : (
344+ transfer .source_location_id
345+ if hasattr (transfer , "source_location_id" )
346+ else None
347+ ),
348+ "target_location_id" : (
349+ transfer .target_location_id
350+ if hasattr (transfer , "target_location_id" )
351+ else None
352+ ),
353+ "status" : (
354+ transfer .status .value
355+ if hasattr (transfer , "status" ) and transfer .status
356+ else None
357+ ),
358+ "notes" : (
359+ transfer .additional_info
360+ if hasattr (transfer , "additional_info" )
361+ else None
362+ ),
363+ }
364+ )
365+
366+ # Add adjustments
367+ for adjustment in adjustments :
368+ movements .append (
369+ {
370+ "id" : adjustment .id if hasattr (adjustment , "id" ) else None ,
371+ "timestamp" : (
372+ adjustment .adjustment_date .isoformat ()
373+ if hasattr (adjustment , "adjustment_date" )
374+ and adjustment .adjustment_date
375+ else (
376+ adjustment .updated_at .isoformat ()
377+ if hasattr (adjustment , "updated_at" )
378+ and adjustment .updated_at
379+ else None
380+ )
381+ ),
382+ "type" : "adjustment" ,
383+ "number" : (
384+ adjustment .stock_adjustment_number
385+ if hasattr (adjustment , "stock_adjustment_number" )
386+ else None
387+ ),
388+ "location_id" : (
389+ adjustment .location_id
390+ if hasattr (adjustment , "location_id" )
391+ else None
392+ ),
393+ "reference_no" : (
394+ adjustment .reference_no
395+ if hasattr (adjustment , "reference_no" )
396+ else None
397+ ),
398+ "status" : (
399+ adjustment .status .value
400+ if hasattr (adjustment , "status" ) and adjustment .status
401+ else None
402+ ),
403+ "notes" : (
404+ adjustment .additional_info
405+ if hasattr (adjustment , "additional_info" )
406+ else None
407+ ),
408+ }
409+ )
410+
411+ # Sort by timestamp (most recent first)
412+ movements .sort (key = lambda m : m .get ("timestamp" ) or "" , reverse = True )
413+
414+ # Count movement types
415+ movement_types = {"transfer" : len (transfers ), "adjustment" : len (adjustments )}
416+
417+ # Build summary
418+ summary = StockMovementsSummary (
419+ total_movements = len (transfers ) + len (adjustments ),
420+ movements_in_response = len (movements ),
421+ movement_types = movement_types ,
422+ )
423+
424+ duration_ms = round ((time .monotonic () - start_time ) * 1000 , 2 )
425+ logger .info (
426+ "stock_movements_resource_completed" ,
427+ total_movements = summary .total_movements ,
428+ duration_ms = duration_ms ,
429+ )
430+
431+ return StockMovementsResource (
432+ generated_at = datetime .now (UTC ).isoformat (),
433+ summary = summary ,
434+ movements = movements ,
435+ next_actions = [
436+ "Review recent adjustments for accuracy" ,
437+ "Check transfer statuses for pending movements" ,
438+ "Audit patterns in stock movements" ,
439+ ],
440+ )
441+
442+ except Exception as e :
443+ duration_ms = round ((time .monotonic () - start_time ) * 1000 , 2 )
444+ logger .error (
445+ "stock_movements_resource_failed" ,
446+ error = str (e ),
447+ error_type = type (e ).__name__ ,
448+ duration_ms = duration_ms ,
449+ exc_info = True ,
450+ )
451+ raise
452+
453+
454+ async def get_stock_movements (context : Context ) -> dict :
455+ """Get stock movements resource.
456+
457+ Provides unified view of recent inventory movements including stock transfers
458+ between locations and manual stock adjustments.
459+
460+ **Resource URI:** `katana://inventory/stock-movements`
461+
462+ **Purpose:** Track inventory changes and audit trail
463+
464+ **Refresh Rate:** On-demand (no caching in v0.1.0)
465+
466+ **Data Includes:**
467+ - Recent stock transfers between locations
468+ - Manual stock adjustments
469+ - Movement timestamps and statuses
470+ - Location information
471+ - Reference numbers and notes
472+
473+ **Use Cases:**
474+ - Monitor recent inventory activity
475+ - Audit stock changes
476+ - Track transfer status
477+ - Investigate discrepancies
478+
479+ **Related Tools:**
480+ - `check_inventory` - Get current stock levels
481+ - `create_purchase_order` - Order more stock
482+
483+ Args:
484+ context: FastMCP context providing access to Katana client
485+
486+ Returns:
487+ Dictionary containing stock movements data with summary and movements list
488+ """
489+ response = await _get_stock_movements_impl (context )
490+ return response .model_dump ()
491+
492+
246493def register_resources (mcp : FastMCP ) -> None :
247494 """Register all inventory resources with the FastMCP instance.
248495
@@ -257,5 +504,13 @@ def register_resources(mcp: FastMCP) -> None:
257504 mime_type = "application/json" ,
258505 )(get_inventory_items )
259506
507+ # Register katana://inventory/stock-movements resource
508+ mcp .resource (
509+ uri = "katana://inventory/stock-movements" ,
510+ name = "Stock Movements" ,
511+ description = "Recent inventory movements (transfers and adjustments)" ,
512+ mime_type = "application/json" ,
513+ )(get_stock_movements )
514+
260515
261516__all__ = ["register_resources" ]
0 commit comments