feat: ortak kalem havuzu — teklif cetveli kalemleri kartta gorunur ve aranabilir
Firmalar ihalenin sadece basligina bakarak kendi urunlerinin icinde olup
olmadigini goremiyordu ("148 KALEM KIRTASIYE MALZEME ALIMI"). Artik bir
kullanici ihale dokumanini yukledinde teklif cetveli kalemleri ayristirilip
IKN'ye baglaniyor ve o ihale HERKESIN kartinda kalemleriyle gorunuyor.
NEDEN YUKLEMEYE DAYALI: kalem listesi EKAP'in programatik yuzeyinde yok.
- Arama/detay API'si (ihtiyacKalemiOkasList) yalnizca OKAS sinifi veriyor:
"KALP AKCIGER POMPASI CIHAZI ALIMI" icin tek satir "Tibbi cihazlar"
- Ilan metni yalnizca sayiyi veriyor: "148 KALEM ... Ayrintili bilgiye EKAP'ta
yer alan ihale dokumani icinde bulunan idari sartnameden ulasilabilir"
- Dokuman sayfasi CAPTCHA ile korunuyor (ctl00_btnCaptchaProtect, guvenlik
resmi). Gercek tarayiciyla (Playwright) da acildi, donen tek metin: "Dogrula"
OTOMATIK, AI'SIZ: kalem cikarimi yukleme aninda kendiliginden calisir,
kullanicidan tiklama beklemez ve model cagirmaz (saf regex). AI analizi ayri
dugmede, kullanici isterse.
- server/kalem-cikar.mjs: teklif cetveli ayristirici. " | " tablolarindan
(docx/xlsx/zip) sutun basligini cozerek; ayracsiz PDF metninde sezgisel
satir kalibiyla. Miktar+birim tek hucrede olabiliyor ("25000 TEST").
Birim listesinde 'test' var — tibbi kitlerde kalem miktari boyle yaziliyor.
- server/kalem-store.mjs: kalem.sqlite, IKN bazli, Turkce duyarli arama.
Bos ayristirma mevcut iyi veriyi silmez.
- Uclar: POST /api/kalem/cikar (tarayicidan gelen PDF metni), GET /api/kalem,
GET /api/kalem/ara, GET /api/health/kalem
- /api/tenders payloadu kalemSayisi + ilk kalemleri tasiyor
- Kartta kalem rozeti ve kalem seridi; aramada eslesen kalemler one alinir
- Ust arama kutusu kalem havuzunda da ariyor (350ms debounce, sunucu tarafi)
- Detay modalinda tam kalem listesi + kalem ici arama
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Ortak kalem havuzu (SQLite).
|
||||
*
|
||||
* NEDEN: Teklif cetveli kalemleri EKAP'ın API'sinde yok, yalnızca ihale
|
||||
* dokümanının içinde — ve doküman sayfası CAPTCHA ile korunuyor, sunucudan
|
||||
* otomatik indirilemiyor. Kullanıcı dokümanı kendi tarayıcısından indirip
|
||||
* yükleyince kalemler buraya yazılıyor ve o ihale HERKESİN kartında
|
||||
* kalemleriyle görünür oluyor. İlk yükleyen herkes için açıyor.
|
||||
*
|
||||
* Kalıcı anahtar İKN — EKAP'ın `id` alanı anlık token, saklanmaz.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { normalizeTr } from './siniflandir.mjs';
|
||||
|
||||
let DatabaseSync = null;
|
||||
try {
|
||||
({ DatabaseSync } = await import('node:sqlite'));
|
||||
} catch {
|
||||
console.warn('[kalem-store] node:sqlite yok — kalem havuzu devre dışı');
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'kalem.sqlite');
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS ihale_kalem (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ikn TEXT NOT NULL,
|
||||
sira INTEGER,
|
||||
ad TEXT NOT NULL,
|
||||
ad_norm TEXT NOT NULL,
|
||||
miktar REAL,
|
||||
birim TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kalem_ikn ON ihale_kalem(ikn);
|
||||
CREATE INDEX IF NOT EXISTS idx_kalem_norm ON ihale_kalem(ad_norm);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kalem_kaynak (
|
||||
ikn TEXT PRIMARY KEY,
|
||||
kalem_sayisi INTEGER NOT NULL DEFAULT 0,
|
||||
dosya TEXT,
|
||||
yontem TEXT,
|
||||
ekleyen TEXT,
|
||||
zaman TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
let db = null;
|
||||
export function available() {
|
||||
return !!DatabaseSync;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (db) return db;
|
||||
if (!DatabaseSync) return null;
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
db = new DatabaseSync(DB_PATH);
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bir ihalenin kalemlerini yazar (varsa eskisini değiştirir).
|
||||
* Boş liste yazılmaz — hatalı bir ayrıştırma mevcut iyi veriyi silmesin.
|
||||
*/
|
||||
export function kalemYaz(ikn, kalemler, { dosya, yontem, ekleyen } = {}) {
|
||||
const d = open();
|
||||
if (!d || !ikn || !Array.isArray(kalemler) || kalemler.length === 0) return 0;
|
||||
|
||||
const sil = d.prepare('DELETE FROM ihale_kalem WHERE ikn = ?');
|
||||
const ekle = d.prepare(
|
||||
'INSERT INTO ihale_kalem (ikn, sira, ad, ad_norm, miktar, birim) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
const ozet = d.prepare(
|
||||
`INSERT INTO kalem_kaynak (ikn, kalem_sayisi, dosya, yontem, ekleyen, zaman)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ikn) DO UPDATE SET
|
||||
kalem_sayisi = excluded.kalem_sayisi, dosya = excluded.dosya,
|
||||
yontem = excluded.yontem, ekleyen = excluded.ekleyen, zaman = excluded.zaman`,
|
||||
);
|
||||
|
||||
d.exec('BEGIN');
|
||||
try {
|
||||
sil.run(ikn);
|
||||
for (const k of kalemler) {
|
||||
ekle.run(ikn, k.sira ?? null, String(k.ad).slice(0, 300), normalizeTr(k.ad).slice(0, 300),
|
||||
k.miktar ?? null, k.birim ? String(k.birim).slice(0, 30) : null);
|
||||
}
|
||||
ozet.run(ikn, kalemler.length, dosya || null, yontem || null, ekleyen || null, new Date().toISOString());
|
||||
d.exec('COMMIT');
|
||||
} catch (e) {
|
||||
d.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
return kalemler.length;
|
||||
}
|
||||
|
||||
export function kalemOku(ikn) {
|
||||
const d = open();
|
||||
if (!d || !ikn) return { kalemler: [], kaynak: null };
|
||||
const kalemler = d.prepare(
|
||||
'SELECT sira, ad, miktar, birim FROM ihale_kalem WHERE ikn = ? ORDER BY COALESCE(sira, 999999), id',
|
||||
).all(ikn);
|
||||
const kaynak = d.prepare('SELECT kalem_sayisi, dosya, yontem, zaman FROM kalem_kaynak WHERE ikn = ?').get(ikn) || null;
|
||||
return { kalemler, kaynak };
|
||||
}
|
||||
|
||||
/** Havuzdaki tüm ihalelerin kalem özeti: ikn → { sayi, ilkler[] } */
|
||||
export function kalemOzetleri(limit = 20000) {
|
||||
const d = open();
|
||||
if (!d) return new Map();
|
||||
const ozet = d.prepare('SELECT ikn, kalem_sayisi FROM kalem_kaynak LIMIT ?').all(limit);
|
||||
const harita = new Map();
|
||||
if (!ozet.length) return harita;
|
||||
const ilk = d.prepare('SELECT ad FROM ihale_kalem WHERE ikn = ? ORDER BY COALESCE(sira, 999999), id LIMIT 6');
|
||||
for (const o of ozet) {
|
||||
harita.set(o.ikn, { sayi: o.kalem_sayisi, ilkler: ilk.all(o.ikn).map((r) => r.ad) });
|
||||
}
|
||||
return harita;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kalem metninde arama. Firmanın kendi ürününü ("eldiven", "ortopedik vida")
|
||||
* hangi ihalelerde geçtiğini bulması için.
|
||||
*/
|
||||
export function kalemAra(q, { limit = 400 } = {}) {
|
||||
const d = open();
|
||||
const terim = normalizeTr(q || '').trim();
|
||||
if (!d || terim.length < 2) return [];
|
||||
const satirlar = d.prepare(
|
||||
`SELECT ikn, ad, miktar, birim FROM ihale_kalem
|
||||
WHERE ad_norm LIKE ? ORDER BY ikn LIMIT ?`,
|
||||
).all(`%${terim}%`, limit * 5);
|
||||
|
||||
const harita = new Map();
|
||||
for (const s of satirlar) {
|
||||
if (!harita.has(s.ikn)) harita.set(s.ikn, { ikn: s.ikn, eslesen: [] });
|
||||
const e = harita.get(s.ikn);
|
||||
if (e.eslesen.length < 5) e.eslesen.push({ ad: s.ad, miktar: s.miktar, birim: s.birim });
|
||||
if (harita.size >= limit) break;
|
||||
}
|
||||
return [...harita.values()];
|
||||
}
|
||||
|
||||
export function kalemSayilari() {
|
||||
const d = open();
|
||||
if (!d) return { ihale: 0, kalem: 0 };
|
||||
return {
|
||||
ihale: d.prepare('SELECT COUNT(*) n FROM kalem_kaynak').get().n,
|
||||
kalem: d.prepare('SELECT COUNT(*) n FROM ihale_kalem').get().n,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user