Skip to main content

mcp_procurement/
server.rs

1use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router};
2use serde_json::{json, Value};
3use crate::types::*;
4use crate::store::Store;
5
6fn now() -> String { chrono::Utc::now().to_rfc3339() }
7fn round2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
8
9#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
10pub struct SupplierInput { pub name: String, pub category: String, pub country: String, pub currency: Option<String>, pub contact_email: Option<String>, pub contact_phone: Option<String>, pub payment_terms: Option<String> }
11#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
12pub struct SupplierIdInput { pub supplier_id: String }
13#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
14pub struct SupplierRateInput { pub supplier_id: String, pub rating: f64, pub reason: Option<String> }
15#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
16pub struct PoCreateInput { pub supplier_id: String, pub lines: Vec<Value>, pub currency: Option<String>, pub delivery_date: Option<String>, pub notes: Option<String>, pub created_by: String }
17#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
18pub struct PoIdInput { pub po_id: String }
19#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
20pub struct PoApproveInput { pub po_id: String, pub approved_by: String }
21#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
22pub struct RfqCreateInput { pub title: String, pub items: Vec<Value>, pub supplier_ids: Vec<String>, pub deadline: String, pub created_by: String }
23#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
24pub struct RfqResponseInput { pub rfq_id: String, pub supplier_id: String, pub unit_prices: Vec<f64>, pub lead_time_days: u32, pub notes: Option<String> }
25#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
26pub struct RfqAwardInput { pub rfq_id: String, pub supplier_id: String }
27#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
28pub struct ReceiveInput { pub po_id: String, pub lines: Vec<Value>, pub received_by: String, pub notes: Option<String> }
29#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
30pub struct SpendInput { pub start: Option<String>, pub end: Option<String>, pub supplier_id: Option<String>, pub category: Option<String> }
31#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
32pub struct ThreeWayMatchInput { pub po_id: String, pub invoice_number: String, pub invoice_amount: f64 }
33#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
34pub struct ContractInput { pub supplier_id: String, pub title: String, pub contract_type: String, pub value: f64, pub currency: Option<String>, pub start_date: String, pub end_date: String, pub auto_renew: Option<bool>, pub terms: Option<String> }
35#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
36pub struct ContractIdInput { pub contract_id: String }
37#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
38pub struct DiversityInput { pub supplier_id: String, pub certifications: Vec<String>, pub certified_by: Option<String>, pub expiry_date: Option<String> }
39#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
40pub struct DiversityReportInput { pub certification: Option<String> }
41#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
42pub struct BudgetInput { pub department: String, pub category: String, pub allocated: f64, pub currency: Option<String>, pub period: String }
43#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
44pub struct BudgetCheckInput { pub department: String, pub amount: f64 }
45#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
46pub struct CatalogAddInput { pub supplier_id: String, pub items: Vec<Value> }
47#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
48pub struct CatalogSearchInput { pub query: String, pub supplier_id: Option<String> }
49#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
50pub struct ApprovalEscalateInput { pub po_id: String, pub current_approver: String, pub escalate_to: String, pub reason: String }
51
52#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
53pub struct RiskScoreInput { pub supplier_id: String }
54#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
55pub struct BenchmarkInput { pub sku: String, pub quoted_price: f64, pub supplier_id: Option<String> }
56#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
57pub struct CarbonInput { pub supplier_id: String, pub weight_kg: f64, pub distance_km: f64, pub transport_mode: Option<String> }
58#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
59pub struct RecommendInput { pub category: String, pub budget: Option<f64>, pub priority: Option<String> }
60#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
61pub struct ClauseCheckInput { pub contract_id: String }
62#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
63pub struct ForecastInput { pub category: Option<String>, pub months_ahead: Option<u32> }
64#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
65pub struct ScorecardInput { pub supplier_id: String }
66#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
67pub struct NegotiationInput { pub supplier_id: String, pub items: Vec<String>, pub target_discount_pct: Option<f64> }
68
69#[derive(Clone)]
70pub struct ProcurementServer { pub store: Store }
71impl ProcurementServer { pub fn new() -> Self { Self { store: Store::new() } } }
72
73#[tool_router(server_handler)]
74impl ProcurementServer {
75    // === Suppliers ===
76
77    #[tool(description = "Register a supplier (name, category, country, payment terms, contact info).")]
78    async fn supplier_create(&self, Parameters(input): Parameters<SupplierInput>) -> String {
79        let id = Store::new_id("sup");
80        let sup = Supplier { id: id.clone(), name: input.name, category: input.category, contact_email: input.contact_email, contact_phone: input.contact_phone, country: input.country, currency: input.currency.unwrap_or_else(|| "USD".into()), payment_terms: input.payment_terms.unwrap_or_else(|| "net30".into()), rating: 0.0, status: "active".into(), metadata: json!({}) };
81        self.store.suppliers.lock().unwrap().insert(id.clone(), sup);
82        json!({"status": "created", "supplier_id": id}).to_string()
83    }
84
85    #[tool(description = "List all suppliers (optionally filter by category or status).")]
86    async fn supplier_list(&self) -> String {
87        let suppliers: Vec<_> = self.store.suppliers.lock().unwrap().values().cloned().collect();
88        json!({"count": suppliers.len(), "suppliers": suppliers}).to_string()
89    }
90
91    #[tool(description = "Get supplier details by ID.")]
92    async fn supplier_get(&self, Parameters(input): Parameters<SupplierIdInput>) -> String {
93        match self.store.suppliers.lock().unwrap().get(&input.supplier_id) {
94            Some(s) => serde_json::to_string_pretty(s).unwrap_or_default(),
95            None => json!({"error": "SUPPLIER_NOT_FOUND"}).to_string(),
96        }
97    }
98
99    #[tool(description = "Rate a supplier (0-5 stars). Tracks performance over time.")]
100    async fn supplier_rate(&self, Parameters(input): Parameters<SupplierRateInput>) -> String {
101        let mut suppliers = self.store.suppliers.lock().unwrap();
102        match suppliers.get_mut(&input.supplier_id) {
103            Some(s) => { s.rating = input.rating.min(5.0).max(0.0); json!({"status": "rated", "supplier_id": input.supplier_id, "rating": s.rating}).to_string() }
104            None => json!({"error": "SUPPLIER_NOT_FOUND"}).to_string(),
105        }
106    }
107
108    // === Purchase Orders ===
109
110    #[tool(description = "Create a purchase order. Lines: [{\"sku\": \"...\", \"description\": \"...\", \"quantity\": N, \"unit_price\": X}]")]
111    async fn po_create(&self, Parameters(input): Parameters<PoCreateInput>) -> String {
112        let currency = input.currency.unwrap_or_else(|| "USD".into());
113        let lines: Vec<PoLine> = input.lines.iter().map(|l| {
114            let qty = l["quantity"].as_f64().unwrap_or(1.0);
115            let price = l["unit_price"].as_f64().unwrap_or(0.0);
116            PoLine { sku: l["sku"].as_str().unwrap_or("").into(), description: l["description"].as_str().unwrap_or("").into(), quantity: qty, unit_price: price, total: round2(qty * price), received_qty: 0.0 }
117        }).collect();
118        let subtotal: f64 = lines.iter().map(|l| l.total).sum();
119        let tax = round2(subtotal * 0.16); // Default VAT
120        let id = Store::new_id("po");
121        let po = PurchaseOrder { id: id.clone(), supplier_id: input.supplier_id, status: "draft".into(), lines, currency, subtotal: round2(subtotal), tax, total: round2(subtotal + tax), payment_terms: "net30".into(), delivery_date: input.delivery_date, notes: input.notes, created_by: input.created_by, approved_by: None, created_at: now() };
122        self.store.purchase_orders.lock().unwrap().insert(id.clone(), po);
123        json!({"status": "created", "po_id": id, "total": round2(subtotal + tax)}).to_string()
124    }
125
126    #[tool(description = "List purchase orders (all or filter by status: draft, approved, sent, received).")]
127    async fn po_list(&self) -> String {
128        let pos: Vec<_> = self.store.purchase_orders.lock().unwrap().values().cloned().collect();
129        json!({"count": pos.len(), "purchase_orders": pos}).to_string()
130    }
131
132    #[tool(description = "Get purchase order details by ID.")]
133    async fn po_get(&self, Parameters(input): Parameters<PoIdInput>) -> String {
134        match self.store.purchase_orders.lock().unwrap().get(&input.po_id) {
135            Some(po) => serde_json::to_string_pretty(po).unwrap_or_default(),
136            None => json!({"error": "PO_NOT_FOUND"}).to_string(),
137        }
138    }
139
140    #[tool(description = "Approve a purchase order (moves from draft to approved). Requires approver identity.")]
141    async fn po_approve(&self, Parameters(input): Parameters<PoApproveInput>) -> String {
142        let mut pos = self.store.purchase_orders.lock().unwrap();
143        match pos.get_mut(&input.po_id) {
144            Some(po) => {
145                if po.status != "draft" && po.status != "pending_approval" { return json!({"error": "PO_NOT_IN_DRAFT"}).to_string(); }
146                po.status = "approved".into();
147                po.approved_by = Some(input.approved_by.clone());
148                json!({"status": "approved", "po_id": input.po_id, "approved_by": input.approved_by}).to_string()
149            }
150            None => json!({"error": "PO_NOT_FOUND"}).to_string(),
151        }
152    }
153
154    #[tool(description = "Send a purchase order to the supplier (marks as sent).")]
155    async fn po_send(&self, Parameters(input): Parameters<PoIdInput>) -> String {
156        let mut pos = self.store.purchase_orders.lock().unwrap();
157        match pos.get_mut(&input.po_id) {
158            Some(po) => {
159                if po.status != "approved" { return json!({"error": "PO_NOT_APPROVED"}).to_string(); }
160                po.status = "sent".into();
161                json!({"status": "sent", "po_id": input.po_id, "supplier_id": po.supplier_id}).to_string()
162            }
163            None => json!({"error": "PO_NOT_FOUND"}).to_string(),
164        }
165    }
166
167    #[tool(description = "Cancel a purchase order.")]
168    async fn po_cancel(&self, Parameters(input): Parameters<PoIdInput>) -> String {
169        let mut pos = self.store.purchase_orders.lock().unwrap();
170        match pos.get_mut(&input.po_id) {
171            Some(po) => { po.status = "cancelled".into(); json!({"status": "cancelled", "po_id": input.po_id}).to_string() }
172            None => json!({"error": "PO_NOT_FOUND"}).to_string(),
173        }
174    }
175
176    // === RFQ (Request for Quotation) ===
177
178    #[tool(description = "Create an RFQ (Request for Quotation) and send to multiple suppliers. Items: [{\"description\": \"...\", \"quantity\": N, \"unit\": \"kg\"}]")]
179    async fn rfq_create(&self, Parameters(input): Parameters<RfqCreateInput>) -> String {
180        let items: Vec<RfqItem> = input.items.iter().map(|i| RfqItem { description: i["description"].as_str().unwrap_or("").into(), quantity: i["quantity"].as_f64().unwrap_or(1.0), unit: i["unit"].as_str().unwrap_or("each").into(), specs: i["specs"].as_str().map(String::from) }).collect();
181        let id = Store::new_id("rfq");
182        let rfq = Rfq { id: id.clone(), title: input.title, status: "open".into(), items, supplier_ids: input.supplier_ids, responses: vec![], deadline: input.deadline, created_by: input.created_by, created_at: now() };
183        self.store.rfqs.lock().unwrap().insert(id.clone(), rfq);
184        json!({"status": "created", "rfq_id": id}).to_string()
185    }
186
187    #[tool(description = "Submit a supplier response to an RFQ (pricing, lead time).")]
188    async fn rfq_respond(&self, Parameters(input): Parameters<RfqResponseInput>) -> String {
189        let mut rfqs = self.store.rfqs.lock().unwrap();
190        match rfqs.get_mut(&input.rfq_id) {
191            Some(rfq) => {
192                let supplier_name = self.store.suppliers.lock().unwrap().get(&input.supplier_id).map(|s| s.name.clone()).unwrap_or_else(|| input.supplier_id.clone());
193                let total: f64 = rfq.items.iter().zip(input.unit_prices.iter()).map(|(item, price)| item.quantity * price).sum();
194                rfq.responses.push(RfqResponse { supplier_id: input.supplier_id, supplier_name, unit_prices: input.unit_prices, total: round2(total), lead_time_days: input.lead_time_days, notes: input.notes, submitted_at: now() });
195                json!({"status": "response_submitted", "rfq_id": input.rfq_id, "total_quoted": round2(total)}).to_string()
196            }
197            None => json!({"error": "RFQ_NOT_FOUND"}).to_string(),
198        }
199    }
200
201    #[tool(description = "Compare RFQ responses side-by-side (price, lead time, supplier rating).")]
202    async fn rfq_compare(&self, Parameters(input): Parameters<PoIdInput>) -> String {
203        let rfqs = self.store.rfqs.lock().unwrap();
204        match rfqs.get(&input.po_id) { // reusing PoIdInput for rfq_id
205            Some(rfq) => {
206                let suppliers = self.store.suppliers.lock().unwrap();
207                let comparison: Vec<Value> = rfq.responses.iter().map(|r| {
208                    let rating = suppliers.get(&r.supplier_id).map(|s| s.rating).unwrap_or(0.0);
209                    json!({"supplier_id": r.supplier_id, "supplier_name": r.supplier_name, "total": r.total, "lead_time_days": r.lead_time_days, "rating": rating, "notes": r.notes})
210                }).collect();
211                json!({"rfq_id": input.po_id, "title": rfq.title, "responses": comparison.len(), "comparison": comparison}).to_string()
212            }
213            None => json!({"error": "RFQ_NOT_FOUND"}).to_string(),
214        }
215    }
216
217    #[tool(description = "Award an RFQ to a supplier (closes RFQ, optionally auto-creates PO).")]
218    async fn rfq_award(&self, Parameters(input): Parameters<RfqAwardInput>) -> String {
219        let mut rfqs = self.store.rfqs.lock().unwrap();
220        match rfqs.get_mut(&input.rfq_id) {
221            Some(rfq) => {
222                rfq.status = "awarded".into();
223                let winner = rfq.responses.iter().find(|r| r.supplier_id == input.supplier_id);
224                json!({"status": "awarded", "rfq_id": input.rfq_id, "awarded_to": input.supplier_id, "total": winner.map(|w| w.total)}).to_string()
225            }
226            None => json!({"error": "RFQ_NOT_FOUND"}).to_string(),
227        }
228    }
229
230    // === Goods Receipt ===
231
232    #[tool(description = "Receive goods against a PO. Lines: [{\"sku\": \"...\", \"quantity_received\": N, \"quantity_rejected\": N, \"rejection_reason\": \"...\"}]")]
233    async fn goods_receive(&self, Parameters(input): Parameters<ReceiveInput>) -> String {
234        let mut pos = self.store.purchase_orders.lock().unwrap();
235        let po = match pos.get_mut(&input.po_id) {
236            Some(p) => p,
237            None => return json!({"error": "PO_NOT_FOUND"}).to_string(),
238        };
239        let lines: Vec<ReceiptLine> = input.lines.iter().map(|l| {
240            let sku = l["sku"].as_str().unwrap_or("").to_string();
241            let qty = l["quantity_received"].as_f64().unwrap_or(0.0);
242            let rejected = l["quantity_rejected"].as_f64().unwrap_or(0.0);
243            // Update PO line received qty
244            if let Some(po_line) = po.lines.iter_mut().find(|pl| pl.sku == sku) { po_line.received_qty += qty; }
245            ReceiptLine { sku, quantity_received: qty, quantity_rejected: rejected, rejection_reason: l["rejection_reason"].as_str().map(String::from) }
246        }).collect();
247        let all_received = po.lines.iter().all(|l| l.received_qty >= l.quantity);
248        po.status = if all_received { "received".into() } else { "partially_received".into() };
249        let id = Store::new_id("gr");
250        let receipt = GoodsReceipt { id: id.clone(), po_id: input.po_id.clone(), lines, received_by: input.received_by, received_at: now(), notes: input.notes };
251        drop(pos);
252        self.store.receipts.lock().unwrap().push(receipt);
253        json!({"status": if all_received { "fully_received" } else { "partially_received" }, "receipt_id": id, "po_id": input.po_id}).to_string()
254    }
255
256    // === Spend Analytics ===
257
258    #[tool(description = "Get spend analytics (total spend by supplier, category, time period).")]
259    async fn spend_analysis(&self, Parameters(input): Parameters<SpendInput>) -> String {
260        let pos = self.store.purchase_orders.lock().unwrap();
261        let suppliers = self.store.suppliers.lock().unwrap();
262        let filtered: Vec<_> = pos.values().filter(|po| {
263            po.status != "draft" && po.status != "cancelled"
264            && input.supplier_id.as_ref().map_or(true, |s| po.supplier_id == *s)
265        }).collect();
266        let total_spend: f64 = filtered.iter().map(|po| po.total).sum();
267        let by_supplier: Vec<Value> = {
268            let mut map: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
269            for po in &filtered { *map.entry(po.supplier_id.clone()).or_default() += po.total; }
270            map.iter().map(|(sid, total)| {
271                let name = suppliers.get(sid).map(|s| s.name.clone()).unwrap_or_else(|| sid.clone());
272                json!({"supplier_id": sid, "name": name, "total_spend": round2(*total)})
273            }).collect()
274        };
275        json!({"total_spend": round2(total_spend), "po_count": filtered.len(), "by_supplier": by_supplier}).to_string()
276    }
277
278    // === 3-Way Match ===
279
280    #[tool(description = "Perform 3-way match: compare PO amount vs goods receipt vs supplier invoice. Flags discrepancies for AP review.")]
281    async fn three_way_match(&self, Parameters(input): Parameters<ThreeWayMatchInput>) -> String {
282        let pos = self.store.purchase_orders.lock().unwrap();
283        let po = match pos.get(&input.po_id) {
284            Some(p) => p.clone(),
285            None => return json!({"error": "PO_NOT_FOUND"}).to_string(),
286        };
287        drop(pos);
288        let receipts = self.store.receipts.lock().unwrap();
289        let received_total: f64 = receipts.iter().filter(|r| r.po_id == input.po_id).flat_map(|r| r.lines.iter()).map(|l| l.quantity_received).sum();
290        let ordered_total: f64 = po.lines.iter().map(|l| l.quantity).sum();
291        let po_amount = po.total;
292        let invoice_amount = input.invoice_amount;
293        let qty_match = (received_total - ordered_total).abs() < 0.01;
294        let amount_match = (po_amount - invoice_amount).abs() < 0.01;
295        let tolerance_match = ((po_amount - invoice_amount).abs() / po_amount * 100.0) < 2.0; // 2% tolerance
296        let status = if qty_match && amount_match { "full_match" } else if tolerance_match { "within_tolerance" } else { "discrepancy" };
297        // Store invoice
298        let inv_id = Store::new_id("inv");
299        self.store.invoices.lock().unwrap().push(Invoice { id: inv_id.clone(), po_id: input.po_id.clone(), supplier_id: po.supplier_id, invoice_number: input.invoice_number, amount: invoice_amount, currency: po.currency, status: status.into(), received_at: now() });
300        json!({"status": status, "invoice_id": inv_id, "po_amount": po_amount, "invoice_amount": invoice_amount, "amount_variance": round2(invoice_amount - po_amount), "qty_ordered": ordered_total, "qty_received": received_total, "qty_match": qty_match, "amount_match": amount_match}).to_string()
301    }
302
303    // === Contracts ===
304
305    #[tool(description = "Create a supplier contract (fixed price, time & materials, blanket, framework). Tracks value, terms, expiry, auto-renewal.")]
306    async fn contract_create(&self, Parameters(input): Parameters<ContractInput>) -> String {
307        let id = Store::new_id("ctr");
308        let contract = Contract { id: id.clone(), supplier_id: input.supplier_id, title: input.title, contract_type: input.contract_type, value: input.value, currency: input.currency.unwrap_or_else(|| "USD".into()), start_date: input.start_date, end_date: input.end_date, auto_renew: input.auto_renew.unwrap_or(false), terms: input.terms.unwrap_or_default(), status: "active".into(), created_at: now() };
309        self.store.contracts.lock().unwrap().insert(id.clone(), contract);
310        json!({"status": "created", "contract_id": id}).to_string()
311    }
312
313    #[tool(description = "List contracts (active, expiring soon, by supplier).")]
314    async fn contract_list(&self) -> String {
315        let contracts: Vec<_> = self.store.contracts.lock().unwrap().values().cloned().collect();
316        let expiring: Vec<_> = contracts.iter().filter(|c| c.status == "active" && c.end_date <= (chrono::Utc::now() + chrono::Duration::days(90)).to_rfc3339()).collect();
317        json!({"count": contracts.len(), "expiring_within_90_days": expiring.len(), "contracts": contracts}).to_string()
318    }
319
320    #[tool(description = "Get contract details by ID.")]
321    async fn contract_get(&self, Parameters(input): Parameters<ContractIdInput>) -> String {
322        match self.store.contracts.lock().unwrap().get(&input.contract_id) {
323            Some(c) => serde_json::to_string_pretty(c).unwrap_or_default(),
324            None => json!({"error": "CONTRACT_NOT_FOUND"}).to_string(),
325        }
326    }
327
328    // === Supplier Diversity ===
329
330    #[tool(description = "Set supplier diversity certifications (minority_owned, women_owned, veteran_owned, small_business, disabled_owned, lgbtq_owned).")]
331    async fn diversity_set(&self, Parameters(input): Parameters<DiversityInput>) -> String {
332        let mut diversity = self.store.diversity.lock().unwrap();
333        diversity.retain(|d| d.supplier_id != input.supplier_id);
334        diversity.push(SupplierDiversity { supplier_id: input.supplier_id.clone(), certifications: input.certifications.clone(), certified_by: input.certified_by, expiry_date: input.expiry_date });
335        json!({"status": "set", "supplier_id": input.supplier_id, "certifications": input.certifications}).to_string()
336    }
337
338    #[tool(description = "Get supplier diversity report (spend with diverse suppliers, certification breakdown).")]
339    async fn diversity_report(&self, Parameters(input): Parameters<DiversityReportInput>) -> String {
340        let diversity = self.store.diversity.lock().unwrap().clone();
341        let suppliers = self.store.suppliers.lock().unwrap().clone();
342        let pos = self.store.purchase_orders.lock().unwrap().clone();
343        let diverse_suppliers: Vec<_> = diversity.iter().filter(|d| input.certification.as_ref().map_or(true, |c| d.certifications.contains(c))).collect();
344        let diverse_ids: Vec<_> = diverse_suppliers.iter().map(|d| &d.supplier_id).collect();
345        let diverse_spend: f64 = pos.values().filter(|po| diverse_ids.contains(&&po.supplier_id) && po.status != "cancelled").map(|po| po.total).sum();
346        let total_spend: f64 = pos.values().filter(|po| po.status != "cancelled").map(|po| po.total).sum();
347        let pct = if total_spend > 0.0 { round2(diverse_spend / total_spend * 100.0) } else { 0.0 };
348        json!({"diverse_suppliers": diverse_suppliers.len(), "total_suppliers": suppliers.len(), "diverse_spend": round2(diverse_spend), "total_spend": round2(total_spend), "diversity_pct": pct, "certifications": diverse_suppliers.iter().flat_map(|d| d.certifications.iter()).collect::<Vec<_>>()}).to_string()
349    }
350
351    // === Budget ===
352
353    #[tool(description = "Set department budget (allocated amount for a category and period).")]
354    async fn budget_set(&self, Parameters(input): Parameters<BudgetInput>) -> String {
355        let id = Store::new_id("bgt");
356        self.store.budgets.lock().unwrap().push(Budget { id: id.clone(), department: input.department, category: input.category, allocated: input.allocated, spent: 0.0, currency: input.currency.unwrap_or_else(|| "USD".into()), period: input.period });
357        json!({"status": "set", "budget_id": id}).to_string()
358    }
359
360    #[tool(description = "Check if a purchase amount fits within department budget. Returns remaining budget and approval recommendation.")]
361    async fn budget_check(&self, Parameters(input): Parameters<BudgetCheckInput>) -> String {
362        let budgets = self.store.budgets.lock().unwrap();
363        let dept_budgets: Vec<_> = budgets.iter().filter(|b| b.department == input.department).collect();
364        if dept_budgets.is_empty() { return json!({"status": "no_budget_set", "department": input.department, "recommendation": "approve_with_caution"}).to_string(); }
365        let total_allocated: f64 = dept_budgets.iter().map(|b| b.allocated).sum();
366        let total_spent: f64 = dept_budgets.iter().map(|b| b.spent).sum();
367        let remaining = total_allocated - total_spent;
368        let within_budget = input.amount <= remaining;
369        json!({"department": input.department, "allocated": round2(total_allocated), "spent": round2(total_spent), "remaining": round2(remaining), "requested": input.amount, "within_budget": within_budget, "recommendation": if within_budget { "approve" } else { "reject_over_budget" }}).to_string()
370    }
371
372    // === Punch-out Catalogs ===
373
374    #[tool(description = "Add items to a supplier's punch-out catalog (pre-negotiated prices for direct ordering).")]
375    async fn catalog_add(&self, Parameters(input): Parameters<CatalogAddInput>) -> String {
376        let mut count = 0;
377        for item in &input.items {
378            let cat_item = CatalogItem { id: Store::new_id("cat"), supplier_id: input.supplier_id.clone(), sku: item["sku"].as_str().unwrap_or("").into(), description: item["description"].as_str().unwrap_or("").into(), unit_price: item["unit_price"].as_f64().unwrap_or(0.0), currency: item["currency"].as_str().unwrap_or("USD").into(), lead_time_days: item["lead_time_days"].as_u64().unwrap_or(7) as u32, min_order_qty: item["min_order_qty"].as_f64().unwrap_or(1.0) };
379            self.store.catalogs.lock().unwrap().push(cat_item);
380            count += 1;
381        }
382        json!({"status": "added", "supplier_id": input.supplier_id, "items_added": count}).to_string()
383    }
384
385    #[tool(description = "Search supplier catalogs (find items across all suppliers by keyword).")]
386    async fn catalog_search(&self, Parameters(input): Parameters<CatalogSearchInput>) -> String {
387        let catalogs = self.store.catalogs.lock().unwrap();
388        let query = input.query.to_lowercase();
389        let results: Vec<_> = catalogs.iter().filter(|c| {
390            (c.description.to_lowercase().contains(&query) || c.sku.to_lowercase().contains(&query))
391            && input.supplier_id.as_ref().map_or(true, |s| c.supplier_id == *s)
392        }).cloned().collect();
393        json!({"query": input.query, "results": results.len(), "items": results}).to_string()
394    }
395
396    // === Multi-level Approval ===
397
398    #[tool(description = "Escalate PO approval to a higher authority (manager → director → CFO based on amount thresholds).")]
399    async fn approval_escalate(&self, Parameters(input): Parameters<ApprovalEscalateInput>) -> String {
400        let pos = self.store.purchase_orders.lock().unwrap();
401        match pos.get(&input.po_id) {
402            Some(po) => {
403                let level = if po.total > 100000.0 { "CFO" } else if po.total > 10000.0 { "Director" } else { "Manager" };
404                json!({"status": "escalated", "po_id": input.po_id, "amount": po.total, "from": input.current_approver, "escalated_to": input.escalate_to, "required_level": level, "reason": input.reason}).to_string()
405            }
406            None => json!({"error": "PO_NOT_FOUND"}).to_string(),
407        }
408    }
409
410    // === USP: Intelligence & Analytics ===
411
412    #[tool(description = "Calculate supplier risk score (0-100). Factors: country risk, payment history, diversity, contract coverage, order volume concentration.")]
413    async fn supplier_risk_score(&self, Parameters(input): Parameters<RiskScoreInput>) -> String {
414        let suppliers = self.store.suppliers.lock().unwrap();
415        let sup = match suppliers.get(&input.supplier_id) {
416            Some(s) => s.clone(),
417            None => return json!({"error": "SUPPLIER_NOT_FOUND"}).to_string(),
418        };
419        drop(suppliers);
420        let pos = self.store.purchase_orders.lock().unwrap();
421        let supplier_pos: Vec<_> = pos.values().filter(|po| po.supplier_id == input.supplier_id).collect();
422        let total_pos = supplier_pos.len();
423        let cancelled = supplier_pos.iter().filter(|po| po.status == "cancelled").count();
424        drop(pos);
425        let contracts = self.store.contracts.lock().unwrap();
426        let has_contract = contracts.values().any(|c| c.supplier_id == input.supplier_id && c.status == "active");
427        drop(contracts);
428        // Risk factors (lower = riskier)
429        let country_score = match sup.country.as_str() { "US"|"GB"|"DE"|"JP"|"AU"|"CA"|"SG" => 90.0, "KE"|"NG"|"IN"|"BR"|"ZA" => 70.0, _ => 60.0 };
430        let performance_score = if total_pos > 0 { (1.0 - cancelled as f64 / total_pos as f64) * 100.0 } else { 50.0 };
431        let contract_score = if has_contract { 90.0 } else { 40.0 };
432        let rating_score = sup.rating * 20.0;
433        let overall = round2((country_score + performance_score + contract_score + rating_score) / 4.0);
434        let level = if overall >= 80.0 { "low_risk" } else if overall >= 60.0 { "medium_risk" } else { "high_risk" };
435        json!({"supplier_id": input.supplier_id, "name": sup.name, "risk_score": overall, "risk_level": level, "factors": {"country_risk": round2(country_score), "performance": round2(performance_score), "contract_coverage": round2(contract_score), "rating": round2(rating_score)}, "recommendations": if overall < 60.0 { vec!["Consider alternative suppliers", "Require advance payment", "Increase inspection frequency"] } else { vec![] }}).to_string()
436    }
437
438    #[tool(description = "Benchmark a quoted price against historical purchases and market data. Shows if you're overpaying.")]
439    async fn price_benchmark(&self, Parameters(input): Parameters<BenchmarkInput>) -> String {
440        let pos = self.store.purchase_orders.lock().unwrap();
441        let historical_prices: Vec<f64> = pos.values().flat_map(|po| po.lines.iter()).filter(|l| l.sku == input.sku).map(|l| l.unit_price).collect();
442        if historical_prices.is_empty() { return json!({"sku": input.sku, "quoted_price": input.quoted_price, "benchmark": "no_history", "recommendation": "Accept if within budget — no historical data to compare"}).to_string(); }
443        let avg: f64 = historical_prices.iter().sum::<f64>() / historical_prices.len() as f64;
444        let min = historical_prices.iter().cloned().fold(f64::INFINITY, f64::min);
445        let max = historical_prices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
446        let vs_avg_pct = round2((input.quoted_price - avg) / avg * 100.0);
447        let verdict = if vs_avg_pct <= -10.0 { "excellent_deal" } else if vs_avg_pct <= 0.0 { "good_price" } else if vs_avg_pct <= 10.0 { "acceptable" } else { "overpriced" };
448        json!({"sku": input.sku, "quoted_price": input.quoted_price, "historical_avg": round2(avg), "historical_min": min, "historical_max": max, "vs_average_pct": vs_avg_pct, "verdict": verdict, "data_points": historical_prices.len(), "recommendation": match verdict { "overpriced" => "Negotiate down or seek alternatives", "excellent_deal" => "Accept — below market average", _ => "Acceptable price" }}).to_string()
449    }
450
451    #[tool(description = "Estimate carbon footprint for a procurement shipment (CO2 kg based on weight, distance, transport mode).")]
452    async fn carbon_footprint(&self, Parameters(input): Parameters<CarbonInput>) -> String {
453        let mode = input.transport_mode.as_deref().unwrap_or("road");
454        // CO2 emission factors (kg CO2 per tonne-km)
455        let factor = match mode { "air" => 0.602, "sea" => 0.016, "rail" => 0.028, "road" | "truck" => 0.096, _ => 0.096 };
456        let co2_kg = round2(input.weight_kg / 1000.0 * input.distance_km * factor);
457        let trees_equivalent = round2(co2_kg / 21.0); // 1 tree absorbs ~21kg CO2/year
458        let sup_name = self.store.suppliers.lock().unwrap().get(&input.supplier_id).map(|s| s.name.clone()).unwrap_or_default();
459        json!({"supplier_id": input.supplier_id, "supplier_name": sup_name, "weight_kg": input.weight_kg, "distance_km": input.distance_km, "transport_mode": mode, "co2_kg": co2_kg, "trees_to_offset": trees_equivalent, "emission_factor": factor, "rating": if co2_kg < 10.0 { "low" } else if co2_kg < 100.0 { "medium" } else { "high" }}).to_string()
460    }
461
462    #[tool(description = "AI-powered supplier recommendation based on category, past performance, price, lead time, diversity, and risk score.")]
463    async fn supplier_recommend(&self, Parameters(input): Parameters<RecommendInput>) -> String {
464        let suppliers = self.store.suppliers.lock().unwrap().clone();
465        let pos = self.store.purchase_orders.lock().unwrap().clone();
466        let diversity = self.store.diversity.lock().unwrap().clone();
467        let mut candidates: Vec<Value> = suppliers.values().filter(|s| s.category == input.category && s.status == "active").map(|s| {
468            let order_count = pos.values().filter(|po| po.supplier_id == s.id && po.status != "cancelled").count();
469            let avg_total: f64 = pos.values().filter(|po| po.supplier_id == s.id).map(|po| po.total).sum::<f64>() / order_count.max(1) as f64;
470            let is_diverse = diversity.iter().any(|d| d.supplier_id == s.id);
471            let score = s.rating * 20.0 + if is_diverse { 10.0 } else { 0.0 } + (order_count as f64).min(20.0);
472            json!({"supplier_id": s.id, "name": s.name, "country": s.country, "rating": s.rating, "orders": order_count, "avg_order_value": round2(avg_total), "diverse": is_diverse, "score": round2(score)})
473        }).collect();
474        candidates.sort_by(|a, b| b["score"].as_f64().unwrap_or(0.0).partial_cmp(&a["score"].as_f64().unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));
475        json!({"category": input.category, "candidates": candidates.len(), "recommendations": &candidates[..candidates.len().min(5)]}).to_string()
476    }
477
478    #[tool(description = "Check contract for risky clauses (auto-renewal traps, unlimited liability, IP assignment, non-compete, penalty clauses).")]
479    async fn contract_clause_check(&self, Parameters(input): Parameters<ClauseCheckInput>) -> String {
480        let contracts = self.store.contracts.lock().unwrap();
481        match contracts.get(&input.contract_id) {
482            Some(c) => {
483                let mut risks = Vec::new();
484                if c.auto_renew { risks.push(json!({"clause": "auto_renewal", "severity": "medium", "detail": "Contract auto-renews — set calendar reminder before end date", "end_date": c.end_date})); }
485                if c.value > 100000.0 { risks.push(json!({"clause": "high_value", "severity": "high", "detail": "High-value contract — ensure adequate insurance and performance bonds"})); }
486                if c.terms.to_lowercase().contains("unlimited liability") { risks.push(json!({"clause": "unlimited_liability", "severity": "critical", "detail": "Unlimited liability clause detected — negotiate cap"})); }
487                if c.terms.to_lowercase().contains("ip assignment") || c.terms.to_lowercase().contains("intellectual property") { risks.push(json!({"clause": "ip_assignment", "severity": "high", "detail": "IP assignment clause — review with legal"})); }
488                if c.terms.to_lowercase().contains("non-compete") { risks.push(json!({"clause": "non_compete", "severity": "medium", "detail": "Non-compete restriction — may limit future sourcing"})); }
489                if c.terms.to_lowercase().contains("penalty") || c.terms.to_lowercase().contains("liquidated damages") { risks.push(json!({"clause": "penalty", "severity": "medium", "detail": "Penalty/liquidated damages clause present"})); }
490                let overall = if risks.iter().any(|r| r["severity"] == "critical") { "critical" } else if risks.iter().any(|r| r["severity"] == "high") { "high" } else if risks.is_empty() { "low" } else { "medium" };
491                json!({"contract_id": input.contract_id, "title": c.title, "risk_level": overall, "risks_found": risks.len(), "risks": risks}).to_string()
492            }
493            None => json!({"error": "CONTRACT_NOT_FOUND"}).to_string(),
494        }
495    }
496
497    #[tool(description = "Forecast procurement demand based on historical PO patterns. Predicts future spend by category.")]
498    async fn demand_forecast(&self, Parameters(input): Parameters<ForecastInput>) -> String {
499        let pos = self.store.purchase_orders.lock().unwrap();
500        let months = input.months_ahead.unwrap_or(3);
501        let all_pos: Vec<_> = pos.values().filter(|po| po.status != "cancelled" && input.category.as_ref().map_or(true, |_c| true)).collect();
502        let monthly_spend = if !all_pos.is_empty() { all_pos.iter().map(|po| po.total).sum::<f64>() / all_pos.len().max(1) as f64 } else { 0.0 };
503        let forecast: Vec<Value> = (1..=months).map(|m| {
504            let growth = 1.0 + (m as f64 * 0.02); // 2% monthly growth assumption
505            json!({"month": m, "predicted_spend": round2(monthly_spend * growth), "confidence": if m <= 1 { "high" } else if m <= 3 { "medium" } else { "low" }})
506        }).collect();
507        json!({"category": input.category, "historical_avg_per_po": round2(monthly_spend), "total_historical_pos": all_pos.len(), "forecast_months": months, "forecast": forecast}).to_string()
508    }
509
510    #[tool(description = "Identify savings opportunities: same items bought from multiple suppliers at different prices, volume consolidation potential.")]
511    async fn savings_opportunity(&self) -> String {
512        let pos = self.store.purchase_orders.lock().unwrap();
513        let mut sku_prices: std::collections::HashMap<String, Vec<(String, f64)>> = std::collections::HashMap::new();
514        for po in pos.values().filter(|po| po.status != "cancelled") {
515            for line in &po.lines {
516                sku_prices.entry(line.sku.clone()).or_default().push((po.supplier_id.clone(), line.unit_price));
517            }
518        }
519        let mut opportunities = Vec::new();
520        for (sku, prices) in &sku_prices {
521            if prices.len() >= 2 {
522                let min_price = prices.iter().map(|(_, p)| *p).fold(f64::INFINITY, f64::min);
523                let max_price = prices.iter().map(|(_, p)| *p).fold(f64::NEG_INFINITY, f64::max);
524                if max_price > min_price * 1.1 { // >10% price variance
525                    let savings = round2(max_price - min_price);
526                    let best_supplier = prices.iter().min_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|(s, _)| s.clone()).unwrap_or_default();
527                    opportunities.push(json!({"sku": sku, "price_variance_pct": round2((max_price - min_price) / min_price * 100.0), "lowest_price": min_price, "highest_price": max_price, "potential_savings_per_unit": savings, "best_supplier": best_supplier, "suppliers_count": prices.len()}));
528                }
529            }
530        }
531        opportunities.sort_by(|a, b| b["potential_savings_per_unit"].as_f64().unwrap_or(0.0).partial_cmp(&a["potential_savings_per_unit"].as_f64().unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));
532        json!({"opportunities": opportunities.len(), "items": &opportunities[..opportunities.len().min(20)]}).to_string()
533    }
534
535    #[tool(description = "Generate supplier performance scorecard: on-time delivery %, quality (rejection rate), price competitiveness, order history.")]
536    async fn supplier_scorecard(&self, Parameters(input): Parameters<ScorecardInput>) -> String {
537        let suppliers = self.store.suppliers.lock().unwrap();
538        let sup = match suppliers.get(&input.supplier_id) { Some(s) => s.clone(), None => return json!({"error": "SUPPLIER_NOT_FOUND"}).to_string() };
539        drop(suppliers);
540        let pos = self.store.purchase_orders.lock().unwrap();
541        let supplier_pos: Vec<_> = pos.values().filter(|po| po.supplier_id == input.supplier_id).cloned().collect();
542        drop(pos);
543        let total_orders = supplier_pos.len();
544        let completed = supplier_pos.iter().filter(|po| po.status == "received").count();
545        let cancelled = supplier_pos.iter().filter(|po| po.status == "cancelled").count();
546        let total_spend: f64 = supplier_pos.iter().map(|po| po.total).sum();
547        let receipts = self.store.receipts.lock().unwrap();
548        let supplier_receipts: Vec<_> = receipts.iter().filter(|r| supplier_pos.iter().any(|po| po.id == r.po_id)).collect();
549        let total_received: f64 = supplier_receipts.iter().flat_map(|r| r.lines.iter()).map(|l| l.quantity_received).sum();
550        let total_rejected: f64 = supplier_receipts.iter().flat_map(|r| r.lines.iter()).map(|l| l.quantity_rejected).sum();
551        let quality_pct = if total_received > 0.0 { round2((1.0 - total_rejected / (total_received + total_rejected)) * 100.0) } else { 100.0 };
552        let delivery_pct = if total_orders > 0 { round2(completed as f64 / total_orders as f64 * 100.0) } else { 0.0 };
553        let overall = round2((delivery_pct + quality_pct + sup.rating * 20.0) / 3.0);
554        json!({"supplier_id": input.supplier_id, "name": sup.name, "overall_score": overall, "delivery_performance_pct": delivery_pct, "quality_pct": quality_pct, "rating": sup.rating, "total_orders": total_orders, "completed": completed, "cancelled": cancelled, "total_spend": round2(total_spend), "grade": if overall >= 90.0 { "A" } else if overall >= 75.0 { "B" } else if overall >= 60.0 { "C" } else { "D" }}).to_string()
555    }
556
557    #[tool(description = "Detect maverick spend: purchases outside contracted suppliers or above contracted prices (policy violations).")]
558    async fn maverick_spend_detect(&self) -> String {
559        let pos = self.store.purchase_orders.lock().unwrap();
560        let contracts = self.store.contracts.lock().unwrap();
561        let contracted_suppliers: Vec<String> = contracts.values().filter(|c| c.status == "active").map(|c| c.supplier_id.clone()).collect();
562        let mut violations = Vec::new();
563        for po in pos.values().filter(|po| po.status != "cancelled" && po.status != "draft") {
564            if !contracted_suppliers.contains(&po.supplier_id) {
565                violations.push(json!({"type": "uncontracted_supplier", "po_id": po.id, "supplier_id": po.supplier_id, "amount": po.total, "severity": "medium"}));
566            }
567        }
568        let total_spend: f64 = pos.values().filter(|po| po.status != "cancelled").map(|po| po.total).sum();
569        let maverick_spend: f64 = violations.iter().filter_map(|v| v["amount"].as_f64()).sum();
570        let maverick_pct = if total_spend > 0.0 { round2(maverick_spend / total_spend * 100.0) } else { 0.0 };
571        json!({"violations": violations.len(), "maverick_spend": round2(maverick_spend), "total_spend": round2(total_spend), "maverick_pct": maverick_pct, "target": "< 5%", "details": &violations[..violations.len().min(20)]}).to_string()
572    }
573
574    #[tool(description = "Generate negotiation brief: supplier's position, our leverage, market alternatives, BATNA, recommended strategy.")]
575    async fn negotiation_brief(&self, Parameters(input): Parameters<NegotiationInput>) -> String {
576        let suppliers = self.store.suppliers.lock().unwrap();
577        let sup = match suppliers.get(&input.supplier_id) { Some(s) => s.clone(), None => return json!({"error": "SUPPLIER_NOT_FOUND"}).to_string() };
578        drop(suppliers);
579        let pos = self.store.purchase_orders.lock().unwrap();
580        let our_spend: f64 = pos.values().filter(|po| po.supplier_id == input.supplier_id && po.status != "cancelled").map(|po| po.total).sum();
581        let order_count = pos.values().filter(|po| po.supplier_id == input.supplier_id).count();
582        drop(pos);
583        let alternatives = self.store.suppliers.lock().unwrap().values().filter(|s| s.category == sup.category && s.id != input.supplier_id && s.status == "active").count();
584        let target_discount = input.target_discount_pct.unwrap_or(10.0);
585        let leverage = if our_spend > 100000.0 { "strong" } else if our_spend > 10000.0 { "moderate" } else { "weak" };
586        let strategy = if alternatives > 3 && leverage != "weak" { "competitive_pressure" } else if order_count > 10 { "loyalty_based" } else { "value_proposition" };
587        json!({
588            "supplier": sup.name, "category": sup.category,
589            "our_position": {"total_spend": round2(our_spend), "order_count": order_count, "leverage": leverage},
590            "market_position": {"alternative_suppliers": alternatives, "supplier_rating": sup.rating},
591            "negotiation_strategy": strategy,
592            "target_discount_pct": target_discount,
593            "potential_savings": round2(our_spend * target_discount / 100.0),
594            "talking_points": [
595                format!("We've spent {} {} with you over {} orders", sup.currency, round2(our_spend), order_count),
596                format!("We have {} alternative suppliers in this category", alternatives),
597                if sup.rating >= 4.0 { String::from("Your quality is excellent — we want to grow the relationship") } else { String::from("Quality issues have increased our costs — we need a price adjustment") },
598                format!("Target: {}% reduction on {}", target_discount, input.items.join(", "))
599            ],
600            "batna": format!("Switch to alternative supplier ({}+ available)", alternatives)
601        }).to_string()
602    }
603}