Álvaro Valenzuela Valdes commited on
Commit ·
0d71eae
1
Parent(s): 8a93890
feat: enhance AI agents with detailed Mercado Publico data and update frontend UI
Browse files- backend/api_sample_detail.json +108 -0
- backend/app/models/tender.py +5 -0
- backend/app/schemas/tender.py +12 -2
- backend/app/services/agents.py +12 -6
- backend/app/services/mercado_publico.py +163 -72
- backend/app/services/sync.py +18 -3
- backend/migrate_db.py +37 -0
- backend/scratch_test_api.py +38 -0
- frontend/components/TenderSearch.tsx +79 -12
- frontend/lib/types.ts +11 -2
backend/api_sample_detail.json
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Cantidad": 1,
|
| 3 |
+
"FechaCreacion": "2026-05-06T02:03:14.3264192Z",
|
| 4 |
+
"Version": "v1",
|
| 5 |
+
"Listado": [
|
| 6 |
+
{
|
| 7 |
+
"CodigoExterno": "1000-6-LE26",
|
| 8 |
+
"Nombre": "Sum. material p\u00e9treo Comunas Cabrero y Yumbel.",
|
| 9 |
+
"CodigoEstado": 5,
|
| 10 |
+
"Descripcion": "Para obras de recebo de carpetas granulares del programa de trabajo o para emergencias de caminos de las provincias de Arauco, Biob\u00edo y Concepci\u00f3n.",
|
| 11 |
+
"FechaCierre": null,
|
| 12 |
+
"Estado": "Publicada",
|
| 13 |
+
"Comprador": {
|
| 14 |
+
"CodigoOrganismo": "7248",
|
| 15 |
+
"NombreOrganismo": "MINISTERIO DE OBRAS PUBLICAS DIREC CION GRAL DE OO PP DCYF",
|
| 16 |
+
"RutUnidad": "61.202.000-0",
|
| 17 |
+
"CodigoUnidad": "1996",
|
| 18 |
+
"NombreUnidad": "Direcci\u00f3n de Vialidad - VIII Regi\u00f3n - Provincia Bio Bio",
|
| 19 |
+
"DireccionUnidad": "Avda. Ricardo Vicu\u00f1a N\u00ba 243, Los Angeles",
|
| 20 |
+
"ComunaUnidad": "Los Angeles",
|
| 21 |
+
"RegionUnidad": "Regi\u00f3n del Biob\u00edo ",
|
| 22 |
+
"RutUsuario": "",
|
| 23 |
+
"CodigoUsuario": "1406365",
|
| 24 |
+
"NombreUsuario": "Ghislaine Bruning Cereceda",
|
| 25 |
+
"CargoUsuario": "Analista de Conservaci\u00f3n"
|
| 26 |
+
},
|
| 27 |
+
"DiasCierreLicitacion": "3",
|
| 28 |
+
"Informada": 0,
|
| 29 |
+
"CodigoTipo": 1,
|
| 30 |
+
"Tipo": "LE",
|
| 31 |
+
"TipoConvocatoria": "1",
|
| 32 |
+
"Moneda": "CLP",
|
| 33 |
+
"Etapas": 1,
|
| 34 |
+
"EstadoEtapas": "0",
|
| 35 |
+
"TomaRazon": "0",
|
| 36 |
+
"EstadoPublicidadOfertas": 1,
|
| 37 |
+
"JustificacionPublicidad": "",
|
| 38 |
+
"Contrato": "2",
|
| 39 |
+
"Obras": "0",
|
| 40 |
+
"CantidadReclamos": 459,
|
| 41 |
+
"Fechas": {
|
| 42 |
+
"FechaCreacion": "2026-04-24T11:02:24.817",
|
| 43 |
+
"FechaCierre": "2026-05-08T15:10:00",
|
| 44 |
+
"FechaInicio": "2026-04-28T15:20:00",
|
| 45 |
+
"FechaFinal": "2026-05-05T18:00:00",
|
| 46 |
+
"FechaPubRespuestas": "2026-05-06T18:00:00",
|
| 47 |
+
"FechaActoAperturaTecnica": "2026-05-08T15:10:00",
|
| 48 |
+
"FechaActoAperturaEconomica": "2026-05-08T15:10:00",
|
| 49 |
+
"FechaPublicacion": "2026-04-28T14:16:06.67",
|
| 50 |
+
"FechaAdjudicacion": "2026-05-25T18:00:00",
|
| 51 |
+
"FechaEstimadaAdjudicacion": "2026-05-25T18:00:00",
|
| 52 |
+
"FechaSoporteFisico": null,
|
| 53 |
+
"FechaTiempoEvaluacion": null,
|
| 54 |
+
"FechaEstimadaFirma": null,
|
| 55 |
+
"FechasUsuario": null,
|
| 56 |
+
"FechaVisitaTerreno": null,
|
| 57 |
+
"FechaEntregaAntecedentes": null
|
| 58 |
+
},
|
| 59 |
+
"UnidadTiempoEvaluacion": 1,
|
| 60 |
+
"DireccionVisita": "",
|
| 61 |
+
"DireccionEntrega": "",
|
| 62 |
+
"Estimacion": 2,
|
| 63 |
+
"FuenteFinanciamiento": "Fondo sectorial",
|
| 64 |
+
"VisibilidadMonto": 0,
|
| 65 |
+
"MontoEstimado": null,
|
| 66 |
+
"Tiempo": "30",
|
| 67 |
+
"UnidadTiempo": "1",
|
| 68 |
+
"Modalidad": 1,
|
| 69 |
+
"TipoPago": "1",
|
| 70 |
+
"NombreResponsablePago": "MOP, Direcci\u00f3n de Vialidad",
|
| 71 |
+
"EmailResponsablePago": "",
|
| 72 |
+
"NombreResponsableContrato": "Se dar\u00e1 a conocer en la resoluci\u00f3n de adjudicaci\u00f3n",
|
| 73 |
+
"EmailResponsableContrato": "",
|
| 74 |
+
"FonoResponsableContrato": "",
|
| 75 |
+
"ProhibicionContratacion": "Por resguardo del inter\u00e9s fiscal.",
|
| 76 |
+
"SubContratacion": "0",
|
| 77 |
+
"UnidadTiempoDuracionContrato": 2,
|
| 78 |
+
"TiempoDuracionContrato": "30",
|
| 79 |
+
"TipoDuracionContrato": " ",
|
| 80 |
+
"JustificacionMontoEstimado": "",
|
| 81 |
+
"ObservacionContract": null,
|
| 82 |
+
"ExtensionPlazo": 0,
|
| 83 |
+
"EsBaseTipo": 0,
|
| 84 |
+
"UnidadTiempoContratoLicitacion": "2",
|
| 85 |
+
"ValorTiempoRenovacion": "0",
|
| 86 |
+
"PeriodoTiempoRenovacion": " ",
|
| 87 |
+
"EsRenovable": 0,
|
| 88 |
+
"CodigoBIP": null,
|
| 89 |
+
"Adjudicacion": null,
|
| 90 |
+
"Items": {
|
| 91 |
+
"Cantidad": 1,
|
| 92 |
+
"Listado": [
|
| 93 |
+
{
|
| 94 |
+
"Correlativo": 1,
|
| 95 |
+
"CodigoProducto": 11111611,
|
| 96 |
+
"CodigoCategoria": "11111600",
|
| 97 |
+
"Categoria": "Productos derivados de minerales, plantas y animales / Tierra y piedra / Piedras",
|
| 98 |
+
"NombreProducto": "Grava",
|
| 99 |
+
"Descripcion": "SUMINISTRO DE MATERIAL P\u00c9TREO PARA CAMINOS PERTENECIENTES A LAS COMUNAS DE YUMBEL Y CABRERO, PROVINCIA DE BIOB\u00cdO, REGI\u00d3N DEL BIOB\u00cdO.",
|
| 100 |
+
"UnidadMedida": "Metro C\u00fabico",
|
| 101 |
+
"Cantidad": 2000.0,
|
| 102 |
+
"Adjudicacion": null
|
| 103 |
+
}
|
| 104 |
+
]
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
]
|
| 108 |
+
}
|
backend/app/models/tender.py
CHANGED
|
@@ -9,11 +9,16 @@ class TenderModel(Base):
|
|
| 9 |
name = Column(String(255), index=True)
|
| 10 |
buyer = Column(String(255), index=True)
|
| 11 |
status = Column(String(100))
|
|
|
|
|
|
|
|
|
|
| 12 |
closing_date = Column(DateTime, nullable=True)
|
|
|
|
| 13 |
description = Column(Text)
|
| 14 |
estimated_amount = Column(Float, nullable=True)
|
| 15 |
source = Column(String(50), default="Mercado Publico")
|
| 16 |
region = Column(String(100), nullable=True)
|
|
|
|
| 17 |
sector = Column(String(100), nullable=True)
|
| 18 |
|
| 19 |
# Storage for nested structures as JSON for simplicity in this hackathon
|
|
|
|
| 9 |
name = Column(String(255), index=True)
|
| 10 |
buyer = Column(String(255), index=True)
|
| 11 |
status = Column(String(100))
|
| 12 |
+
status_code = Column(String(10), nullable=True)
|
| 13 |
+
type = Column(String(20), nullable=True)
|
| 14 |
+
currency = Column(String(10), nullable=True)
|
| 15 |
closing_date = Column(DateTime, nullable=True)
|
| 16 |
+
publication_date = Column(DateTime, nullable=True)
|
| 17 |
description = Column(Text)
|
| 18 |
estimated_amount = Column(Float, nullable=True)
|
| 19 |
source = Column(String(50), default="Mercado Publico")
|
| 20 |
region = Column(String(100), nullable=True)
|
| 21 |
+
buyer_region = Column(String(100), nullable=True)
|
| 22 |
sector = Column(String(100), nullable=True)
|
| 23 |
|
| 24 |
# Storage for nested structures as JSON for simplicity in this hackathon
|
backend/app/schemas/tender.py
CHANGED
|
@@ -3,7 +3,11 @@ from typing import List, Optional, Union
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
class TenderItem(BaseModel):
|
|
|
|
|
|
|
|
|
|
| 6 |
name: str
|
|
|
|
| 7 |
quantity: float
|
| 8 |
unit: str
|
| 9 |
|
|
@@ -16,13 +20,19 @@ class Tender(BaseModel):
|
|
| 16 |
|
| 17 |
code: str
|
| 18 |
name: str
|
|
|
|
| 19 |
buyer: str
|
|
|
|
| 20 |
status: str
|
|
|
|
|
|
|
|
|
|
| 21 |
closing_date: Union[str, datetime, None] = None
|
| 22 |
-
|
| 23 |
estimated_amount: Optional[float] = None
|
| 24 |
-
source: str
|
| 25 |
region: Optional[str] = None
|
| 26 |
sector: Optional[str] = None
|
| 27 |
items: List[TenderItem] = []
|
| 28 |
attachments: List[TenderAttachment] = []
|
|
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
class TenderItem(BaseModel):
|
| 6 |
+
correlative: Optional[int] = None
|
| 7 |
+
product_code: Optional[str] = None
|
| 8 |
+
category: Optional[str] = None
|
| 9 |
name: str
|
| 10 |
+
description: Optional[str] = None
|
| 11 |
quantity: float
|
| 12 |
unit: str
|
| 13 |
|
|
|
|
| 20 |
|
| 21 |
code: str
|
| 22 |
name: str
|
| 23 |
+
description: str
|
| 24 |
buyer: str
|
| 25 |
+
buyer_region: Optional[str] = None
|
| 26 |
status: str
|
| 27 |
+
status_code: Optional[int] = None
|
| 28 |
+
type: Optional[str] = None # L1, LE, LP, etc.
|
| 29 |
+
currency: Optional[str] = None # CLP, USD, etc.
|
| 30 |
closing_date: Union[str, datetime, None] = None
|
| 31 |
+
publication_date: Union[str, datetime, None] = None
|
| 32 |
estimated_amount: Optional[float] = None
|
| 33 |
+
source: str = "Mercado Público"
|
| 34 |
region: Optional[str] = None
|
| 35 |
sector: Optional[str] = None
|
| 36 |
items: List[TenderItem] = []
|
| 37 |
attachments: List[TenderAttachment] = []
|
| 38 |
+
raw_data: Optional[dict] = None # Store the full response if needed
|
backend/app/services/agents.py
CHANGED
|
@@ -9,23 +9,27 @@ from app.services.report import generate_markdown_report
|
|
| 9 |
|
| 10 |
async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
|
| 11 |
prompt = (
|
| 12 |
-
f"AGENT ROLE: Legal & Compliance Expert\n"
|
| 13 |
f"GOAL: Analyze administrative bases and compliance risks.\n"
|
| 14 |
-
f"TENDER: {tender.name}
|
|
|
|
|
|
|
| 15 |
f"COMPANY: {company.name} (Docs: {', '.join(company.documents_available)})\n"
|
| 16 |
f"EXTRACTED TEXT: {document_text[:5000]}\n"
|
| 17 |
-
f"TASK: Identify 3 legal gaps
|
| 18 |
)
|
| 19 |
return await asyncio.to_thread(call_gemini, prompt)
|
| 20 |
|
| 21 |
async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
|
|
|
|
| 22 |
prompt = (
|
| 23 |
f"AGENT ROLE: Technical Architect\n"
|
| 24 |
f"GOAL: Evaluate technical feasibility and product-market fit.\n"
|
| 25 |
f"TENDER: {tender.name} - {tender.description}\n"
|
|
|
|
| 26 |
f"COMPANY: {company.industry} - {company.experience}\n"
|
| 27 |
f"EXTRACTED TEXT: {document_text[:5000]}\n"
|
| 28 |
-
f"TASK: Identify 3 technical challenges
|
| 29 |
)
|
| 30 |
return await asyncio.to_thread(call_gemini, prompt)
|
| 31 |
|
|
@@ -33,9 +37,11 @@ async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_
|
|
| 33 |
prompt = (
|
| 34 |
f"AGENT ROLE: Risk & Strategy Specialist\n"
|
| 35 |
f"GOAL: Calculate ROI, competitive risks, and overall strategy.\n"
|
| 36 |
-
f"TENDER: {tender.name}
|
|
|
|
|
|
|
| 37 |
f"COMPANY: {company.name}\n"
|
| 38 |
-
f"TASK: Identify 3 strategic risks
|
| 39 |
)
|
| 40 |
return await asyncio.to_thread(call_gemini, prompt)
|
| 41 |
|
|
|
|
| 9 |
|
| 10 |
async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
|
| 11 |
prompt = (
|
| 12 |
+
f"AGENT ROLE: Legal & Compliance Expert (Chilean Public Procurement)\n"
|
| 13 |
f"GOAL: Analyze administrative bases and compliance risks.\n"
|
| 14 |
+
f"TENDER: {tender.name} (Type: {tender.type})\n"
|
| 15 |
+
f"STATUS: {tender.status} (Code: {tender.status_code})\n"
|
| 16 |
+
f"DATES: Published: {tender.publication_date}, Closing: {tender.closing_date}\n"
|
| 17 |
f"COMPANY: {company.name} (Docs: {', '.join(company.documents_available)})\n"
|
| 18 |
f"EXTRACTED TEXT: {document_text[:5000]}\n"
|
| 19 |
+
f"TASK: Identify 3 legal gaps. Analyze if the timeline is 'Express' (suspiciously short) for the tender type {tender.type}. Verify if company documents meet common requirements for this tender level."
|
| 20 |
)
|
| 21 |
return await asyncio.to_thread(call_gemini, prompt)
|
| 22 |
|
| 23 |
async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
|
| 24 |
+
items_summary = ", ".join([f"{i.name} ({i.quantity} {i.unit})" for i in (tender.items or [])[:10]])
|
| 25 |
prompt = (
|
| 26 |
f"AGENT ROLE: Technical Architect\n"
|
| 27 |
f"GOAL: Evaluate technical feasibility and product-market fit.\n"
|
| 28 |
f"TENDER: {tender.name} - {tender.description}\n"
|
| 29 |
+
f"LINE ITEMS: {items_summary}\n"
|
| 30 |
f"COMPANY: {company.industry} - {company.experience}\n"
|
| 31 |
f"EXTRACTED TEXT: {document_text[:5000]}\n"
|
| 32 |
+
f"TASK: Analyze specific LINE ITEMS against company capabilities. Identify 3 technical challenges. Is this a generic 'buy' or a complex project?"
|
| 33 |
)
|
| 34 |
return await asyncio.to_thread(call_gemini, prompt)
|
| 35 |
|
|
|
|
| 37 |
prompt = (
|
| 38 |
f"AGENT ROLE: Risk & Strategy Specialist\n"
|
| 39 |
f"GOAL: Calculate ROI, competitive risks, and overall strategy.\n"
|
| 40 |
+
f"TENDER: {tender.name}\n"
|
| 41 |
+
f"AMOUNT: {tender.estimated_amount} {tender.currency}\n"
|
| 42 |
+
f"DATES: Closing on {tender.closing_date}\n"
|
| 43 |
f"COMPANY: {company.name}\n"
|
| 44 |
+
f"TASK: Identify 3 strategic risks. Pay special attention to CURRENCY RISK if not CLP ({tender.currency}). Suggest a 'Win Strategy' based on the tender type {tender.type}."
|
| 45 |
)
|
| 46 |
return await asyncio.to_thread(call_gemini, prompt)
|
| 47 |
|
backend/app/services/mercado_publico.py
CHANGED
|
@@ -1,96 +1,187 @@
|
|
| 1 |
import httpx
|
| 2 |
-
from typing import List, Optional
|
| 3 |
from app.config import settings
|
| 4 |
-
from app.schemas.tender import Tender
|
| 5 |
from datetime import datetime
|
| 6 |
|
| 7 |
API_BASE = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
if not settings.mercado_publico_ticket:
|
| 14 |
print("⚠️ No Mercado Público Ticket configured.")
|
| 15 |
return []
|
| 16 |
|
| 17 |
-
|
| 18 |
-
search_date = date if date else datetime.now().strftime("%d%m%Y")
|
| 19 |
-
|
| 20 |
-
params = {
|
| 21 |
-
"ticket": settings.mercado_publico_ticket,
|
| 22 |
-
"fecha": search_date,
|
| 23 |
-
"estado": "activas"
|
| 24 |
-
}
|
| 25 |
|
| 26 |
try:
|
| 27 |
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 28 |
-
response = client.get(API_BASE, params=params)
|
| 29 |
response.raise_for_status()
|
| 30 |
data = response.json()
|
| 31 |
|
| 32 |
raw_list = data.get("Listado", [])
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
if keyword and keyword.lower() not in item.get("Nombre", "").lower():
|
| 38 |
-
continue
|
| 39 |
-
|
| 40 |
-
results.append(Tender(
|
| 41 |
-
code=item.get("CodigoExterno", ""),
|
| 42 |
-
name=item.get("Nombre", ""),
|
| 43 |
-
description=item.get("Descripcion", item.get("Nombre", "")),
|
| 44 |
-
buyer=item.get("Comprador", {}).get("NombreOrganismo", "Unknown"),
|
| 45 |
-
status=item.get("Estado", "Publicada"),
|
| 46 |
-
closing_date=item.get("FechaCierre", ""),
|
| 47 |
-
estimated_amount=float(item.get("MontoEstimado", 0)) if item.get("MontoEstimado") else None,
|
| 48 |
-
source="Mercado Público",
|
| 49 |
-
region="Nacional", # MP API provides this in detail view usually
|
| 50 |
-
sector="General",
|
| 51 |
-
items=[],
|
| 52 |
-
attachments=[]
|
| 53 |
-
))
|
| 54 |
-
return results
|
| 55 |
except Exception as e:
|
| 56 |
-
print(f"❌ API Error
|
| 57 |
return []
|
| 58 |
|
| 59 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
"""
|
| 61 |
-
Fetches
|
|
|
|
| 62 |
"""
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
return None
|
|
|
|
| 1 |
import httpx
|
| 2 |
+
from typing import List, Optional, Dict, Any
|
| 3 |
from app.config import settings
|
| 4 |
+
from app.schemas.tender import Tender, TenderItem
|
| 5 |
from datetime import datetime
|
| 6 |
|
| 7 |
API_BASE = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
|
| 8 |
|
| 9 |
+
# Constants from documentation
|
| 10 |
+
STATUS_CODES = {
|
| 11 |
+
"5": "Publicada",
|
| 12 |
+
"6": "Cerrada",
|
| 13 |
+
"7": "Desierta",
|
| 14 |
+
"8": "Adjudicada",
|
| 15 |
+
"18": "Revocada",
|
| 16 |
+
"19": "Suspendida"
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
TENDER_TYPES = {
|
| 20 |
+
"L1": "Licitación Pública Menor a 100 UTM",
|
| 21 |
+
"LE": "Licitación Pública Entre 100 y 1000 UTM",
|
| 22 |
+
"LP": "Licitación Pública Mayor 1000 UTM",
|
| 23 |
+
"LS": "Licitación Pública Servicios personales especializados",
|
| 24 |
+
"A1": "Licitación Privada por Licitación Pública anterior sin oferentes",
|
| 25 |
+
"B1": "Licitación Privada por otras causales, excluidas de la ley de Compras",
|
| 26 |
+
"J1": "Licitación Privada por Servicios de Naturaleza Confidencial",
|
| 27 |
+
"F1": "Licitación Privada por Convenios con Personas Jurídicas Extranjeras",
|
| 28 |
+
"E1": "Licitación Privada por Remanente de Contrato anterior",
|
| 29 |
+
"CO": "Licitación Privada entre 100 y 1000 UTM",
|
| 30 |
+
"B2": "Licitación Privada Mayor a 1000 UTM",
|
| 31 |
+
"A2": "Trato Directo por Producto de Licitación Privada anterior sin oferentes o desierta",
|
| 32 |
+
"D1": "Trato Directo por Proveedor Único",
|
| 33 |
+
"E2": "Licitación Privada Menor a 100 UTM",
|
| 34 |
+
"C2": "Trato Directo (Cotización)",
|
| 35 |
+
"C1": "Compra Directa (Orden de compra)",
|
| 36 |
+
"F2": "Trato Directo (Cotización)",
|
| 37 |
+
"F3": "Compra Directa (Orden de compra)",
|
| 38 |
+
"G2": "Directo (Cotización)",
|
| 39 |
+
"G1": "Compra Directa (Orden de compra)",
|
| 40 |
+
"R1": "Orden de Compra menor a 3 UTM",
|
| 41 |
+
"CA": "Orden de Compra sin Resolución",
|
| 42 |
+
"SE": "Orden de Compra proveniente de adquisición sin emisión automática de OC"
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
CURRENCIES = {
|
| 46 |
+
"CLP": "Peso Chileno",
|
| 47 |
+
"CLF": "Unidad de Fomento",
|
| 48 |
+
"USD": "Dólar Americano",
|
| 49 |
+
"UTM": "Unidad Tributaria Mensual",
|
| 50 |
+
"EUR": "Euro"
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
PAYMENT_MODALITIES = {
|
| 54 |
+
"1": "Pago a 30 días",
|
| 55 |
+
"2": "Pago a 30, 60 y 90 días",
|
| 56 |
+
"3": "Pago al día",
|
| 57 |
+
"4": "Pago Anual",
|
| 58 |
+
"5": "Pago a 60 días",
|
| 59 |
+
"6": "Pagos Mensuales",
|
| 60 |
+
"7": "Pago Contra Entrega Conforme",
|
| 61 |
+
"8": "Pago Bimensual",
|
| 62 |
+
"9": "Pago Por Estado de Avance",
|
| 63 |
+
"10": "Pago Trimestral"
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
TIME_UNITS = {
|
| 67 |
+
"1": "Horas",
|
| 68 |
+
"2": "Días",
|
| 69 |
+
"3": "Semanas",
|
| 70 |
+
"4": "Meses",
|
| 71 |
+
"5": "Años"
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
|
| 75 |
+
"""Maps raw API item to Tender schema."""
|
| 76 |
+
# Handle Items
|
| 77 |
+
items_list = []
|
| 78 |
+
raw_items = item.get("Items", {})
|
| 79 |
+
if isinstance(raw_items, dict) and "Listado" in raw_items:
|
| 80 |
+
for i in raw_items["Listado"]:
|
| 81 |
+
items_list.append(TenderItem(
|
| 82 |
+
correlative=i.get("Correlativo"),
|
| 83 |
+
product_code=str(i.get("CodigoProducto", "")),
|
| 84 |
+
category=i.get("Categoria"),
|
| 85 |
+
name=i.get("NombreProducto", ""),
|
| 86 |
+
description=i.get("Descripcion"),
|
| 87 |
+
quantity=float(i.get("Cantidad", 0)),
|
| 88 |
+
unit=i.get("UnidadMedida", "")
|
| 89 |
+
))
|
| 90 |
+
|
| 91 |
+
# Handle dates
|
| 92 |
+
fechas = item.get("Fechas", {})
|
| 93 |
+
closing_date = fechas.get("FechaCierre") or item.get("FechaCierre")
|
| 94 |
+
pub_date = fechas.get("FechaPublicacion")
|
| 95 |
+
|
| 96 |
+
return Tender(
|
| 97 |
+
code=item.get("CodigoExterno", ""),
|
| 98 |
+
name=item.get("Nombre", ""),
|
| 99 |
+
description=item.get("Descripcion", item.get("Nombre", "")),
|
| 100 |
+
buyer=item.get("Comprador", {}).get("NombreOrganismo", "Unknown"),
|
| 101 |
+
buyer_region=item.get("Comprador", {}).get("RegionUnidad"),
|
| 102 |
+
status=item.get("Estado", "Publicada"),
|
| 103 |
+
status_code=item.get("CodigoEstado"),
|
| 104 |
+
type=item.get("Tipo"),
|
| 105 |
+
currency=item.get("Moneda"),
|
| 106 |
+
closing_date=closing_date,
|
| 107 |
+
publication_date=pub_date,
|
| 108 |
+
estimated_amount=float(item.get("MontoEstimado", 0)) if item.get("MontoEstimado") else None,
|
| 109 |
+
source="Mercado Público",
|
| 110 |
+
region=item.get("Comprador", {}).get("RegionUnidad", "Nacional"),
|
| 111 |
+
sector="Public",
|
| 112 |
+
items=items_list,
|
| 113 |
+
attachments=[], # API v1 doesn't seem to provide attachments directly in this list
|
| 114 |
+
raw_data=item
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
async def _fetch(params: Dict[str, str]) -> List[Tender]:
|
| 118 |
+
"""Helper to perform the actual API request."""
|
| 119 |
if not settings.mercado_publico_ticket:
|
| 120 |
print("⚠️ No Mercado Público Ticket configured.")
|
| 121 |
return []
|
| 122 |
|
| 123 |
+
params["ticket"] = settings.mercado_publico_ticket
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
try:
|
| 126 |
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 127 |
+
response = await client.get(API_BASE, params=params)
|
| 128 |
response.raise_for_status()
|
| 129 |
data = response.json()
|
| 130 |
|
| 131 |
raw_list = data.get("Listado", [])
|
| 132 |
+
if raw_list is None: # Sometimes API returns null for Listado
|
| 133 |
+
return []
|
| 134 |
+
|
| 135 |
+
return [map_raw_to_tender(item) for item in raw_list]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
except Exception as e:
|
| 137 |
+
print(f"❌ API Error fetching with params {params}: {e}")
|
| 138 |
return []
|
| 139 |
|
| 140 |
+
async def get_active_tenders() -> List[Tender]:
|
| 141 |
+
"""Shows all tenders published on the day of the query."""
|
| 142 |
+
return await _fetch({"estado": "activas"})
|
| 143 |
+
|
| 144 |
+
async def get_tenders_by_date(date: str) -> List[Tender]:
|
| 145 |
+
"""Fetches all tenders for a specific date (format ddmmaaaa)."""
|
| 146 |
+
return await _fetch({"fecha": date})
|
| 147 |
+
|
| 148 |
+
async def get_tenders_by_status_and_date(status: str, date: Optional[str] = None) -> List[Tender]:
|
| 149 |
"""
|
| 150 |
+
Fetches tenders by status and date.
|
| 151 |
+
Status can be: publicada, cerrada, desierta, adjudicada, revocada, suspendida, todos.
|
| 152 |
"""
|
| 153 |
+
params = {"estado": status}
|
| 154 |
+
if date:
|
| 155 |
+
params["fecha"] = date
|
| 156 |
+
return await _fetch(params)
|
| 157 |
|
| 158 |
+
async def get_tender_by_code(code: str) -> Optional[Tender]:
|
| 159 |
+
"""Fetches detailed information for a specific tender code."""
|
| 160 |
+
results = await _fetch({"codigo": code})
|
| 161 |
+
return results[0] if results else None
|
| 162 |
|
| 163 |
+
async def get_tenders_by_provider(provider_code: str, date: str) -> List[Tender]:
|
| 164 |
+
"""Fetches tenders for a specific provider on a specific date."""
|
| 165 |
+
return await _fetch({"CodigoProveedor": provider_code, "fecha": date})
|
| 166 |
+
|
| 167 |
+
async def get_tenders_by_org(org_code: str, date: str) -> List[Tender]:
|
| 168 |
+
"""Fetches tenders for a specific public organization on a specific date."""
|
| 169 |
+
return await _fetch({"CodigoOrganismo": org_code, "fecha": date})
|
| 170 |
+
|
| 171 |
+
async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
|
| 172 |
+
"""
|
| 173 |
+
Legacy/Wrapper function for general search.
|
| 174 |
+
"""
|
| 175 |
+
search_date = date if date else datetime.now().strftime("%d%m%Y")
|
| 176 |
+
|
| 177 |
+
# We use active tenders as default if no date is provided
|
| 178 |
+
if not date:
|
| 179 |
+
tenders = await get_active_tenders()
|
| 180 |
+
else:
|
| 181 |
+
tenders = await get_tenders_by_date(search_date)
|
| 182 |
+
|
| 183 |
+
if keyword:
|
| 184 |
+
keyword = keyword.lower()
|
| 185 |
+
tenders = [t for t in tenders if keyword in t.name.lower() or keyword in t.description.lower()]
|
| 186 |
+
|
| 187 |
+
return tenders
|
|
|
backend/app/services/sync.py
CHANGED
|
@@ -28,20 +28,35 @@ async def sync_tenders_to_db(db: Session, keyword: str = None):
|
|
| 28 |
# Check if exists
|
| 29 |
db_tender = db.query(TenderModel).filter(TenderModel.code == api_t.code).first()
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# Convert Pydantic model to dict for DB
|
| 32 |
tender_data = {
|
| 33 |
"code": api_t.code,
|
| 34 |
"name": api_t.name,
|
| 35 |
"buyer": api_t.buyer,
|
|
|
|
| 36 |
"status": api_t.status,
|
| 37 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
"description": api_t.description,
|
| 39 |
"estimated_amount": api_t.estimated_amount,
|
| 40 |
"source": api_t.source,
|
| 41 |
"region": api_t.region,
|
| 42 |
"sector": api_t.sector,
|
| 43 |
-
"items": [item.
|
| 44 |
-
"attachments": [att.
|
| 45 |
}
|
| 46 |
|
| 47 |
if db_tender:
|
|
|
|
| 28 |
# Check if exists
|
| 29 |
db_tender = db.query(TenderModel).filter(TenderModel.code == api_t.code).first()
|
| 30 |
|
| 31 |
+
# Helper to parse dates
|
| 32 |
+
def parse_dt(dt_str):
|
| 33 |
+
if not dt_str: return None
|
| 34 |
+
try:
|
| 35 |
+
# Handle Z and other common formats
|
| 36 |
+
clean_str = dt_str.replace("Z", "").split(".")[0]
|
| 37 |
+
return datetime.fromisoformat(clean_str)
|
| 38 |
+
except:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
# Convert Pydantic model to dict for DB
|
| 42 |
tender_data = {
|
| 43 |
"code": api_t.code,
|
| 44 |
"name": api_t.name,
|
| 45 |
"buyer": api_t.buyer,
|
| 46 |
+
"buyer_region": api_t.buyer_region,
|
| 47 |
"status": api_t.status,
|
| 48 |
+
"status_code": str(api_t.status_code) if api_t.status_code else None,
|
| 49 |
+
"type": api_t.type,
|
| 50 |
+
"currency": api_t.currency,
|
| 51 |
+
"closing_date": parse_dt(api_t.closing_date) if isinstance(api_t.closing_date, str) else api_t.closing_date,
|
| 52 |
+
"publication_date": parse_dt(api_t.publication_date) if isinstance(api_t.publication_date, str) else api_t.publication_date,
|
| 53 |
"description": api_t.description,
|
| 54 |
"estimated_amount": api_t.estimated_amount,
|
| 55 |
"source": api_t.source,
|
| 56 |
"region": api_t.region,
|
| 57 |
"sector": api_t.sector,
|
| 58 |
+
"items": [item.model_dump() for item in api_t.items] if api_t.items else [],
|
| 59 |
+
"attachments": [att.model_dump() for att in api_t.attachments] if api_t.attachments else []
|
| 60 |
}
|
| 61 |
|
| 62 |
if db_tender:
|
backend/migrate_db.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "andesops.db")
|
| 5 |
+
|
| 6 |
+
def migrate():
|
| 7 |
+
if not os.path.exists(db_path):
|
| 8 |
+
print(f"Database not found at {db_path}")
|
| 9 |
+
return
|
| 10 |
+
|
| 11 |
+
conn = sqlite3.connect(db_path)
|
| 12 |
+
cursor = conn.cursor()
|
| 13 |
+
|
| 14 |
+
columns_to_add = [
|
| 15 |
+
("status_code", "VARCHAR(10)"),
|
| 16 |
+
("type", "VARCHAR(20)"),
|
| 17 |
+
("currency", "VARCHAR(10)"),
|
| 18 |
+
("publication_date", "DATETIME"),
|
| 19 |
+
("buyer_region", "VARCHAR(100)")
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
for col_name, col_type in columns_to_add:
|
| 23 |
+
try:
|
| 24 |
+
cursor.execute(f"ALTER TABLE tenders ADD COLUMN {col_name} {col_type}")
|
| 25 |
+
print(f"Added column {col_name}")
|
| 26 |
+
except sqlite3.OperationalError as e:
|
| 27 |
+
if "duplicate column name" in str(e).lower():
|
| 28 |
+
print(f"Column {col_name} already exists.")
|
| 29 |
+
else:
|
| 30 |
+
print(f"Error adding {col_name}: {e}")
|
| 31 |
+
|
| 32 |
+
conn.commit()
|
| 33 |
+
conn.close()
|
| 34 |
+
print("Migration finished.")
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
migrate()
|
backend/scratch_test_api.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
async def test_full_api():
|
| 6 |
+
ticket = "99B4CA8C-C1DF-4E3F-B5CF-C1672D432A91"
|
| 7 |
+
|
| 8 |
+
# 1. Fetch active tenders
|
| 9 |
+
url_active = f"https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json?estado=activas&ticket={ticket}"
|
| 10 |
+
print(f"Fetching active tenders: {url_active}")
|
| 11 |
+
|
| 12 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 13 |
+
try:
|
| 14 |
+
resp = await client.get(url_active)
|
| 15 |
+
data = resp.json()
|
| 16 |
+
items = data.get("Listado", [])
|
| 17 |
+
print(f"Found {len(items)} active items.")
|
| 18 |
+
|
| 19 |
+
if items:
|
| 20 |
+
code = items[0].get("CodigoExterno")
|
| 21 |
+
print(f"Fetching details for code: {code}")
|
| 22 |
+
|
| 23 |
+
url_detail = f"https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json?codigo={code}&ticket={ticket}"
|
| 24 |
+
resp_detail = await client.get(url_detail)
|
| 25 |
+
detail_data = resp_detail.json()
|
| 26 |
+
|
| 27 |
+
print("Detail sample:")
|
| 28 |
+
print(json.dumps(detail_data, indent=2))
|
| 29 |
+
|
| 30 |
+
# Save to file for reference
|
| 31 |
+
with open("api_sample_detail.json", "w") as f:
|
| 32 |
+
json.dump(detail_data, f, indent=2)
|
| 33 |
+
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"Error: {e}")
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
asyncio.run(test_full_api())
|
frontend/components/TenderSearch.tsx
CHANGED
|
@@ -313,11 +313,18 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 313 |
</td>
|
| 314 |
<td className="px-4 py-5 text-slate-400 text-[11px] truncate">{tender.buyer}</td>
|
| 315 |
<td className="px-4 py-5 text-center">
|
| 316 |
-
<
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
</td>
|
| 322 |
</tr>
|
| 323 |
))}
|
|
@@ -377,7 +384,11 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 377 |
</div>
|
| 378 |
<div className="flex items-center gap-3">
|
| 379 |
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">📍</div>
|
| 380 |
-
<span className="text-slate-400 font-medium">{selectedTenderForModal.region || "Nacional"}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</div>
|
| 382 |
</div>
|
| 383 |
</div>
|
|
@@ -393,17 +404,57 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 393 |
<span className="w-8 h-[1px] bg-slate-700" />
|
| 394 |
Project Scope & Description
|
| 395 |
</h4>
|
| 396 |
-
<div className="text-slate-300 leading-relaxed text-lg bg-white/[0.02] p-8 rounded-[2rem] border border-white/5 whitespace-pre-wrap font-light">
|
| 397 |
{selectedTenderForModal.description || "No detailed description provided."}
|
| 398 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
</section>
|
| 400 |
|
| 401 |
<div className="grid grid-cols-2 gap-6">
|
| 402 |
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 403 |
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Estimated Investment</div>
|
| 404 |
<div className="text-xl text-white font-bold tracking-tight">
|
| 405 |
-
{selectedTenderForModal.estimated_amount
|
|
|
|
|
|
|
| 406 |
</div>
|
|
|
|
|
|
|
|
|
|
| 407 |
</div>
|
| 408 |
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 409 |
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Industry Classification</div>
|
|
@@ -416,11 +467,27 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 416 |
<div className="space-y-12">
|
| 417 |
<div className="p-8 rounded-[2rem] bg-purple-600/10 border border-purple-500/20 shadow-2xl shadow-purple-500/5 relative overflow-hidden group">
|
| 418 |
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/20 blur-[60px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 419 |
-
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-purple-400 mb-6">
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
</div>
|
| 423 |
-
|
|
|
|
| 424 |
</div>
|
| 425 |
|
| 426 |
<div>
|
|
|
|
| 313 |
</td>
|
| 314 |
<td className="px-4 py-5 text-slate-400 text-[11px] truncate">{tender.buyer}</td>
|
| 315 |
<td className="px-4 py-5 text-center">
|
| 316 |
+
<div className="flex flex-col items-center gap-1">
|
| 317 |
+
<span className={`inline-block rounded-full px-2 py-0.5 text-[9px] font-bold ${
|
| 318 |
+
tender.status.toLowerCase().includes('publicada') ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-slate-800/50 text-slate-500'
|
| 319 |
+
}`}>
|
| 320 |
+
{tender.status}
|
| 321 |
+
</span>
|
| 322 |
+
{tender.type && (
|
| 323 |
+
<span className="text-[8px] font-mono text-slate-600 bg-white/5 px-1 rounded">
|
| 324 |
+
{tender.type}
|
| 325 |
+
</span>
|
| 326 |
+
)}
|
| 327 |
+
</div>
|
| 328 |
</td>
|
| 329 |
</tr>
|
| 330 |
))}
|
|
|
|
| 384 |
</div>
|
| 385 |
<div className="flex items-center gap-3">
|
| 386 |
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">📍</div>
|
| 387 |
+
<span className="text-slate-400 font-medium">{selectedTenderForModal.buyer_region || selectedTenderForModal.region || "Nacional"}</span>
|
| 388 |
+
</div>
|
| 389 |
+
<div className="flex items-center gap-3">
|
| 390 |
+
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">🏷️</div>
|
| 391 |
+
<span className="text-slate-400 font-medium">Type: {selectedTenderForModal.type || "N/A"}</span>
|
| 392 |
</div>
|
| 393 |
</div>
|
| 394 |
</div>
|
|
|
|
| 404 |
<span className="w-8 h-[1px] bg-slate-700" />
|
| 405 |
Project Scope & Description
|
| 406 |
</h4>
|
| 407 |
+
<div className="text-slate-300 leading-relaxed text-lg bg-white/[0.02] p-8 rounded-[2rem] border border-white/5 whitespace-pre-wrap font-light mb-12">
|
| 408 |
{selectedTenderForModal.description || "No detailed description provided."}
|
| 409 |
</div>
|
| 410 |
+
|
| 411 |
+
{selectedTenderForModal.items && selectedTenderForModal.items.length > 0 && (
|
| 412 |
+
<section>
|
| 413 |
+
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-500 mb-6 flex items-center gap-3">
|
| 414 |
+
<span className="w-8 h-[1px] bg-slate-700" />
|
| 415 |
+
Line Items & Requirements
|
| 416 |
+
</h4>
|
| 417 |
+
<div className="overflow-hidden rounded-3xl border border-white/5 bg-white/[0.01]">
|
| 418 |
+
<table className="w-full text-left text-xs">
|
| 419 |
+
<thead className="bg-white/5 text-slate-500 uppercase font-black tracking-tighter">
|
| 420 |
+
<tr>
|
| 421 |
+
<th className="px-6 py-4">Item / Product</th>
|
| 422 |
+
<th className="px-6 py-4">Category</th>
|
| 423 |
+
<th className="px-6 py-4 text-right">Quantity</th>
|
| 424 |
+
</tr>
|
| 425 |
+
</thead>
|
| 426 |
+
<tbody className="divide-y divide-white/5">
|
| 427 |
+
{selectedTenderForModal.items.map((item, idx) => (
|
| 428 |
+
<tr key={idx} className="hover:bg-white/[0.02]">
|
| 429 |
+
<td className="px-6 py-4">
|
| 430 |
+
<div className="text-slate-200 font-bold">{item.name}</div>
|
| 431 |
+
<div className="text-[10px] text-slate-500 mt-1">{item.description}</div>
|
| 432 |
+
</td>
|
| 433 |
+
<td className="px-6 py-4 text-slate-400">{item.category || "N/A"}</td>
|
| 434 |
+
<td className="px-6 py-4 text-right">
|
| 435 |
+
<span className="text-cyan font-mono font-bold">{item.quantity}</span>
|
| 436 |
+
<span className="ml-1 text-slate-500 uppercase text-[10px]">{item.unit}</span>
|
| 437 |
+
</td>
|
| 438 |
+
</tr>
|
| 439 |
+
))}
|
| 440 |
+
</tbody>
|
| 441 |
+
</table>
|
| 442 |
+
</div>
|
| 443 |
+
</section>
|
| 444 |
+
)}
|
| 445 |
</section>
|
| 446 |
|
| 447 |
<div className="grid grid-cols-2 gap-6">
|
| 448 |
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 449 |
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Estimated Investment</div>
|
| 450 |
<div className="text-xl text-white font-bold tracking-tight">
|
| 451 |
+
{selectedTenderForModal.estimated_amount
|
| 452 |
+
? new Intl.NumberFormat("es-CL", { style: "currency", currency: selectedTenderForModal.currency || "CLP" }).format(selectedTenderForModal.estimated_amount)
|
| 453 |
+
: "Not Disclosed"}
|
| 454 |
</div>
|
| 455 |
+
{selectedTenderForModal.currency && selectedTenderForModal.currency !== 'CLP' && (
|
| 456 |
+
<div className="text-[10px] text-cyan mt-1 font-bold">Currency: {selectedTenderForModal.currency}</div>
|
| 457 |
+
)}
|
| 458 |
</div>
|
| 459 |
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 460 |
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Industry Classification</div>
|
|
|
|
| 467 |
<div className="space-y-12">
|
| 468 |
<div className="p-8 rounded-[2rem] bg-purple-600/10 border border-purple-500/20 shadow-2xl shadow-purple-500/5 relative overflow-hidden group">
|
| 469 |
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/20 blur-[60px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 470 |
+
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-purple-400 mb-6">Timeline</h4>
|
| 471 |
+
|
| 472 |
+
<div className="space-y-4">
|
| 473 |
+
<div>
|
| 474 |
+
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Closing Deadline</div>
|
| 475 |
+
<div className="text-2xl font-black text-white font-mono">
|
| 476 |
+
{selectedTenderForModal.closing_date ? new Date(selectedTenderForModal.closing_date).toLocaleDateString() : "---"}
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
|
| 480 |
+
{selectedTenderForModal.publication_date && (
|
| 481 |
+
<div>
|
| 482 |
+
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Published On</div>
|
| 483 |
+
<div className="text-sm font-bold text-slate-300">
|
| 484 |
+
{new Date(selectedTenderForModal.publication_date).toLocaleDateString()}
|
| 485 |
+
</div>
|
| 486 |
+
</div>
|
| 487 |
+
)}
|
| 488 |
</div>
|
| 489 |
+
|
| 490 |
+
<p className="mt-6 text-[10px] text-purple-400/60 font-bold uppercase tracking-tighter border-t border-purple-400/10 pt-4">Final Window for Submission</p>
|
| 491 |
</div>
|
| 492 |
|
| 493 |
<div>
|
frontend/lib/types.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
export type TenderItem = {
|
|
|
|
|
|
|
|
|
|
| 2 |
name: string;
|
|
|
|
| 3 |
quantity: number;
|
| 4 |
unit: string;
|
| 5 |
};
|
|
@@ -12,10 +16,15 @@ export type TenderAttachment = {
|
|
| 12 |
export type Tender = {
|
| 13 |
code: string;
|
| 14 |
name: string;
|
|
|
|
| 15 |
buyer: string;
|
|
|
|
| 16 |
status: string;
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
| 19 |
estimated_amount: number | null;
|
| 20 |
source: string;
|
| 21 |
region?: string;
|
|
|
|
| 1 |
export type TenderItem = {
|
| 2 |
+
correlative?: number;
|
| 3 |
+
product_code?: string;
|
| 4 |
+
category?: string;
|
| 5 |
name: string;
|
| 6 |
+
description?: string;
|
| 7 |
quantity: number;
|
| 8 |
unit: string;
|
| 9 |
};
|
|
|
|
| 16 |
export type Tender = {
|
| 17 |
code: string;
|
| 18 |
name: string;
|
| 19 |
+
description: string;
|
| 20 |
buyer: string;
|
| 21 |
+
buyer_region?: string;
|
| 22 |
status: string;
|
| 23 |
+
status_code?: string;
|
| 24 |
+
type?: string;
|
| 25 |
+
currency?: string;
|
| 26 |
+
closing_date: string | null;
|
| 27 |
+
publication_date?: string | null;
|
| 28 |
estimated_amount: number | null;
|
| 29 |
source: string;
|
| 30 |
region?: string;
|