CPV/OKAS ham kod sınıflandırması: yayınlanmadan önce tamamlanan bir gate + sidebar filtresi
- server/cpv-classify.mjs (yeni): existing (kalem kodu) → keyword (66 kural, 45 OKAS_BOLUM kodunun tamamı kapsanıyor) → AI (analyzeSpec, sadece sözlükte gerçekten var olan kod kabul edilir) hibrit sınıflandırma - server/index.js: cache.tenders'a eklenmeden ÖNCE her ihale için CPV sınıflandırması await'leniyor (mevcut "sektör" alanının aksine, sonradan zenginleştirilmiyor) — hem ilk yükleme hem arka plan tamamlama yolunda - İKN bazlı kalıcı SQLite önbellek (history-store.mjs) — aynı ilan bir daha asla yeniden sınıflandırılmıyor - /api/meta/filtreler artık cpvBolumler döndürüyor - Frontend: Sektörler'in hemen altına CPV Kodu filtresi (arama + çoklu seçim), ihale kartında CPV rozeti, landing page'de yeni özellik: "AB uyumlu CPV sınıflandırması — Türkiye'de ilk"
This commit is contained in:
+113
-2
@@ -7,6 +7,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { sektorKarar } from './siniflandir.mjs';
|
||||
import { classifyCpvByKeyword } from './cpv-classify.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { titleCaseTr } from './tr-text.mjs';
|
||||
@@ -57,6 +58,15 @@ CREATE TABLE IF NOT EXISTS sektor_zengin (
|
||||
zaman TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cpv_cache (
|
||||
ikn TEXT PRIMARY KEY, -- İhale Kayıt Numarası — CPV sınıflandırmasının kalıcı anahtarı
|
||||
cpv TEXT, -- JSON dizi: eşleşen tam CPV/OKAS kodları
|
||||
cpv_bolum TEXT, -- JSON dizi: 2 haneli bölüm kodları
|
||||
cpv_label TEXT, -- birincil gösterim etiketi
|
||||
cpv_source TEXT, -- 'existing' | 'keyword' | 'ai' | 'none'
|
||||
denenme_zamani INTEGER -- epoch ms
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS havuz_olcum (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
zaman TEXT NOT NULL,
|
||||
@@ -170,7 +180,8 @@ function okasParse(json, bedel) {
|
||||
}
|
||||
|
||||
function rowToTender(r) {
|
||||
return {
|
||||
const items = okasParse(r.okas_kalemler, r.sozlesme_bedeli);
|
||||
const tender = {
|
||||
id: `hist-${r.ikn}-${r.kisim}`,
|
||||
kaynak: 'EKAP Sonuç İlanı',
|
||||
ikn: r.ikn,
|
||||
@@ -190,7 +201,7 @@ function rowToTender(r) {
|
||||
teklifSayisi: r.teklif_sayisi,
|
||||
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
|
||||
durum: 'Sonuçlandı',
|
||||
items: okasParse(r.okas_kalemler, r.sozlesme_bedeli),
|
||||
items,
|
||||
// Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan)
|
||||
eklenmeZamani: 'arşivden',
|
||||
isYeni: false,
|
||||
@@ -206,6 +217,15 @@ function rowToTender(r) {
|
||||
takipte: false,
|
||||
incelendi: false,
|
||||
};
|
||||
// CPV/OKAS — YAYINLANMADAN ÖNCE tamamlanır (bkz. cpv-classify.mjs). Bu
|
||||
// kaynakta kalemler (items[].kod) OKAS kodunu genelde zaten taşır, bu
|
||||
// yüzden 'existing' dalı çoğunlukla anında sonuç verir; AI'a düşmez.
|
||||
const cpv = classifyCpvByKeyword(tender) || { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
|
||||
tender.cpv = cpv.cpv;
|
||||
tender.cpvBolum = cpv.cpvBolum;
|
||||
tender.cpvLabel = cpv.cpvLabel;
|
||||
tender.cpvSource = cpv.cpvSource;
|
||||
return tender;
|
||||
}
|
||||
|
||||
// OKAS kalemlerinden ad listesi — sektör tahminini güçlendirir.
|
||||
@@ -508,3 +528,94 @@ export function sektorZenginSayi() {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- CPV / OKAS kalıcı önbelleği --------------------------------------------
|
||||
// AYNI ihale (IKN ile) bir daha ASLA yeniden sınıflandırılmasın — özellikle
|
||||
// AI çağrısı gerektiren durumlarda (maliyet + gecikme). sektor_zengin ile
|
||||
// aynı desen: id → sonuç haritası, toplu okuma/yazma, dosya başına tek
|
||||
// açma/kapama.
|
||||
|
||||
function cpvJsonDizi(ham) {
|
||||
if (!ham) return [];
|
||||
try {
|
||||
const v = JSON.parse(ham);
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** ikn → CPV sonucu haritası. Yalnızca istenen ikn'ler okunur. */
|
||||
export function cpvCacheOkuCoklu(iknler) {
|
||||
const harita = new Map();
|
||||
const gecerli = [...new Set((iknler || []).filter(Boolean))];
|
||||
if (!gecerli.length) return harita;
|
||||
const db = openDb();
|
||||
if (!db) return harita;
|
||||
try {
|
||||
for (let i = 0; i < gecerli.length; i += 400) {
|
||||
const parca = gecerli.slice(i, i + 400);
|
||||
const yer = parca.map(() => '?').join(',');
|
||||
const satirlar = db
|
||||
.prepare(`SELECT ikn, cpv, cpv_bolum, cpv_label, cpv_source FROM cpv_cache WHERE ikn IN (${yer})`)
|
||||
.all(...parca);
|
||||
for (const r of satirlar) {
|
||||
harita.set(r.ikn, {
|
||||
cpv: cpvJsonDizi(r.cpv),
|
||||
cpvBolum: cpvJsonDizi(r.cpv_bolum),
|
||||
cpvLabel: r.cpv_label || 'Genel',
|
||||
cpvSource: r.cpv_source || 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
return harita;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tek bir ikn için kayıtlı CPV sonucu — yoksa null. */
|
||||
export function cpvCacheOku(ikn) {
|
||||
if (!ikn) return null;
|
||||
return cpvCacheOkuCoklu([ikn]).get(ikn) || null;
|
||||
}
|
||||
|
||||
/** Toplu yazma: [{ikn, cpv, cpvBolum, cpvLabel, cpvSource}, ...] */
|
||||
export function cpvCacheYazCoklu(kayitlar) {
|
||||
if (!kayitlar?.length) return 0;
|
||||
const db = openDb({ create: true });
|
||||
if (!db) return 0;
|
||||
try {
|
||||
const st = db.prepare(`
|
||||
INSERT INTO cpv_cache (ikn, cpv, cpv_bolum, cpv_label, cpv_source, denenme_zamani)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(ikn) DO UPDATE SET
|
||||
cpv=excluded.cpv, cpv_bolum=excluded.cpv_bolum, cpv_label=excluded.cpv_label,
|
||||
cpv_source=excluded.cpv_source, denenme_zamani=excluded.denenme_zamani
|
||||
`);
|
||||
const now = Date.now();
|
||||
let n = 0;
|
||||
for (const k of kayitlar) {
|
||||
if (!k?.ikn) continue;
|
||||
st.run(
|
||||
k.ikn,
|
||||
JSON.stringify(k.cpv || []),
|
||||
JSON.stringify(k.cpvBolum || []),
|
||||
k.cpvLabel || 'Genel',
|
||||
k.cpvSource || 'none',
|
||||
now,
|
||||
);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tek bir ikn için CPV sonucunu kaydeder. */
|
||||
export function cpvCacheYaz(ikn, sonuc) {
|
||||
if (!ikn || !sonuc) return false;
|
||||
return cpvCacheYazCoklu([{ ikn, ...sonuc }]) > 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user