feat: derin analiz & yol haritasi + ihale dosyasi hazirlama; docx/xlsx/zip okunuyor

Doküman panelindeki "Tum Evraki Derin Analiz Et" dugmesi EKAP ilan metnini ve
ihaleye yuklenmis butun belgeleri TEK metinde birlestirip gonderiyor; model
sartnameyi ilanla ve eklerle capraz okuyabiliyor.

Derin analiz (mode='derin', ayri sistem istemi + 8000 token):
- Teklifi gecersiz kilabilecek noktalar (kirmizi uyari, en ustte)
- Gozden kacan ince detaylar (Kritik/Yuksek/Orta + madde no + neden onemli)
- Alinacak kalemler: aranan evsaflar, miktar, sartnamede geciyorsa fiyat
  (modele "TAHMIN URETME" talimati verildi, yoksa null)
- Belge kontrol listesi, kritik tarihler, maliyet kalemleri
- Teklife kadar yol haritasi (faz / kim / ne zaman / cikti)
- Idareye sorulacak belirsizlikler
Standart hizli ozet modu aynen duruyor.

Ihale Dosyasi Hazirla (rapor sonunda, acilir bolum):
- Eksik firma bilgileri kullaniciya sorulur, profilden gelenler sorulmaz,
  girilenler tarayicida saklanir
- Kalem fiyatlandirma tablosu, canli toplam + yaziyla karsiligi
- Teklif Mektubu, Birim Fiyat Teklif Cetveli, Evrak Kontrol Listesi
- Belgeler yazdirilabilir HTML: jsPDF'in gomulu fontlari WinAnsi, s/g/i/I
  tasimiyor (bkz. services/pdf.js ASCII basliklari) — resmi teklif mektubunda
  bozuk Turkce kabul edilemez

docx/xlsx/zip metin cikarimi (server/office-text.mjs):
- ihaleab projesindeki officeText.ts'ten tasindi, harici bagimlilik yok
- Sunucuda: tarayicida cozucu yok, baytlar zaten BELGE_DIR'de
- Tablolar " | " ile korunuyor, ic ice zip aciliyor, ~$ gecici dosyalari atlanir
- Yeni uc: GET /api/me/documents/text
- Yukleme kabul listesi .doc/.rar cikarildi (karsiligi yok), .xlsx eklendi

fix: yuklenen belgelerdeki "AI analiz" dugmesi bozuktu — extractText nesne
donduruyor ({text,pageCount,usedPages}), kod onu duz metin sanip gonderiyordu;
sunucuya "[object Object]" gidiyor ve analiz reddediliyordu. Uzunluk kontrolu
de nesne uzerinde undefined verdigi icin hatayi yutuyordu.

