Álvaro Valenzuela Valdes commited on
Commit
573951f
·
1 Parent(s): 9f7d194

Improve Mercado Público search integration and tender search UX

Browse files
backend/app/routers/tenders.py CHANGED
@@ -1,12 +1,21 @@
 
1
  from typing import List, Optional
2
  from fastapi import APIRouter, Query, Depends
3
  from sqlalchemy.orm import Session
4
- from sqlalchemy import or_, desc
5
 
6
  from app.schemas.tender import Tender
7
  from app.database import get_db
8
  from app.models.tender import TenderModel
9
  from app.services.sync import sync_tenders_to_db, clean_expired_tenders
 
 
 
 
 
 
 
 
10
 
11
  router = APIRouter()
12
 
@@ -15,10 +24,35 @@ async def search_tender_opportunities(
15
  keyword: Optional[str] = None,
16
  buyer: Optional[str] = None,
17
  region: Optional[str] = None,
 
 
 
 
 
18
  skip: int = 0,
19
  limit: int = 50,
20
  db: Session = Depends(get_db)
21
  ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # 1. Búsqueda en DB con paginación
23
  query = db.query(TenderModel)
24
 
@@ -44,7 +78,7 @@ async def search_tender_opportunities(
44
  # Ordenar por fecha de cierre (más próximas primero)
45
  results = query.order_by(TenderModel.closing_date.asc()).offset(skip).limit(limit).all()
46
 
47
- # 2. Si la DB está vacía y no hay filtros, sugerir sincronización (no hace nada automático)
48
  if not results and keyword and len(keyword) > 3:
49
  await sync_tenders_to_db(db, keyword=keyword)
50
  results = query.offset(skip).limit(limit).all()
 
1
+ from datetime import datetime
2
  from typing import List, Optional
3
  from fastapi import APIRouter, Query, Depends
4
  from sqlalchemy.orm import Session
5
+ from sqlalchemy import or_
6
 
7
  from app.schemas.tender import Tender
8
  from app.database import get_db
9
  from app.models.tender import TenderModel
10
  from app.services.sync import sync_tenders_to_db, clean_expired_tenders
11
+ from app.services.mercado_publico import (
12
+ fetch_tenders,
13
+ get_tender_by_code,
14
+ get_tenders_by_provider,
15
+ get_tenders_by_org,
16
+ get_tenders_by_status_and_date,
17
+ get_tenders_by_date,
18
+ )
19
 
20
  router = APIRouter()
21
 
 
24
  keyword: Optional[str] = None,
25
  buyer: Optional[str] = None,
26
  region: Optional[str] = None,
27
+ provider_code: Optional[str] = Query(None, alias="provider_code"),
28
+ org_code: Optional[str] = Query(None, alias="org_code"),
29
+ status: Optional[str] = None,
30
+ code: Optional[str] = None,
31
+ date: Optional[str] = None,
32
  skip: int = 0,
33
  limit: int = 50,
34
  db: Session = Depends(get_db)
35
  ):
36
+ # If a Mercado Público-specific query is requested, fetch live from the external API.
37
+ if code:
38
+ tender = await get_tender_by_code(code)
39
+ return [tender] if tender else []
40
+
41
+ if provider_code:
42
+ return await get_tenders_by_provider(provider_code, date)
43
+
44
+ if org_code:
45
+ return await get_tenders_by_org(org_code, date)
46
+
47
+ if status:
48
+ return await get_tenders_by_status_and_date(status, date)
49
+
50
+ if date and not (buyer or region or keyword):
51
+ return await get_tenders_by_date(date)
52
+
53
+ if keyword and not (buyer or region):
54
+ return await fetch_tenders(keyword=keyword, date=date)
55
+
56
  # 1. Búsqueda en DB con paginación
57
  query = db.query(TenderModel)
58
 
 
78
  # Ordenar por fecha de cierre (más próximas primero)
79
  results = query.order_by(TenderModel.closing_date.asc()).offset(skip).limit(limit).all()
80
 
81
+ # 2. Si la DB está vacía y se busca por palabra clave, hacer un intento de sincronización.
82
  if not results and keyword and len(keyword) > 3:
83
  await sync_tenders_to_db(db, keyword=keyword)
84
  results = query.offset(skip).limit(limit).all()
backend/app/services/mercado_publico.py CHANGED
@@ -75,6 +75,19 @@ TIME_UNITS = {
75
  "5": "Años"
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
79
  """Maps raw API item to Tender schema."""
80
  items_list = []
@@ -163,26 +176,29 @@ async def get_active_tenders() -> List[Tender]:
163
 
164
  async def get_tenders_by_date(date: str) -> List[Tender]:
165
  """Fetches all tenders for a specific date (format ddmmaaaa)."""
166
- return await _fetch({"fecha": date})
 
167
 
168
  async def get_tenders_by_status_and_date(status: str, date: Optional[str] = None) -> List[Tender]:
169
  params = {"estado": status}
170
  if date:
171
- params["fecha"] = date
172
  return await _fetch(params)
173
 
174
  async def get_tender_by_code(code: str) -> Optional[Tender]:
175
  results = await _fetch({"codigo": code})
176
  return results[0] if results else None
177
 
178
- async def get_tenders_by_provider(provider_code: str, date: str) -> List[Tender]:
179
- return await _fetch({"CodigoProveedor": provider_code, "fecha": date})
 
180
 
181
- async def get_tenders_by_org(org_code: str, date: str) -> List[Tender]:
182
- return await _fetch({"CodigoOrganismo": org_code, "fecha": date})
 
183
 
184
  async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
185
- search_date = date if date else datetime.now().strftime("%d%m%Y")
186
 
187
  if not date:
188
  tenders = await get_active_tenders()
 
75
  "5": "Años"
76
  }
77
 
78
+ def normalize_mp_date(date_str: Optional[str]) -> Optional[str]:
79
+ if not date_str:
80
+ return None
81
+ if "-" in date_str:
82
+ parts = date_str.split("-")
83
+ if len(parts) == 3 and all(part.isdigit() for part in parts):
84
+ # Convert ISO date YYYY-MM-DD into ddmmaaaa
85
+ return f"{parts[2].zfill(2)}{parts[1].zfill(2)}{parts[0]}"
86
+ if len(date_str) == 8 and date_str.isdigit():
87
+ return date_str
88
+ return date_str
89
+
90
+
91
  def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
92
  """Maps raw API item to Tender schema."""
93
  items_list = []
 
176
 
177
  async def get_tenders_by_date(date: str) -> List[Tender]:
178
  """Fetches all tenders for a specific date (format ddmmaaaa)."""
179
+ normalized = normalize_mp_date(date)
180
+ return await _fetch({"fecha": normalized})
181
 
182
  async def get_tenders_by_status_and_date(status: str, date: Optional[str] = None) -> List[Tender]:
183
  params = {"estado": status}
184
  if date:
185
+ params["fecha"] = normalize_mp_date(date)
186
  return await _fetch(params)
187
 
188
  async def get_tender_by_code(code: str) -> Optional[Tender]:
189
  results = await _fetch({"codigo": code})
190
  return results[0] if results else None
191
 
192
+ async def get_tenders_by_provider(provider_code: str, date: Optional[str] = None) -> List[Tender]:
193
+ normalized_date = normalize_mp_date(date if date else datetime.now().strftime("%Y-%m-%d"))
194
+ return await _fetch({"CodigoProveedor": provider_code, "fecha": normalized_date})
195
 
196
+ async def get_tenders_by_org(org_code: str, date: Optional[str] = None) -> List[Tender]:
197
+ normalized_date = normalize_mp_date(date if date else datetime.now().strftime("%Y-%m-%d"))
198
+ return await _fetch({"CodigoOrganismo": org_code, "fecha": normalized_date})
199
 
200
  async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
201
+ search_date = normalize_mp_date(date if date else datetime.now().strftime("%Y-%m-%d"))
202
 
203
  if not date:
204
  tenders = await get_active_tenders()
frontend/components/TenderSearch.tsx CHANGED
@@ -7,7 +7,7 @@ import { Language, translations } from "../lib/translations";
7
 
8
  type Props = {
9
  tenders: Tender[];
10
- onSearch: (params: { keyword?: string; buyer?: string; provider_code?: string; date?: string; skip?: number; limit?: number; isAgile?: boolean }) => void;
11
  onAnalyze: (tender: Tender) => void;
12
  forceShowFollowed?: boolean;
13
  initialKeyword?: string;
@@ -18,6 +18,9 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
18
  const t = translations[lang];
19
  const [keyword, setKeyword] = useState(initialKeyword);
20
  const [buyerCode, setBuyerCode] = useState("");
 
 
 
21
  const [date, setDate] = useState("");
22
  const [selectedTenderForModal, setSelectedTenderForModal] = useState<Tender | null>(null);
23
  const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
@@ -38,6 +41,14 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
38
  const [isAgileMode, setIsAgileMode] = useState(false);
39
  const itemsPerPage = 50;
40
 
 
 
 
 
 
 
 
 
41
  useEffect(() => {
42
  if (forceShowFollowed) setShowOnlyFollowed(true);
43
  }, [forceShowFollowed]);
@@ -96,7 +107,10 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
96
 
97
  await onSearch({
98
  keyword: isCode ? undefined : keyword,
99
- provider_code: isCode ? keyword : undefined,
 
 
 
100
  buyer: buyerCode,
101
  date,
102
  skip: 0,
@@ -161,53 +175,117 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
161
  </div>
162
 
163
  {!forceShowFollowed && (
164
- <form onSubmit={handleSearch} className="relative z-10 grid grid-cols-1 md:grid-cols-4 gap-6 animate-in slide-in-from-top-4 duration-500">
165
-
166
- <div className="space-y-2">
167
- <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Keyword</label>
168
- <input
169
- type="text"
170
- name="keyword"
171
- autoComplete="off"
172
- placeholder="e.g. Software, Cloud..."
173
- className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
174
- value={keyword}
175
- onChange={(e) => setKeyword(e.target.value)}
176
- />
177
- </div>
178
- <div className="space-y-2">
179
- <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Buyer Code</label>
180
- <input
181
- type="text"
182
- name="buyerCode"
183
- autoComplete="off"
184
- placeholder="e.g. 6945"
185
- className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
186
- value={buyerCode}
187
- onChange={(e) => setBuyerCode(e.target.value)}
188
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  </div>
190
- <div className="space-y-2">
191
- <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Date Limit</label>
192
- <input
193
- type="date"
194
- className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all [color-scheme:dark]"
195
- value={date}
196
- onChange={(e) => setDate(e.target.value)}
197
- />
198
  </div>
199
- <div className="flex items-end gap-3">
200
- <button
201
- type="submit"
202
- disabled={isLoading}
203
- className="flex-1 premium-gradient hover:opacity-90 text-white font-bold py-3.5 rounded-xl transition-all shadow-lg shadow-purple-500/20 active:scale-[0.98] disabled:opacity-50"
204
- >
205
- {isLoading ? "Searching..." : "Fetch Opportunities"}
206
- </button>
 
 
 
 
 
 
 
207
  <button
208
  type="button"
209
  onClick={() => setIsAgileMode(!isAgileMode)}
210
- className={`px-4 py-3.5 rounded-xl border font-bold text-[10px] uppercase tracking-widest transition-all ${
211
  isAgileMode
212
  ? "bg-cyan/20 border-cyan/40 text-cyan animate-pulse"
213
  : "bg-white/5 border-white/10 text-slate-500 hover:border-white/20"
 
7
 
8
  type Props = {
9
  tenders: Tender[];
10
+ onSearch: (params: { keyword?: string; buyer?: string; provider_code?: string; org_code?: string; status?: string; code?: string; date?: string; skip?: number; limit?: number; isAgile?: boolean }) => void;
11
  onAnalyze: (tender: Tender) => void;
12
  forceShowFollowed?: boolean;
13
  initialKeyword?: string;
 
18
  const t = translations[lang];
19
  const [keyword, setKeyword] = useState(initialKeyword);
20
  const [buyerCode, setBuyerCode] = useState("");
21
+ const [providerCode, setProviderCode] = useState("");
22
+ const [orgCode, setOrgCode] = useState("");
23
+ const [status, setStatus] = useState("");
24
  const [date, setDate] = useState("");
25
  const [selectedTenderForModal, setSelectedTenderForModal] = useState<Tender | null>(null);
26
  const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
 
41
  const [isAgileMode, setIsAgileMode] = useState(false);
42
  const itemsPerPage = 50;
43
 
44
+ const isTenderCode = /^[0-9]+-[0-9]+-[A-Z0-9]+$/i.test(keyword);
45
+ const isLiveSearch = Boolean(isTenderCode || providerCode || orgCode || status || date);
46
+ const searchButtonLabel = isLoading
47
+ ? "Searching..."
48
+ : isLiveSearch
49
+ ? "Live MP Search"
50
+ : "Fetch Active Tenders";
51
+
52
  useEffect(() => {
53
  if (forceShowFollowed) setShowOnlyFollowed(true);
54
  }, [forceShowFollowed]);
 
107
 
108
  await onSearch({
109
  keyword: isCode ? undefined : keyword,
110
+ code: isCode ? keyword : undefined,
111
+ provider_code: providerCode || undefined,
112
+ org_code: orgCode || undefined,
113
+ status: status || undefined,
114
  buyer: buyerCode,
115
  date,
116
  skip: 0,
 
175
  </div>
176
 
177
  {!forceShowFollowed && (
178
+ <form onSubmit={handleSearch} className="relative z-10 space-y-4 animate-in slide-in-from-top-4 duration-500">
179
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
180
+ <div className="space-y-2">
181
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Keyword</label>
182
+ <input
183
+ type="text"
184
+ name="keyword"
185
+ autoComplete="off"
186
+ placeholder="e.g. Software, Cloud..."
187
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
188
+ value={keyword}
189
+ onChange={(e) => setKeyword(e.target.value)}
190
+ />
191
+ </div>
192
+ <div className="space-y-2">
193
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Keyword / Tender Code</label>
194
+ <input
195
+ type="text"
196
+ name="code"
197
+ autoComplete="off"
198
+ placeholder="e.g. Software or 1509-5-L114"
199
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
200
+ value={keyword}
201
+ onChange={(e) => setKeyword(e.target.value)}
202
+ />
203
+ </div>
204
+ <div className="space-y-2">
205
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Buyer name</label>
206
+ <input
207
+ type="text"
208
+ name="buyerCode"
209
+ autoComplete="off"
210
+ placeholder="e.g. Servicio de Salud"
211
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
212
+ value={buyerCode}
213
+ onChange={(e) => setBuyerCode(e.target.value)}
214
+ />
215
+ </div>
216
+ <div className="space-y-2">
217
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Provider Code</label>
218
+ <input
219
+ type="text"
220
+ name="providerCode"
221
+ autoComplete="off"
222
+ placeholder="e.g. 17793"
223
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
224
+ value={providerCode}
225
+ onChange={(e) => setProviderCode(e.target.value)}
226
+ />
227
+ </div>
228
+ <div className="space-y-2">
229
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Org Code</label>
230
+ <input
231
+ type="text"
232
+ name="orgCode"
233
+ autoComplete="off"
234
+ placeholder="e.g. 6945"
235
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
236
+ value={orgCode}
237
+ onChange={(e) => setOrgCode(e.target.value)}
238
+ />
239
+ </div>
240
+ <div className="space-y-2">
241
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Status</label>
242
+ <select
243
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
244
+ value={status}
245
+ onChange={(e) => setStatus(e.target.value)}
246
+ >
247
+ <option value="">Any state</option>
248
+ <option value="activas">activas</option>
249
+ <option value="publicada">publicada</option>
250
+ <option value="cerrada">cerrada</option>
251
+ <option value="desierta">desierta</option>
252
+ <option value="adjudicada">adjudicada</option>
253
+ <option value="revocada">revocada</option>
254
+ <option value="suspendida">suspendida</option>
255
+ </select>
256
+ </div>
257
+ <div className="space-y-2">
258
+ <label className="text-[10px] uppercase tracking-wider text-slate-500 font-bold px-1">Date</label>
259
+ <input
260
+ type="date"
261
+ className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all [color-scheme:dark]"
262
+ value={date}
263
+ onChange={(e) => setDate(e.target.value)}
264
+ />
265
+ </div>
266
  </div>
267
+ <div className="rounded-3xl bg-slate-900/70 border border-white/10 p-4 text-slate-400 text-xs leading-5">
268
+ <span className="font-bold text-slate-200">Tip:</span> Deja todos los campos vacíos para mostrar las licitaciones activas del día. Usa <span className="font-semibold text-white">Tender Code</span> para una búsqueda exacta o los filtros <span className="font-semibold text-white">Provider Code</span>, <span className="font-semibold text-white">Org Code</span>, <span className="font-semibold text-white">Status</span> y <span className="font-semibold text-white">Date</span> para consultas directas en Mercado Público.
 
 
 
 
 
 
269
  </div>
270
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
271
+ <div className="flex flex-col gap-2">
272
+ <button
273
+ type="submit"
274
+ disabled={isLoading}
275
+ className="w-full sm:w-auto premium-gradient hover:opacity-90 text-white font-bold py-3.5 rounded-xl transition-all shadow-lg shadow-purple-500/20 active:scale-[0.98] disabled:opacity-50"
276
+ >
277
+ {searchButtonLabel}
278
+ </button>
279
+ {isLiveSearch && (
280
+ <span className="inline-flex items-center justify-center rounded-full bg-emerald-500/10 text-emerald-300 text-[10px] uppercase tracking-[0.25em] px-3 py-2 border border-emerald-500/20">
281
+ Live Mercado Público search
282
+ </span>
283
+ )}
284
+ </div>
285
  <button
286
  type="button"
287
  onClick={() => setIsAgileMode(!isAgileMode)}
288
+ className={`w-full sm:w-auto px-4 py-3.5 rounded-xl border font-bold text-[10px] uppercase tracking-widest transition-all ${
289
  isAgileMode
290
  ? "bg-cyan/20 border-cyan/40 text-cyan animate-pulse"
291
  : "bg-white/5 border-white/10 text-slate-500 hover:border-white/20"
frontend/lib/api.ts CHANGED
@@ -24,6 +24,9 @@ export async function searchTenders(params: {
24
  keyword?: string;
25
  buyer?: string;
26
  provider_code?: string;
 
 
 
27
  date?: string;
28
  skip?: number;
29
  limit?: number;
@@ -32,6 +35,9 @@ export async function searchTenders(params: {
32
  if (params.keyword) query.append("keyword", params.keyword);
33
  if (params.buyer) query.append("buyer", params.buyer);
34
  if (params.provider_code) query.append("provider_code", params.provider_code);
 
 
 
35
  if (params.date) query.append("date", params.date);
36
  if (params.skip !== undefined) query.append("skip", params.skip.toString());
37
  if (params.limit !== undefined) query.append("limit", params.limit.toString());
 
24
  keyword?: string;
25
  buyer?: string;
26
  provider_code?: string;
27
+ org_code?: string;
28
+ status?: string;
29
+ code?: string;
30
  date?: string;
31
  skip?: number;
32
  limit?: number;
 
35
  if (params.keyword) query.append("keyword", params.keyword);
36
  if (params.buyer) query.append("buyer", params.buyer);
37
  if (params.provider_code) query.append("provider_code", params.provider_code);
38
+ if (params.org_code) query.append("org_code", params.org_code);
39
+ if (params.status) query.append("status", params.status);
40
+ if (params.code) query.append("code", params.code);
41
  if (params.date) query.append("date", params.date);
42
  if (params.skip !== undefined) query.append("skip", params.skip.toString());
43
  if (params.limit !== undefined) query.append("limit", params.limit.toString());