Γlvaro Valenzuela Valdes commited on
Commit Β·
5f2460b
1
Parent(s): 0d71eae
feat: implement Purchase Order (OC) monitoring and real-time market monitor UI
Browse files- backend/app/main.py +3 -1
- backend/app/models/oc.py +24 -0
- backend/app/routers/oc.py +30 -0
- backend/app/schemas/oc.py +31 -0
- backend/app/services/mercado_publico_oc.py +106 -0
- backend/scratch_test_oc.py +45 -0
- frontend/app/page.tsx +3 -0
- frontend/components/MarketMonitor.tsx +138 -0
- frontend/components/Sidebar.tsx +2 -0
- frontend/lib/api.ts +12 -0
- frontend/lib/types.ts +27 -0
backend/app/main.py
CHANGED
|
@@ -4,11 +4,12 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
-
from app.routers import analysis, company, health, tenders, documents
|
| 8 |
from app.database import engine, Base, SessionLocal
|
| 9 |
from app.models.tender import TenderModel
|
| 10 |
from app.models.analysis import AnalysisHistoryModel
|
| 11 |
from app.models.company import CompanyProfileModel
|
|
|
|
| 12 |
from app.config import settings
|
| 13 |
from datetime import datetime, timedelta
|
| 14 |
import json
|
|
@@ -32,6 +33,7 @@ app.include_router(tenders.router, prefix="/api", tags=["Tenders"])
|
|
| 32 |
app.include_router(analysis.router, prefix="/api", tags=["Analysis"])
|
| 33 |
app.include_router(company.router, prefix="/api", tags=["Company"])
|
| 34 |
app.include_router(documents.router, prefix="/api", tags=["Documents"])
|
|
|
|
| 35 |
|
| 36 |
@app.on_event("startup")
|
| 37 |
async def startup_event():
|
|
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from app.routers import analysis, company, health, tenders, documents, oc
|
| 8 |
from app.database import engine, Base, SessionLocal
|
| 9 |
from app.models.tender import TenderModel
|
| 10 |
from app.models.analysis import AnalysisHistoryModel
|
| 11 |
from app.models.company import CompanyProfileModel
|
| 12 |
+
from app.models.oc import OCModel
|
| 13 |
from app.config import settings
|
| 14 |
from datetime import datetime, timedelta
|
| 15 |
import json
|
|
|
|
| 33 |
app.include_router(analysis.router, prefix="/api", tags=["Analysis"])
|
| 34 |
app.include_router(company.router, prefix="/api", tags=["Company"])
|
| 35 |
app.include_router(documents.router, prefix="/api", tags=["Documents"])
|
| 36 |
+
app.include_router(oc.router, prefix="/api", tags=["Purchase Orders"])
|
| 37 |
|
| 38 |
@app.on_event("startup")
|
| 39 |
async def startup_event():
|
backend/app/models/oc.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Float, DateTime, Text, JSON
|
| 2 |
+
from app.database import Base
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class OCModel(Base):
|
| 6 |
+
__tablename__ = "purchase_orders"
|
| 7 |
+
|
| 8 |
+
code = Column(String(50), primary_key=True, index=True)
|
| 9 |
+
name = Column(String(255), index=True)
|
| 10 |
+
status = Column(String(100))
|
| 11 |
+
status_code = Column(String(10), nullable=True)
|
| 12 |
+
buyer = Column(String(255), index=True)
|
| 13 |
+
buyer_rut = Column(String(20), nullable=True)
|
| 14 |
+
provider = Column(String(255), index=True)
|
| 15 |
+
provider_rut = Column(String(20), nullable=True)
|
| 16 |
+
date_creation = Column(DateTime, nullable=True)
|
| 17 |
+
total_amount = Column(Float, nullable=True)
|
| 18 |
+
currency = Column(String(10), nullable=True)
|
| 19 |
+
type = Column(String(50), nullable=True)
|
| 20 |
+
|
| 21 |
+
items = Column(JSON, nullable=True)
|
| 22 |
+
raw_data = Column(JSON, nullable=True)
|
| 23 |
+
|
| 24 |
+
last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
backend/app/routers/oc.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
from fastapi import APIRouter, Query, Depends
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.schemas.oc import PurchaseOrder
|
| 5 |
+
from app.database import get_db
|
| 6 |
+
from app.models.oc import OCModel
|
| 7 |
+
from app.services.mercado_publico_oc import get_ocs_by_date, get_oc_by_code
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.get("/purchase-orders", response_model=List[PurchaseOrder])
|
| 12 |
+
async def list_purchase_orders(
|
| 13 |
+
date: Optional[str] = None,
|
| 14 |
+
status: str = "todos",
|
| 15 |
+
db: Session = Depends(get_db)
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
List purchase orders for a specific date (ddmmaaaa).
|
| 19 |
+
"""
|
| 20 |
+
if not date:
|
| 21 |
+
from datetime import datetime
|
| 22 |
+
date = datetime.now().strftime("%d%m%Y")
|
| 23 |
+
|
| 24 |
+
# Check if we have them in DB first?
|
| 25 |
+
# For now, let's just fetch from API as a real-time monitor
|
| 26 |
+
return await get_ocs_by_date(date, status)
|
| 27 |
+
|
| 28 |
+
@router.get("/purchase-orders/{code}", response_model=Optional[PurchaseOrder])
|
| 29 |
+
async def get_purchase_order(code: str):
|
| 30 |
+
return await get_oc_by_code(code)
|
backend/app/schemas/oc.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, ConfigDict
|
| 2 |
+
from typing import List, Optional, Union
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class OCItem(BaseModel):
|
| 6 |
+
correlative: Optional[int] = None
|
| 7 |
+
product_code: Optional[str] = None
|
| 8 |
+
name: str
|
| 9 |
+
description: Optional[str] = None
|
| 10 |
+
quantity: float
|
| 11 |
+
unit: str
|
| 12 |
+
price: Optional[float] = None
|
| 13 |
+
total: Optional[float] = None
|
| 14 |
+
|
| 15 |
+
class PurchaseOrder(BaseModel):
|
| 16 |
+
model_config = ConfigDict(from_attributes=True)
|
| 17 |
+
|
| 18 |
+
code: str
|
| 19 |
+
name: str
|
| 20 |
+
status: str
|
| 21 |
+
status_code: Optional[str] = None
|
| 22 |
+
buyer: str
|
| 23 |
+
buyer_rut: Optional[str] = None
|
| 24 |
+
provider: str
|
| 25 |
+
provider_rut: Optional[str] = None
|
| 26 |
+
date_creation: Union[str, datetime, None] = None
|
| 27 |
+
total_amount: Optional[float] = None
|
| 28 |
+
currency: Optional[str] = None
|
| 29 |
+
type: Optional[str] = None
|
| 30 |
+
items: List[OCItem] = []
|
| 31 |
+
raw_data: Optional[dict] = None
|
backend/app/services/mercado_publico_oc.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from typing import List, Optional, Dict, Any
|
| 3 |
+
from app.config import settings
|
| 4 |
+
from app.schemas.oc import PurchaseOrder, OCItem
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
API_BASE_OC = "https://api.mercadopublico.cl/servicios/v1/publico/ordenesdecompra.json"
|
| 8 |
+
|
| 9 |
+
OC_STATUS_CODES = {
|
| 10 |
+
"4": "Enviada a Proveedor",
|
| 11 |
+
"5": "En proceso",
|
| 12 |
+
"6": "Aceptada",
|
| 13 |
+
"9": "Cancelada",
|
| 14 |
+
"12": "RecepciΓ³n Conforme",
|
| 15 |
+
"13": "Pendiente de Recepcionar",
|
| 16 |
+
"14": "Recepcionada Parcialmente",
|
| 17 |
+
"15": "Recepcion Conforme Incompleta"
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
OC_TYPES = {
|
| 21 |
+
"1": "OC AutomΓ‘tica",
|
| 22 |
+
"2": "D1 - Proveedor Γnico",
|
| 23 |
+
"3": "C1 - Emergencia/Urgencia",
|
| 24 |
+
"4": "F3 - Confidencialidad",
|
| 25 |
+
"5": "G1 - Naturaleza de negociaciΓ³n",
|
| 26 |
+
"6": "R1 - Menor a 3UTM",
|
| 27 |
+
"7": "CA - Sin resoluciΓ³n",
|
| 28 |
+
"8": "SE - Sin emisiΓ³n automΓ‘tica",
|
| 29 |
+
"9": "CM - Convenio Marco",
|
| 30 |
+
"10": "FG - Trato Directo (Art. 8 f y g)",
|
| 31 |
+
"12": "MC - Microcompra",
|
| 32 |
+
"13": "AG - Compra Γgil",
|
| 33 |
+
"14": "CC - Compra Coordinada"
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
def map_raw_to_oc(item: Dict[str, Any]) -> PurchaseOrder:
|
| 37 |
+
# Handle items
|
| 38 |
+
items_list = []
|
| 39 |
+
raw_items = item.get("Items", {})
|
| 40 |
+
if isinstance(raw_items, dict) and "Listado" in raw_items:
|
| 41 |
+
for i in raw_items["Listado"]:
|
| 42 |
+
items_list.append(OCItem(
|
| 43 |
+
correlative=i.get("Correlativo"),
|
| 44 |
+
product_code=str(i.get("CodigoProducto", "")),
|
| 45 |
+
name=i.get("Nombre", ""),
|
| 46 |
+
description=i.get("EspecificacionComprador"),
|
| 47 |
+
quantity=float(i.get("Cantidad", 0)),
|
| 48 |
+
unit=i.get("Unidad"),
|
| 49 |
+
price=float(i.get("PrecioNeto", 0)),
|
| 50 |
+
total=float(i.get("TotalNeto", 0))
|
| 51 |
+
))
|
| 52 |
+
|
| 53 |
+
def parse_dt(dt_str):
|
| 54 |
+
if not dt_str: return None
|
| 55 |
+
try:
|
| 56 |
+
return datetime.fromisoformat(dt_str.replace("Z", "").split(".")[0])
|
| 57 |
+
except:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
return PurchaseOrder(
|
| 61 |
+
code=item.get("Codigo", ""),
|
| 62 |
+
name=item.get("Nombre", ""),
|
| 63 |
+
status=item.get("Estado", "Desconocido"),
|
| 64 |
+
status_code=str(item.get("CodigoEstado", "")),
|
| 65 |
+
buyer=item.get("Comprador", {}).get("NombreOrganismo", "Unknown"),
|
| 66 |
+
buyer_rut=item.get("Comprador", {}).get("RutUnidad"),
|
| 67 |
+
provider=item.get("Proveedor", {}).get("Nombre", "Unknown"),
|
| 68 |
+
provider_rut=item.get("Proveedor", {}).get("Rut", ""),
|
| 69 |
+
date_creation=parse_dt(item.get("Fechas", {}).get("FechaCreacion")),
|
| 70 |
+
total_amount=float(item.get("Total", 0)),
|
| 71 |
+
currency=item.get("Moneda"),
|
| 72 |
+
type=item.get("Tipo"),
|
| 73 |
+
items=items_list,
|
| 74 |
+
raw_data=item
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
async def _fetch_oc(params: Dict[str, str]) -> List[PurchaseOrder]:
|
| 78 |
+
if not settings.mercado_publico_ticket:
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
params["ticket"] = settings.mercado_publico_ticket
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 85 |
+
response = await client.get(API_BASE_OC, params=params)
|
| 86 |
+
response.raise_for_status()
|
| 87 |
+
data = response.json()
|
| 88 |
+
|
| 89 |
+
raw_list = data.get("Listado", [])
|
| 90 |
+
if not raw_list:
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
return [map_raw_to_oc(item) for item in raw_list]
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"β OC API Error: {e}")
|
| 96 |
+
return []
|
| 97 |
+
|
| 98 |
+
async def get_oc_by_code(code: str) -> Optional[PurchaseOrder]:
|
| 99 |
+
results = await _fetch_oc({"codigo": code})
|
| 100 |
+
return results[0] if results else None
|
| 101 |
+
|
| 102 |
+
async def get_ocs_by_date(date: str, status: str = "todos") -> List[PurchaseOrder]:
|
| 103 |
+
return await _fetch_oc({"fecha": date, "estado": status})
|
| 104 |
+
|
| 105 |
+
async def get_ocs_by_provider(provider_code: str, date: str) -> List[PurchaseOrder]:
|
| 106 |
+
return await _fetch_oc({"CodigoProveedor": provider_code, "fecha": date})
|
backend/scratch_test_oc.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
async def test_oc_api():
|
| 10 |
+
ticket = os.getenv("MERCADO_PUBLICO_TICKET")
|
| 11 |
+
if not ticket:
|
| 12 |
+
print("No ticket found in .env")
|
| 13 |
+
return
|
| 14 |
+
|
| 15 |
+
# 1. Fetch today's OCs
|
| 16 |
+
url_list = f"https://api.mercadopublico.cl/servicios/v1/publico/ordenesdecompra.json?ticket={ticket}"
|
| 17 |
+
print(f"Fetching OCs: {url_list}")
|
| 18 |
+
|
| 19 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 20 |
+
try:
|
| 21 |
+
resp = await client.get(url_list)
|
| 22 |
+
data = resp.json()
|
| 23 |
+
items = data.get("Listado", [])
|
| 24 |
+
print(f"Found {len(items)} OCs today.")
|
| 25 |
+
|
| 26 |
+
if items:
|
| 27 |
+
code = items[0].get("Codigo")
|
| 28 |
+
print(f"Fetching details for OC code: {code}")
|
| 29 |
+
|
| 30 |
+
url_detail = f"https://api.mercadopublico.cl/servicios/v1/publico/ordenesdecompra.json?codigo={code}&ticket={ticket}"
|
| 31 |
+
resp_detail = await client.get(url_detail)
|
| 32 |
+
detail_data = resp_detail.json()
|
| 33 |
+
|
| 34 |
+
print("OC Detail sample:")
|
| 35 |
+
# print(json.dumps(detail_data, indent=2))
|
| 36 |
+
|
| 37 |
+
with open("oc_sample_detail.json", "w") as f:
|
| 38 |
+
json.dump(detail_data, f, indent=2)
|
| 39 |
+
print("Saved to oc_sample_detail.json")
|
| 40 |
+
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"Error: {e}")
|
| 43 |
+
|
| 44 |
+
if __name__ == "__main__":
|
| 45 |
+
asyncio.run(test_oc_api())
|
frontend/app/page.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import Reports from "../components/Reports";
|
|
| 10 |
import Sidebar from "../components/Sidebar";
|
| 11 |
import AnalysisHistory from "../components/AnalysisHistory";
|
| 12 |
import GlobalSync from "../components/GlobalSync";
|
|
|
|
| 13 |
import SystemInfo from "../components/SystemInfo";
|
| 14 |
import { analyzeTender, fetchAnalysisHistory, fetchCompanyProfile, healthCheck, saveCompanyProfile, searchTenders } from "../lib/api";
|
| 15 |
import type { AnalysisHistoryItem, AnalysisResult, CompanyProfile as CompanyProfileType, Tender } from "../lib/types";
|
|
@@ -19,6 +20,7 @@ const tabs = [
|
|
| 19 |
"Dashboard",
|
| 20 |
"Tender Search",
|
| 21 |
"My Portfolio",
|
|
|
|
| 22 |
"Company Profile",
|
| 23 |
"Agent Analysis",
|
| 24 |
"Proposal Draft",
|
|
@@ -256,6 +258,7 @@ export default function HomePage() {
|
|
| 256 |
lang={lang}
|
| 257 |
/>
|
| 258 |
)}
|
|
|
|
| 259 |
{activeTab === "Company Profile" && (
|
| 260 |
<CompanyProfile profile={companyProfile} onSave={handleProfileSave} />
|
| 261 |
)}
|
|
|
|
| 10 |
import Sidebar from "../components/Sidebar";
|
| 11 |
import AnalysisHistory from "../components/AnalysisHistory";
|
| 12 |
import GlobalSync from "../components/GlobalSync";
|
| 13 |
+
import MarketMonitor from "../components/MarketMonitor";
|
| 14 |
import SystemInfo from "../components/SystemInfo";
|
| 15 |
import { analyzeTender, fetchAnalysisHistory, fetchCompanyProfile, healthCheck, saveCompanyProfile, searchTenders } from "../lib/api";
|
| 16 |
import type { AnalysisHistoryItem, AnalysisResult, CompanyProfile as CompanyProfileType, Tender } from "../lib/types";
|
|
|
|
| 20 |
"Dashboard",
|
| 21 |
"Tender Search",
|
| 22 |
"My Portfolio",
|
| 23 |
+
"Market Monitor",
|
| 24 |
"Company Profile",
|
| 25 |
"Agent Analysis",
|
| 26 |
"Proposal Draft",
|
|
|
|
| 258 |
lang={lang}
|
| 259 |
/>
|
| 260 |
)}
|
| 261 |
+
{activeTab === "Market Monitor" && <MarketMonitor />}
|
| 262 |
{activeTab === "Company Profile" && (
|
| 263 |
<CompanyProfile profile={companyProfile} onSave={handleProfileSave} />
|
| 264 |
)}
|
frontend/components/MarketMonitor.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { fetchPurchaseOrders } from "../lib/api";
|
| 5 |
+
import { PurchaseOrder } from "../lib/types";
|
| 6 |
+
import BrandLoader from "./BrandLoader";
|
| 7 |
+
|
| 8 |
+
export default function MarketMonitor() {
|
| 9 |
+
const [ocs, setOcs] = useState<PurchaseOrder[]>([]);
|
| 10 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 11 |
+
const [filter, setFilter] = useState("todos");
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
loadOcs();
|
| 15 |
+
}, [filter]);
|
| 16 |
+
|
| 17 |
+
async function loadOcs() {
|
| 18 |
+
setIsLoading(true);
|
| 19 |
+
try {
|
| 20 |
+
const data = await fetchPurchaseOrders(undefined, filter);
|
| 21 |
+
setOcs(data);
|
| 22 |
+
} catch (e) {
|
| 23 |
+
console.error(e);
|
| 24 |
+
} finally {
|
| 25 |
+
setIsLoading(false);
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const formatCurrency = (amount: number | null, currency: string | null) => {
|
| 30 |
+
if (amount === null) return "---";
|
| 31 |
+
return new Intl.NumberFormat("es-CL", {
|
| 32 |
+
style: "currency",
|
| 33 |
+
currency: currency || "CLP",
|
| 34 |
+
maximumFractionDigits: 0
|
| 35 |
+
}).format(amount);
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="space-y-8 animate-in fade-in duration-700">
|
| 40 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
| 41 |
+
<div>
|
| 42 |
+
<p className="text-[10px] uppercase tracking-[0.4em] text-cyan/60 font-black mb-2">Real-Time Intelligence</p>
|
| 43 |
+
<h2 className="text-4xl font-black text-white tracking-tight">Market Monitor</h2>
|
| 44 |
+
<p className="text-slate-400 text-sm mt-2 max-w-xl">
|
| 45 |
+
Monitor real-time Purchase Orders (OCs) issued by public organizations.
|
| 46 |
+
Identify high-velocity buying patterns and competitive wins.
|
| 47 |
+
</p>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div className="flex bg-slate-900/50 p-1 rounded-2xl border border-white/5 backdrop-blur-xl">
|
| 51 |
+
{["todos", "aceptada", "enviadaproveedor"].map((f) => (
|
| 52 |
+
<button
|
| 53 |
+
key={f}
|
| 54 |
+
onClick={() => setFilter(f)}
|
| 55 |
+
className={`px-6 py-2.5 rounded-xl text-[10px] uppercase font-black tracking-widest transition-all ${
|
| 56 |
+
filter === f ? "bg-cyan text-slate-950 shadow-lg shadow-cyan/20" : "text-slate-500 hover:text-white"
|
| 57 |
+
}`}
|
| 58 |
+
>
|
| 59 |
+
{f === "todos" ? "Live Stream" : f}
|
| 60 |
+
</button>
|
| 61 |
+
))}
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div className="grid gap-6">
|
| 66 |
+
{isLoading ? (
|
| 67 |
+
<div className="py-20">
|
| 68 |
+
<BrandLoader />
|
| 69 |
+
</div>
|
| 70 |
+
) : ocs.length > 0 ? (
|
| 71 |
+
<div className="glass-card rounded-[2rem] overflow-hidden border border-white/5">
|
| 72 |
+
<div className="overflow-x-auto custom-scrollbar">
|
| 73 |
+
<table className="w-full text-left text-xs border-collapse">
|
| 74 |
+
<thead>
|
| 75 |
+
<tr className="bg-white/5 text-slate-500 uppercase font-black tracking-tighter border-b border-white/5">
|
| 76 |
+
<th className="px-6 py-5">Issue Date</th>
|
| 77 |
+
<th className="px-6 py-5">Order ID / Description</th>
|
| 78 |
+
<th className="px-6 py-5">Buyer Organism</th>
|
| 79 |
+
<th className="px-6 py-5">Vendor (Winner)</th>
|
| 80 |
+
<th className="px-6 py-5 text-right">Total Amount</th>
|
| 81 |
+
</tr>
|
| 82 |
+
</thead>
|
| 83 |
+
<tbody className="divide-y divide-white/5">
|
| 84 |
+
{ocs.map((oc) => (
|
| 85 |
+
<tr key={oc.code} className="hover:bg-white/[0.03] transition-colors group">
|
| 86 |
+
<td className="px-6 py-5 whitespace-nowrap">
|
| 87 |
+
<div className="text-slate-300 font-mono">
|
| 88 |
+
{oc.date_creation ? new Date(oc.date_creation).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '---'}
|
| 89 |
+
</div>
|
| 90 |
+
<div className="text-[10px] text-slate-600">
|
| 91 |
+
{oc.date_creation ? new Date(oc.date_creation).toLocaleDateString() : 'Today'}
|
| 92 |
+
</div>
|
| 93 |
+
</td>
|
| 94 |
+
<td className="px-6 py-5 max-w-md">
|
| 95 |
+
<div className="flex items-center gap-2 mb-1">
|
| 96 |
+
<span className="text-cyan font-bold font-mono text-[10px] bg-cyan/5 px-2 py-0.5 rounded border border-cyan/10">
|
| 97 |
+
{oc.code}
|
| 98 |
+
</span>
|
| 99 |
+
{oc.type && (
|
| 100 |
+
<span className="text-[8px] bg-white/5 text-slate-500 px-1.5 py-0.5 rounded uppercase font-black">
|
| 101 |
+
{oc.type}
|
| 102 |
+
</span>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
<div className="text-white font-bold truncate group-hover:text-cyan transition-colors">
|
| 106 |
+
{oc.name || "Orden de Compra"}
|
| 107 |
+
</div>
|
| 108 |
+
</td>
|
| 109 |
+
<td className="px-6 py-5">
|
| 110 |
+
<div className="text-slate-300 font-medium truncate max-w-[200px]">{oc.buyer}</div>
|
| 111 |
+
<div className="text-[10px] text-slate-600">{oc.buyer_rut}</div>
|
| 112 |
+
</td>
|
| 113 |
+
<td className="px-6 py-5">
|
| 114 |
+
<div className="text-sky-400 font-bold truncate max-w-[200px]">{oc.provider}</div>
|
| 115 |
+
<div className="text-[10px] text-slate-600">{oc.provider_rut}</div>
|
| 116 |
+
</td>
|
| 117 |
+
<td className="px-6 py-5 text-right">
|
| 118 |
+
<div className="text-white font-black text-sm">
|
| 119 |
+
{formatCurrency(oc.total_amount, oc.currency)}
|
| 120 |
+
</div>
|
| 121 |
+
<div className="text-[10px] text-slate-500 uppercase font-bold">{oc.currency}</div>
|
| 122 |
+
</td>
|
| 123 |
+
</tr>
|
| 124 |
+
))}
|
| 125 |
+
</tbody>
|
| 126 |
+
</table>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
) : (
|
| 130 |
+
<div className="py-40 text-center glass-card rounded-[2rem] border border-white/5">
|
| 131 |
+
<div className="text-4xl mb-4">π</div>
|
| 132 |
+
<p className="text-slate-500 font-medium italic">No purchase orders detected in the last hour.</p>
|
| 133 |
+
</div>
|
| 134 |
+
)}
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
}
|
frontend/components/Sidebar.tsx
CHANGED
|
@@ -7,6 +7,7 @@ type SidebarTab =
|
|
| 7 |
| "Dashboard"
|
| 8 |
| "Tender Search"
|
| 9 |
| "My Portfolio"
|
|
|
|
| 10 |
| "Company Profile"
|
| 11 |
| "Agent Analysis"
|
| 12 |
| "Proposal Draft"
|
|
@@ -33,6 +34,7 @@ export default function Sidebar({ tabs, activeTab, onTabSelect, status, lang, fo
|
|
| 33 |
case "Dashboard": return { label: t.dashboard, icon: "π" };
|
| 34 |
case "Tender Search": return { label: t.tenderSearch, icon: "π‘" };
|
| 35 |
case "My Portfolio": return { label: t.myPortfolio, icon: "β
" };
|
|
|
|
| 36 |
case "Company Profile": return { label: t.companyProfile, icon: "π’" };
|
| 37 |
case "Agent Analysis": return { label: t.agentAnalysis, icon: "π€" };
|
| 38 |
case "Proposal Draft": return { label: t.proposalDraft, icon: "βοΈ" };
|
|
|
|
| 7 |
| "Dashboard"
|
| 8 |
| "Tender Search"
|
| 9 |
| "My Portfolio"
|
| 10 |
+
| "Market Monitor"
|
| 11 |
| "Company Profile"
|
| 12 |
| "Agent Analysis"
|
| 13 |
| "Proposal Draft"
|
|
|
|
| 34 |
case "Dashboard": return { label: t.dashboard, icon: "π" };
|
| 35 |
case "Tender Search": return { label: t.tenderSearch, icon: "π‘" };
|
| 36 |
case "My Portfolio": return { label: t.myPortfolio, icon: "β
" };
|
| 37 |
+
case "Market Monitor": return { label: "Market Monitor", icon: "π" };
|
| 38 |
case "Company Profile": return { label: t.companyProfile, icon: "π’" };
|
| 39 |
case "Agent Analysis": return { label: t.agentAnalysis, icon: "π€" };
|
| 40 |
case "Proposal Draft": return { label: t.proposalDraft, icon: "βοΈ" };
|
frontend/lib/api.ts
CHANGED
|
@@ -122,3 +122,15 @@ export async function scrapeTenders(keyword: string): Promise<Tender[]> {
|
|
| 122 |
}
|
| 123 |
return res.json();
|
| 124 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
return res.json();
|
| 124 |
}
|
| 125 |
+
|
| 126 |
+
export async function fetchPurchaseOrders(date?: string, status: string = "todos"): Promise<PurchaseOrder[]> {
|
| 127 |
+
const query = new URLSearchParams();
|
| 128 |
+
if (date) query.append("date", date);
|
| 129 |
+
query.append("status", status);
|
| 130 |
+
|
| 131 |
+
const res = await fetch(`${API_BASE}/api/purchase-orders?${query.toString()}`);
|
| 132 |
+
if (!res.ok) {
|
| 133 |
+
throw new Error("Error fetching purchase orders");
|
| 134 |
+
}
|
| 135 |
+
return res.json();
|
| 136 |
+
}
|
frontend/lib/types.ts
CHANGED
|
@@ -70,6 +70,33 @@ export type AnalysisResult = {
|
|
| 70 |
audit_log: string[];
|
| 71 |
};
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
export type AnalysisHistoryItem = {
|
| 74 |
tender_code: string;
|
| 75 |
tender_name: string;
|
|
|
|
| 70 |
audit_log: string[];
|
| 71 |
};
|
| 72 |
|
| 73 |
+
export type OCItem = {
|
| 74 |
+
correlative?: number;
|
| 75 |
+
product_code?: string;
|
| 76 |
+
name: string;
|
| 77 |
+
description?: string;
|
| 78 |
+
quantity: number;
|
| 79 |
+
unit: string;
|
| 80 |
+
price?: number;
|
| 81 |
+
total?: number;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
export type PurchaseOrder = {
|
| 85 |
+
code: string;
|
| 86 |
+
name: string;
|
| 87 |
+
status: string;
|
| 88 |
+
status_code?: string;
|
| 89 |
+
buyer: string;
|
| 90 |
+
buyer_rut?: string;
|
| 91 |
+
provider: string;
|
| 92 |
+
provider_rut?: string;
|
| 93 |
+
date_creation: string | null;
|
| 94 |
+
total_amount: number | null;
|
| 95 |
+
currency: string | null;
|
| 96 |
+
type?: string;
|
| 97 |
+
items?: OCItem[];
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
export type AnalysisHistoryItem = {
|
| 101 |
tender_code: string;
|
| 102 |
tender_name: string;
|