fix: analiz dokuman panelinden baslatildiginda modal kendi isAnalyzing'ini
bilmiyordu, kullanici 1-2 dakika bos modal goruyordu — ortak aiAnalyzing sinyali
This commit is contained in:
2026-08-31 14:53:46 +03:00
parent be79f9366a
commit 3eb0f91823
10 changed files with 1584 additions and 51 deletions
+368
View File
@@ -0,0 +1,368 @@
/**
* DOCX / XLSX / ZIP metin çıkarımı — harici bağımlılık YOK.
* ihaleab projesindeki backend/src/lib/officeText.ts dosyasından taşındı.
*
* NEDEN SUNUCUDA: ihaleist'te PDF metni tarayıcıda (pdfjs) çıkarılıyor, ama
* docx/xlsx/zip için tarayıcıda çözücü yok. Belgelerin baytları zaten
* sunucuda duruyor (BELGE_DIR), Node'un yerleşik zlib'i de burada.
*/
// ZIP / DOCX / XLSX metin çıkarımı — harici bağımlılık YOK.
//
// NEDEN: ihale şartnameleri pratikte .docx ve .xlsx olarak yayımlanıyor ve
// çoğu zaman tek bir .zip arşivinin içinde geliyor (ör. Letonya EIS'te
// "Nolikums ar pielikumiem.zip" — 17 dosya, içinde 12 kısmın teknik
// şartnamesi ve mali teklif formu). pdf-parse yalnızca PDF okuyabildiği için
// bu dosyalardan hiç metin çıkarılamıyordu; aranan birim miktarlar
// (kaç adet polo gömlek, kaç çift bot) tam olarak bu dosyaların içinde.
//
// TASARIM: OOXML dosyalarının tamamı ZIP arşividir ve içerikleri düz XML'dir.
// Node'un yerleşik zlib'i ile küçük bir ZIP okuyucu yazmak, yeni bir paket
// eklemekten daha az risk taşıyor (projede zaten HTML/XML için regex tabanlı
// yaklaşım benimsenmiş, bkz. tenderDocuments.ts stripHtml).
//
// SINIRLAR: ZIP64 arşivleri ve şifreli arşivler desteklenmez — bu durumlarda
// anlamlı bir hata döner, sessizce bozuk veri üretilmez.
import { inflateRawSync } from 'node:zlib';
const EOCD_SIG = 0x06054b50;
const CENTRAL_SIG = 0x02014b50;
const LOCAL_SIG = 0x04034b50;
const MAX_ENTRIES = 400;
const MAX_ENTRY_BYTES = 80 * 1024 * 1024;
/**
* ZIP arşivinin merkezî dizinini okuyup girdileri döndürür. İçerik tembel
* açılır (read()) — 17 dosyalık bir arşivde yalnızca ihtiyaç duyulanlar
* açılsın diye.
*/
export function readZip(buf) {
const eocd = findEocd(buf);
if (eocd < 0) throw new Error("ZIP dizini bulunamadı (geçersiz veya ZIP64 arşiv).");
const entryCount = buf.readUInt16LE(eocd + 10);
const centralOffset = buf.readUInt32LE(eocd + 16);
if (centralOffset >= buf.length) throw new Error("ZIP merkezî dizin konumu geçersiz.");
const entries = [];
let p = centralOffset;
for (let i = 0; i < Math.min(entryCount, MAX_ENTRIES); i++) {
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CENTRAL_SIG) break;
const method = buf.readUInt16LE(p + 10);
const flags = buf.readUInt16LE(p + 8);
const compSize = buf.readUInt32LE(p + 20);
const uncompSize = buf.readUInt32LE(p + 24);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const localOffset = buf.readUInt32LE(p + 42);
const name = decodeName(buf.subarray(p + 46, p + 46 + nameLen), flags);
const isDirectory = name.endsWith("/") || uncompSize === 0;
entries.push({
name,
size: uncompSize,
isDirectory,
read: () => {
if (flags & 0x1) throw new Error(`Şifreli arşiv girdisi okunamaz: ${name}`);
if (compSize > MAX_ENTRY_BYTES) throw new Error(`Arşiv girdisi çok büyük: ${name}`);
if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== LOCAL_SIG) {
throw new Error(`Arşiv girdisi konumu geçersiz: ${name}`);
}
const lNameLen = buf.readUInt16LE(localOffset + 26);
const lExtraLen = buf.readUInt16LE(localOffset + 28);
const start = localOffset + 30 + lNameLen + lExtraLen;
const raw = buf.subarray(start, start + compSize);
if (method === 0) return Buffer.from(raw);
if (method === 8) return inflateRawSync(raw);
throw new Error(`Desteklenmeyen sıkıştırma yöntemi (${method}): ${name}`);
},
});
p += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** EOCD imzasını sondan geriye doğru arar (yorum alanı en fazla 64KB olabilir). */
function findEocd(buf) {
const min = Math.max(0, buf.length - 65_557);
for (let i = buf.length - 22; i >= min; i--) {
if (buf.readUInt32LE(i) === EOCD_SIG) return i;
}
return -1;
}
/** ZIP adları UTF-8 bayrağı yoksa CP437'dir; pratikte UTF-8 denemesi yeterli. */
function decodeName(raw, flags) {
if (flags & 0x800) return raw.toString("utf8");
const asUtf8 = raw.toString("utf8");
return asUtf8.includes("�") ? raw.toString("latin1") : asUtf8;
}
/* ---------------- XML yardımcıları ---------------- */
function decodeXmlEntities(s) {
return s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&amp;/g, "&");
}
function stripTags(xml) {
return decodeXmlEntities(xml.replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").trim();
}
function findEntry(entries, path) {
return entries.find((e) => e.name === path || e.name === path.replace(/^\//, "")) ?? null;
}
/* ---------------- DOCX ---------------- */
/**
* DOCX'ten metin çıkarır. Tablolar korunur: hücreler " | ", satırlar yeni
* satır. Miktar tabloları düz metne ezilirse hangi sayının hangi kaleme ait
* olduğu kaybolduğu için bu şart.
*/
export function docxToText(buf) {
const entries = readZip(buf);
const doc = findEntry(entries, "word/document.xml");
if (!doc) throw new Error("DOCX içinde word/document.xml bulunamadı.");
let xml = doc.read().toString("utf8");
// Hücre/satır sınırları ÖNCE işaretleyiciye çevrilir. Doğrudan " | " ve "\n"
// yazılamaz: bir tablo hücresi kendi içinde <w:p> barındırdığı için paragraf
// sonu ayracı kendi satırına düşürür ve satır temizliğinde silinirdi.
const CELL = "";
const ROW = "";
xml = xml
.replace(/<w:br\b[^>]*\/?>/g, "\n")
.replace(/<w:tab\b[^>]*\/?>/g, "\t")
.replace(/<\/w:tc>/g, CELL)
.replace(/<\/w:tr>/g, ROW)
.replace(/<\/w:p>/g, "\n");
const duz = stripTagsPreservingBreaks(xml)
// İşaretleyicinin etrafındaki boşluk/satır sonlarını yut.
.replace(new RegExp(`\\s*${CELL}\\s*`, "g"), " | ")
.replace(new RegExp(`\\s*${ROW}\\s*`, "g"), "\n");
return tidyLines(duz);
}
/** stripTags'in satır sonlarını koruyan hali. */
function stripTagsPreservingBreaks(xml) {
return decodeXmlEntities(xml.replace(/<[^>]+>/g, ""));
}
function tidyLines(text) {
return text
.split("\n")
.map((l) => l.replace(/[ \t ]+/g, " ").replace(/\s*\|\s*/g, " | ").trim())
.map((l) => l.replace(/^\|\s*/, "").replace(/\s*\|$/, "").trim())
.filter((l) => l.length > 0)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/* ---------------- XLSX ---------------- */
/** A1 -> 0, B1 -> 1 ... (sütun harflerini indekse çevirir) */
function colIndex(ref) {
const m = /^([A-Z]+)/.exec(ref);
if (!m) return 0;
let n = 0;
for (const ch of m[1]) n = n * 26 + (ch.charCodeAt(0) - 64);
return n - 1;
}
function parseSharedStrings(entries) {
const e = findEntry(entries, "xl/sharedStrings.xml");
if (!e) return [];
const xml = e.read().toString("utf8");
const out = [];
// Her <si> bir dize; içinde birden çok <t> parçası olabilir (biçimli metin).
for (const si of xml.split(/<si\b[^>]*>/).slice(1)) {
const govde = si.split("</si>")[0] ?? "";
const parcalar = [...govde.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1]);
out.push(decodeXmlEntities(parcalar.join("")).trim());
}
return out;
}
function sheetNames(entries) {
const wb = findEntry(entries, "xl/workbook.xml");
if (!wb) return [];
const xml = wb.read().toString("utf8");
return [...xml.matchAll(/<sheet\b[^>]*\bname="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1]));
}
/**
* XLSX'ten metin çıkarır. Her sayfa "### <sayfa adı>" başlığıyla, satırlar
* " | " ayraçlı olarak döner — mali teklif formundaki kalem/miktar tabloları
* bu biçimde okunabilir kalıyor.
*/
export function xlsxToText(buf) {
const entries = readZip(buf);
const shared = parseSharedStrings(entries);
const adlar = sheetNames(entries);
const sayfalar = entries
.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name))
.sort((a, b) => {
const na = Number(/sheet(\d+)\.xml/.exec(a.name)?.[1] ?? 0);
const nb = Number(/sheet(\d+)\.xml/.exec(b.name)?.[1] ?? 0);
return na - nb;
});
if (sayfalar.length === 0) throw new Error("XLSX içinde çalışma sayfası bulunamadı.");
const bloklar = [];
sayfalar.forEach((sheet, idx) => {
const xml = sheet.read().toString("utf8");
const satirlar = [];
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
const hucreler = [];
for (const c of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = c[1];
const govde = c[2];
const ref = /\br="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? "";
const tip = /\bt="([^"]*)"/.exec(attrs)?.[1] ?? "";
const vRaw = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(govde)?.[1];
let deger = "";
if (tip === "s") {
const i = Number(vRaw);
deger = Number.isInteger(i) ? shared[i] ?? "" : "";
} else if (tip === "inlineStr") {
deger = stripTags(govde);
} else if (vRaw != null) {
deger = decodeXmlEntities(vRaw).trim();
}
if (!deger) continue;
const i = ref ? colIndex(ref) : hucreler.length;
while (hucreler.length < i) hucreler.push("");
hucreler[i] = deger;
}
// Kendi kendine boş satırları atla.
const satir = hucreler.join(" | ").replace(/(\s*\|\s*)+$/, "").trim();
if (satir.replace(/[|\s]/g, "")) satirlar.push(satir);
}
if (satirlar.length > 0) {
bloklar.push(`### ${adlar[idx] ?? `Sayfa ${idx + 1}`}\n${satirlar.join("\n")}`);
}
});
return bloklar.join("\n\n").trim();
}
/* ---------------- Dağıtıcı ---------------- */
export function isDocx(name, buf) {
return /\.docx$/i.test(name) || (/\.doc$/i.test(name) === false && hasEntry(buf, "word/document.xml"));
}
export function isXlsx(name) {
return /\.xlsx$/i.test(name);
}
export function isZip(name, buf) {
return /\.zip$/i.test(name) || (buf.length > 4 && buf.readUInt32LE(0) === LOCAL_SIG);
}
function hasEntry(buf, path) {
try {
return readZip(buf).some((e) => e.name === path);
} catch {
return false;
}
}
/**
* Dosya adına ve içeriğine bakarak uygun çıkarıcıyı seçer. Tanınmayan tür
* için null döner (çağıran taraf PDF/HTML yollarına düşebilsin diye).
*/
export function officeToText(name, buf) {
try {
if (isXlsx(name)) return xlsxToText(buf);
if (/\.docx$/i.test(name)) return docxToText(buf);
} catch {
return null;
}
return null;
}
/* ---------------- Arşiv (zip) ---------------- */
// Derin analiz zaten 90.000 karakter okuyor; tek bir arşivin bunun tamamını
// yemesini istemiyoruz — birden çok belge birleştiriliyor.
const MAX_ARSIV_KARAKTER = 60000;
/**
* ZIP/DOCX/XLSX tamponundan metin çıkarır. Arşivse içindeki Office dosyaları
* tek tek çözülüp "## <dosya adı>" başlıklarıyla birleştirilir. İhale
* paketleri iç içe zip olabildiği için bir seviye iç arşiv de açılır.
*/
export function arsivMetni(name, buf, depth = 0) {
const direct = officeToText(name, buf);
if (direct) return direct;
let entries;
try {
entries = readZip(buf);
} catch (e) {
throw new Error(`Arşiv okunamadı: ${String(e.message || e).slice(0, 160)}`);
}
const bloklar = [];
let toplam = 0;
for (const entry of entries) {
if (toplam >= MAX_ARSIV_KARAKTER) break;
if (entry.isDirectory) continue;
// Word'ün geçici kilit dosyaları (~$...) içerik taşımaz.
if (/(^|\/)~\$/.test(entry.name)) continue;
const kisaAd = entry.name.split('/').pop() ?? entry.name;
let metin = null;
try {
const icerik = entry.read();
if (depth < 1 && /\.zip$/i.test(entry.name)) {
metin = arsivMetni(kisaAd, icerik, depth + 1);
} else {
metin = officeToText(entry.name, icerik);
}
} catch {
continue; // tek bozuk dosya tüm paketi düşürmesin
}
if (!metin) continue;
bloklar.push(`## ${kisaAd}\n${metin}`);
toplam += metin.length;
}
if (bloklar.length === 0) throw new Error('Arşiv içinde okunabilir doküman bulunamadı.');
return bloklar.join('\n\n').slice(0, MAX_ARSIV_KARAKTER);
}
/** Sunucunun metin çıkarabildiği uzantılar (PDF tarayıcıda okunuyor). */
export function sunucuOkuyabilir(ad) {
return /\.(docx|xlsx|zip)$/i.test(String(ad || ''));
}