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 #[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 #[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); 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 #[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) { 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 #[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 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 #[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 #[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; let status = if qty_match && amount_match { "full_match" } else if tolerance_match { "within_tolerance" } else { "discrepancy" };
297 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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); 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); 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 { 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}