Spaces:
Sleeping
Sleeping
MakPr016 commited on
Commit ·
0fc22ba
0
Parent(s):
Updated dates
Browse files- .gitattributes +35 -0
- .gitignore +1 -0
- Dockerfile +16 -0
- README.md +172 -0
- __pycache__/models.cpython-310.pyc +0 -0
- __pycache__/optimizer.cpython-310.pyc +0 -0
- main.py +95 -0
- models.py +44 -0
- optimizer.py +127 -0
- requirements.txt +7 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9
|
| 2 |
+
|
| 3 |
+
WORKDIR /code
|
| 4 |
+
|
| 5 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 6 |
+
|
| 7 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 8 |
+
|
| 9 |
+
COPY . /code
|
| 10 |
+
|
| 11 |
+
RUN useradd -m -u 1000 user
|
| 12 |
+
USER user
|
| 13 |
+
ENV HOME=/home/user \
|
| 14 |
+
PATH=/home/user/.local/bin:$PATH
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Vendor Analysis API
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Vendor Selection & Analysis API
|
| 12 |
+
|
| 13 |
+
A high-performance FastAPI service designed to analyze vendor quotations for medical supply RFQs. It uses combinatorial optimization algorithms to recommend the best vendor allocation strategies (Lowest Cost, Fastest Delivery, Best Quality, and Balanced).
|
| 14 |
+
|
| 15 |
+
This API is built to be deployed on **Hugging Face Spaces** using Docker and connects to a **Supabase** backend.
|
| 16 |
+
|
| 17 |
+
## 🚀 Features
|
| 18 |
+
|
| 19 |
+
* **Multi-Strategy Optimization**: automatically generates four distinct procurement strategies:
|
| 20 |
+
* **Lowest Cost**: Minimizes total spend.
|
| 21 |
+
* **Fastest Delivery**: Prioritizes lead time.
|
| 22 |
+
* **Best Quality**: Maximizes vendor rating scores.
|
| 23 |
+
* **Balanced**: A weighted mix of cost, speed, and quality.
|
| 24 |
+
* **Dynamic Bundle Generation**: Groups items into logical vendor bundles to streamline purchasing.
|
| 25 |
+
* **Supabase Integration**: Fetches real-time RFQ and Quotation data directly from your database.
|
| 26 |
+
* **Dockerized**: Ready for deployment on any container platform (Hugging Face, Railway, AWS).
|
| 27 |
+
|
| 28 |
+
## 🛠️ Tech Stack
|
| 29 |
+
|
| 30 |
+
* **Framework**: FastAPI (Python 3.9+)
|
| 31 |
+
* **Database**: Supabase (PostgreSQL)
|
| 32 |
+
* **Data Processing**: Pandas, NumPy
|
| 33 |
+
* **Server**: Uvicorn
|
| 34 |
+
* **Deployment**: Docker
|
| 35 |
+
|
| 36 |
+
## 📂 Project Structure
|
| 37 |
+
|
| 38 |
+
```text
|
| 39 |
+
.
|
| 40 |
+
├── main.py # API Entry point & Routes
|
| 41 |
+
├── models.py # Pydantic models & Data structures
|
| 42 |
+
├── optimizer.py # Core logic for vendor selection algorithms
|
| 43 |
+
├── requirements.txt # Python dependencies
|
| 44 |
+
├── Dockerfile # Docker configuration for Hugging Face
|
| 45 |
+
└── README.md # Documentation
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## ⚡ Local Development
|
| 49 |
+
|
| 50 |
+
### 1. Clone the repository
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
git clone [https://github.com/your-username/vendor-analysis-api.git](https://github.com/your-username/vendor-analysis-api.git)
|
| 54 |
+
cd vendor-analysis-api
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
### 2. Create a Virtual Environment
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
python -m venv venv
|
| 61 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### 3. Install Dependencies
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
pip install -r requirements.txt
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### 4. Environment Configuration
|
| 72 |
+
|
| 73 |
+
Create a `.env` file in the root directory with your Supabase credentials:
|
| 74 |
+
|
| 75 |
+
```env
|
| 76 |
+
SUPABASE_URL=your_supabase_url
|
| 77 |
+
SUPABASE_SERVICE_KEY=your_supabase_service_key
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### 5. Run the Server
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
uvicorn main:app --reload --port 8000
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
The API will be available at `http://localhost:8000`.
|
| 87 |
+
|
| 88 |
+
## 🚀 API Endpoints
|
| 89 |
+
|
| 90 |
+
### Analyze RFQ
|
| 91 |
+
|
| 92 |
+
**GET** `/api/analyze/{rfq_id}`
|
| 93 |
+
|
| 94 |
+
Analyzes a specific RFQ and returns optimal vendor allocation strategies.
|
| 95 |
+
|
| 96 |
+
**Parameters:**
|
| 97 |
+
|
| 98 |
+
* `rfq_id` (path): The ID of the RFQ to analyze.
|
| 99 |
+
|
| 100 |
+
**Response:**
|
| 101 |
+
|
| 102 |
+
```json
|
| 103 |
+
{
|
| 104 |
+
"rfq_id": "rfq-123",
|
| 105 |
+
"recommended_strategy": "balanced",
|
| 106 |
+
"strategies": {
|
| 107 |
+
"lowest_cost": {
|
| 108 |
+
"total_cost": 1250.00,
|
| 109 |
+
"total_delivery_days": 15,
|
| 110 |
+
"savings": 250.00,
|
| 111 |
+
"bundles": [
|
| 112 |
+
{
|
| 113 |
+
"vendor_id": "vendor-abc",
|
| 114 |
+
"vendor_name": "MediCorp Supplies",
|
| 115 |
+
"items": ["item-001", "item-003"],
|
| 116 |
+
"cost": 800.00,
|
| 117 |
+
"delivery_days": 10
|
| 118 |
+
}
|
| 119 |
+
]
|
| 120 |
+
},
|
| 121 |
+
"fastest_delivery": {
|
| 122 |
+
"total_cost": 1300.00,
|
| 123 |
+
"total_delivery_days": 8,
|
| 124 |
+
"savings": 200.00,
|
| 125 |
+
"bundles": [
|
| 126 |
+
{
|
| 127 |
+
"vendor_id": "vendor-xyz",
|
| 128 |
+
"vendor_name": "QuickMed Inc",
|
| 129 |
+
"items": ["item-001", "item-002"],
|
| 130 |
+
"cost": 1300.00,
|
| 131 |
+
"delivery_days": 8
|
| 132 |
+
}
|
| 133 |
+
]
|
| 134 |
+
},
|
| 135 |
+
"best_quality": {
|
| 136 |
+
"total_cost": 1400.00,
|
| 137 |
+
"total_delivery_days": 12,
|
| 138 |
+
"savings": 100.00,
|
| 139 |
+
"bundles": [
|
| 140 |
+
{
|
| 141 |
+
"vendor_id": "vendor-def",
|
| 142 |
+
"vendor_name": "Premium Health",
|
| 143 |
+
"items": ["item-001", "item-002", "item-003"],
|
| 144 |
+
"cost": 1400.00,
|
| 145 |
+
"delivery_days": 12
|
| 146 |
+
}
|
| 147 |
+
]
|
| 148 |
+
},
|
| 149 |
+
"balanced": {
|
| 150 |
+
"total_cost": 1350.00,
|
| 151 |
+
"total_delivery_days": 10,
|
| 152 |
+
"savings": 150.00,
|
| 153 |
+
"bundles": [
|
| 154 |
+
{
|
| 155 |
+
"vendor_id": "vendor-abc",
|
| 156 |
+
"vendor_name": "MediCorp Supplies",
|
| 157 |
+
"items": ["item-001"],
|
| 158 |
+
"cost": 500.00,
|
| 159 |
+
"delivery_days": 10
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"vendor_id": "vendor-xyz",
|
| 163 |
+
"vendor_name": "QuickMed Inc",
|
| 164 |
+
"items": ["item-002", "item-003"],
|
| 165 |
+
"cost": 850.00,
|
| 166 |
+
"delivery_days": 10
|
| 167 |
+
}
|
| 168 |
+
]
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
```
|
__pycache__/models.cpython-310.pyc
ADDED
|
Binary file (1.99 kB). View file
|
|
|
__pycache__/optimizer.cpython-310.pyc
ADDED
|
Binary file (4.21 kB). View file
|
|
|
main.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uvicorn
|
| 3 |
+
from fastapi import FastAPI, HTTPException
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from supabase import create_client, Client
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from models import AnalysisResponse, RFQItem, VendorOffer, QuoteOffer
|
| 8 |
+
from optimizer import VendorOptimizer
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
url: str = os.environ.get("SUPABASE_URL")
|
| 13 |
+
key: str = os.environ.get("SUPABASE_SERVICE_KEY")
|
| 14 |
+
supabase: Client = create_client(url, key)
|
| 15 |
+
|
| 16 |
+
app = FastAPI()
|
| 17 |
+
|
| 18 |
+
app.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origins=["*"],
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
@app.get("/api/analyze/{rfq_id}", response_model=AnalysisResponse)
|
| 26 |
+
async def analyze_rfq(rfq_id: str):
|
| 27 |
+
items_res = supabase.table('rfq_line_items').select("*").eq('rfq_id', rfq_id).execute()
|
| 28 |
+
if not items_res.data:
|
| 29 |
+
raise HTTPException(status_code=404, detail="RFQ Items not found")
|
| 30 |
+
|
| 31 |
+
rfq_items = [
|
| 32 |
+
RFQItem(id=item['id'], inn_name=item['inn_name'], quantity=item['quantity'])
|
| 33 |
+
for item in items_res.data
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
quotes_res = supabase.table('quotations').select(
|
| 37 |
+
"id, vendor_id, vendors(vendor_name, rating, is_verified)"
|
| 38 |
+
).eq('rfq_id', rfq_id).execute()
|
| 39 |
+
|
| 40 |
+
quote_ids = [q['id'] for q in quotes_res.data]
|
| 41 |
+
if not quote_ids:
|
| 42 |
+
return AnalysisResponse(rfq_id=rfq_id, strategies={}, recommended_strategy="None")
|
| 43 |
+
|
| 44 |
+
q_items_res = supabase.table('quotation_items').select(
|
| 45 |
+
"rfq_item_id, unit_price, delivery_time_days, quotation_id, brand, manufacturer"
|
| 46 |
+
).in_('quotation_id', quote_ids).execute()
|
| 47 |
+
|
| 48 |
+
vendor_map = {}
|
| 49 |
+
|
| 50 |
+
for q in quotes_res.data:
|
| 51 |
+
v_id = q['vendor_id']
|
| 52 |
+
if v_id not in vendor_map:
|
| 53 |
+
vendor_data = q['vendors']
|
| 54 |
+
vendor_map[v_id] = VendorOffer(
|
| 55 |
+
vendor_id=v_id,
|
| 56 |
+
vendor_name=vendor_data.get('vendor_name', 'Unknown') or 'Unknown',
|
| 57 |
+
rating=float(vendor_data.get('rating', 0) or 0),
|
| 58 |
+
is_verified=vendor_data.get('is_verified', False),
|
| 59 |
+
offers=[]
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
q_to_v = {q['id']: q['vendor_id'] for q in quotes_res.data}
|
| 63 |
+
|
| 64 |
+
for qi in q_items_res.data:
|
| 65 |
+
q_id = qi['quotation_id']
|
| 66 |
+
if q_id in q_to_v:
|
| 67 |
+
v_id = q_to_v[q_id]
|
| 68 |
+
vendor_map[v_id].offers.append(QuoteOffer(
|
| 69 |
+
rfq_item_id=qi['rfq_item_id'],
|
| 70 |
+
unit_price=qi['unit_price'],
|
| 71 |
+
delivery_days=qi.get('delivery_time_days', 7),
|
| 72 |
+
brand=qi.get('brand'),
|
| 73 |
+
manufacturer=qi.get('manufacturer')
|
| 74 |
+
))
|
| 75 |
+
|
| 76 |
+
vendor_offers = list(vendor_map.values())
|
| 77 |
+
|
| 78 |
+
optimizer = VendorOptimizer(rfq_items, vendor_offers)
|
| 79 |
+
results = optimizer.run_all()
|
| 80 |
+
|
| 81 |
+
rec = "balanced"
|
| 82 |
+
if "lowest_cost" in results and "balanced" in results:
|
| 83 |
+
if results['lowest_cost'].savings > results['balanced'].savings * 1.5:
|
| 84 |
+
rec = "lowest_cost"
|
| 85 |
+
elif results['fastest_delivery'].avg_delivery_days < results['balanced'].avg_delivery_days - 2:
|
| 86 |
+
rec = "fastest_delivery"
|
| 87 |
+
|
| 88 |
+
return AnalysisResponse(
|
| 89 |
+
rfq_id=rfq_id,
|
| 90 |
+
strategies=results,
|
| 91 |
+
recommended_strategy=rec
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
models.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional, Dict
|
| 3 |
+
|
| 4 |
+
class SelectedVendor(BaseModel):
|
| 5 |
+
vendor_id: str
|
| 6 |
+
vendor_name: str
|
| 7 |
+
total_cost: float
|
| 8 |
+
items_count: int
|
| 9 |
+
item_ids: List[str]
|
| 10 |
+
avg_quality_score: float
|
| 11 |
+
|
| 12 |
+
class StrategyResult(BaseModel):
|
| 13 |
+
strategy_name: str
|
| 14 |
+
total_cost: float
|
| 15 |
+
avg_delivery_days: float
|
| 16 |
+
vendor_count: int
|
| 17 |
+
savings: float
|
| 18 |
+
score: float
|
| 19 |
+
quality_score: float
|
| 20 |
+
allocations: List[SelectedVendor]
|
| 21 |
+
|
| 22 |
+
class AnalysisResponse(BaseModel):
|
| 23 |
+
rfq_id: str
|
| 24 |
+
strategies: Dict[str, StrategyResult]
|
| 25 |
+
recommended_strategy: str
|
| 26 |
+
|
| 27 |
+
class RFQItem(BaseModel):
|
| 28 |
+
id: str
|
| 29 |
+
inn_name: str
|
| 30 |
+
quantity: int
|
| 31 |
+
|
| 32 |
+
class QuoteOffer(BaseModel):
|
| 33 |
+
rfq_item_id: str
|
| 34 |
+
unit_price: float
|
| 35 |
+
delivery_days: int
|
| 36 |
+
brand: Optional[str] = None
|
| 37 |
+
manufacturer: Optional[str] = None
|
| 38 |
+
|
| 39 |
+
class VendorOffer(BaseModel):
|
| 40 |
+
vendor_id: str
|
| 41 |
+
vendor_name: str
|
| 42 |
+
rating: float
|
| 43 |
+
is_verified: bool
|
| 44 |
+
offers: List[QuoteOffer]
|
optimizer.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict
|
| 2 |
+
from models import StrategyResult, SelectedVendor, RFQItem, VendorOffer
|
| 3 |
+
|
| 4 |
+
class VendorOptimizer:
|
| 5 |
+
def __init__(self, rfq_items: List[RFQItem], vendor_offers: List[VendorOffer]):
|
| 6 |
+
self.rfq_items = {item.id: item for item in rfq_items}
|
| 7 |
+
self.vendors = vendor_offers
|
| 8 |
+
|
| 9 |
+
def _calculate_item_quality(self, offer: dict) -> float:
|
| 10 |
+
score = 5.0
|
| 11 |
+
trust_bonus = (offer['rating'] / 5.0) * 5.0
|
| 12 |
+
score += trust_bonus
|
| 13 |
+
return min(round(score, 1), 10.0)
|
| 14 |
+
|
| 15 |
+
def _calculate_strategy(self, strategy_name: str) -> StrategyResult:
|
| 16 |
+
item_offers = {i_id: [] for i_id in self.rfq_items}
|
| 17 |
+
|
| 18 |
+
for vendor in self.vendors:
|
| 19 |
+
for offer in vendor.offers:
|
| 20 |
+
if offer.rfq_item_id in item_offers:
|
| 21 |
+
offer_data = {
|
| 22 |
+
"vendor_id": vendor.vendor_id,
|
| 23 |
+
"vendor_name": vendor.vendor_name,
|
| 24 |
+
"price": offer.unit_price,
|
| 25 |
+
"delivery": offer.delivery_days,
|
| 26 |
+
"rating": vendor.rating,
|
| 27 |
+
"brand": offer.brand,
|
| 28 |
+
"manufacturer": offer.manufacturer
|
| 29 |
+
}
|
| 30 |
+
offer_data['quality_score'] = self._calculate_item_quality(offer_data)
|
| 31 |
+
item_offers[offer.rfq_item_id].append(offer_data)
|
| 32 |
+
|
| 33 |
+
selected_map = {}
|
| 34 |
+
total_rfq_cost = 0.0
|
| 35 |
+
total_delivery_days = 0
|
| 36 |
+
total_quality_accum = 0.0
|
| 37 |
+
fulfilled_count = 0
|
| 38 |
+
total_market_cost = 0.0
|
| 39 |
+
|
| 40 |
+
for item_id, item_req in self.rfq_items.items():
|
| 41 |
+
offers = item_offers.get(item_id, [])
|
| 42 |
+
if not offers:
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
max_price_for_item = max(o['price'] for o in offers)
|
| 46 |
+
total_market_cost += (max_price_for_item * item_req.quantity)
|
| 47 |
+
|
| 48 |
+
if strategy_name == "lowest_cost":
|
| 49 |
+
offers.sort(key=lambda x: (x['price'], -x['quality_score']))
|
| 50 |
+
|
| 51 |
+
elif strategy_name == "fastest_delivery":
|
| 52 |
+
offers.sort(key=lambda x: (x['delivery'], x['price']))
|
| 53 |
+
|
| 54 |
+
elif strategy_name == "best_quality":
|
| 55 |
+
offers.sort(key=lambda x: (-x['quality_score'], x['price']))
|
| 56 |
+
|
| 57 |
+
elif strategy_name == "balanced":
|
| 58 |
+
offers.sort(key=lambda x: (
|
| 59 |
+
x['price'] * 0.6 +
|
| 60 |
+
x['delivery'] * 2.0 +
|
| 61 |
+
(10.0 - x['quality_score']) * 15.0
|
| 62 |
+
))
|
| 63 |
+
|
| 64 |
+
best = offers[0]
|
| 65 |
+
vid = best['vendor_id']
|
| 66 |
+
|
| 67 |
+
if vid not in selected_map:
|
| 68 |
+
selected_map[vid] = {
|
| 69 |
+
"vendor_id": vid,
|
| 70 |
+
"vendor_name": best['vendor_name'],
|
| 71 |
+
"total_cost": 0.0,
|
| 72 |
+
"item_ids": [],
|
| 73 |
+
"quality_sum": 0.0
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
line_cost = best['price'] * item_req.quantity
|
| 77 |
+
selected_map[vid]["total_cost"] += line_cost
|
| 78 |
+
selected_map[vid]["item_ids"].append(item_id)
|
| 79 |
+
selected_map[vid]["quality_sum"] += best['quality_score']
|
| 80 |
+
|
| 81 |
+
total_rfq_cost += line_cost
|
| 82 |
+
total_delivery_days += best['delivery']
|
| 83 |
+
total_quality_accum += best['quality_score']
|
| 84 |
+
fulfilled_count += 1
|
| 85 |
+
|
| 86 |
+
allocations = []
|
| 87 |
+
for vid, data in selected_map.items():
|
| 88 |
+
v_avg_qual = data["quality_sum"] / len(data["item_ids"]) if data["item_ids"] else 0
|
| 89 |
+
|
| 90 |
+
allocations.append(SelectedVendor(
|
| 91 |
+
vendor_id=data['vendor_id'],
|
| 92 |
+
vendor_name=data['vendor_name'],
|
| 93 |
+
total_cost=round(data['total_cost'], 2),
|
| 94 |
+
items_count=len(data['item_ids']),
|
| 95 |
+
item_ids=data['item_ids'],
|
| 96 |
+
avg_quality_score=round(v_avg_qual, 1)
|
| 97 |
+
))
|
| 98 |
+
|
| 99 |
+
avg_delivery = round(total_delivery_days / fulfilled_count, 1) if fulfilled_count else 0
|
| 100 |
+
overall_quality = round(total_quality_accum / fulfilled_count, 1) if fulfilled_count else 0
|
| 101 |
+
savings = round(total_market_cost - total_rfq_cost, 2)
|
| 102 |
+
|
| 103 |
+
ui_score = 70.0
|
| 104 |
+
ui_score += (overall_quality - 5.0) * 6.0
|
| 105 |
+
|
| 106 |
+
if total_market_cost > 0:
|
| 107 |
+
savings_pct = (savings / total_market_cost)
|
| 108 |
+
ui_score += (savings_pct * 100)
|
| 109 |
+
|
| 110 |
+
return StrategyResult(
|
| 111 |
+
strategy_name=strategy_name,
|
| 112 |
+
total_cost=round(total_rfq_cost, 2),
|
| 113 |
+
avg_delivery_days=avg_delivery,
|
| 114 |
+
vendor_count=len(allocations),
|
| 115 |
+
allocations=allocations,
|
| 116 |
+
savings=savings,
|
| 117 |
+
score=max(min(round(ui_score, 1), 100), 40),
|
| 118 |
+
quality_score=overall_quality
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
def run_all(self):
|
| 122 |
+
return {
|
| 123 |
+
"lowest_cost": self._calculate_strategy("lowest_cost"),
|
| 124 |
+
"fastest_delivery": self._calculate_strategy("fastest_delivery"),
|
| 125 |
+
"best_quality": self._calculate_strategy("best_quality"),
|
| 126 |
+
"balanced": self._calculate_strategy("balanced"),
|
| 127 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
supabase
|
| 4 |
+
pandas
|
| 5 |
+
numpy
|
| 6 |
+
pydantic
|
| 7 |
+
python-dotenv
|