Persist observed tender source revisions and expose paginated history
This commit is contained in:
@@ -671,3 +671,13 @@ Takip/hazırlık kaydıyla saklanan ihale özetine durum ve doküman sürümü e
|
||||
Kullanıcı durumu İKN üzerinden birleştirilir; kaynak kimliği değişse de not ve fiyat taslağı korunur. İhale durumunun değişmesi hazırlık kaynak onayını da geçersizleştirir. Bu uyarı son kaydedilen bilgilere göre karşılaştırmadır; tüm zeyilname olaylarının kalıcı geçmişi değildir. Yeni kayıtta karşılaştırma tabanı güncellenir.
|
||||
|
||||
Test: tarih/saat/iptal/sürüm değişiminde doğru dört fark; değişen kaynak id'sine rağmen kayıtların korunması; eski bilinmeyen alanlarda yanlış uyarı olmaması; yeni tabanla farkın temizlenmesi. Tüm 144 test ve üretim derlemesi geçti; ayrıntı getirme bağlantısı sonrasında derleme yeniden doğrulandı. Yeni uyarının tarayıcı kabulü ve tam kaynak sürüm arşivi açık. Canlı yayın yapılmadı.
|
||||
|
||||
### D04 — Kalıcı kaynak gözlem geçmişi
|
||||
|
||||
Ortak veri dizininde `tender-history.sqlite` eklendi. Havuz derlemesinde ve havuzla eşleşen ilan.gov.tr ayrıntısı okunduğunda, izin verilen genel kaynak alanlarının SHA-256 karşılığı son kaynak sürümüyle karşılaştırılır. Aynı içerik yeni kayıt üretmez; A → B → A dönüşü iki ayrı değişiklik olarak saklanır. Eksik liste alanı önceki ayrıntıyı silmez. Kullanıcı notları, dosyaları ve firma verileri gözleme alınmaz.
|
||||
|
||||
Resmi İKN ortak ihale anahtarıdır; doğrudan temin numarası kurumla birlikte kapsamlanır. Kaynak kimlikleri ayrı izlenir; liste/ayrıntı farklı kaynak kanallarıdır. Gözlem zamanı kayıt zamanıdır, resmi zeyilname tarihi değildir. Kaynaklar arasındaki çelişkiyi birleştirip hangisinin doğru olduğunu belirlemez.
|
||||
|
||||
Oturum gerektiren sayfalı geçmiş API'si ve ayrıntı ekranında isteğe bağlı geçmiş görünümü eklendi. Önce/sonra metinleri açılabilir, daha eski kayıtlar getirilebilir. Boş geçmiş değişiklik yokluğunun kanıtı olarak sunulmaz. Kayıt hatası havuz uyarısına, okuma hatası yeniden denemeli ekran durumuna yansır. Kaynak kaydı yalnız uygulamanın gözlemlediği andan itibaren birikir; geçmişe dönük resmi zeyilnameler yüklenmiş değildir.
|
||||
|
||||
Doğrulama: gerçek SQLite yeniden açılışları, aynı veriyi tekrar alma, değer geri dönüşü, kaynak ayrımı, kurum kapsamlı DT kimliği, sayfalama; HTTP oturum/boş sonuç/geçersiz sayfa/metot. Tüm 147 test ve üretim derlemesi geçti. Son depo-açılış temizliği değişikliğinden sonra iki depo testi tekrar geçti. Canlı kaynak birikimi, tarayıcıdan dolu geçmiş ve zeyilname bildirim kabulü açık. Bu volume kullanıcı yedeğinin kapsamı dışında; tam D11 yedeğine ayrıca dahil edilmeli. Canlı yayın yapılmadı.
|
||||
|
||||
@@ -31,3 +31,5 @@ Yedek aracı SQLite snapshot oluşturur, bütünlük kontrolü yapar, referansl
|
||||
## Bekleyen dosya temizliği
|
||||
|
||||
`20260906_document_file_cleanup` migration'ı, mantıksal olarak silinmiş belgelerin fiziksel dosyalarını kalıcı kuyrukta tutar. Erişim kaydı ve temizleme görevi aynı transaction'da değişir; sonraki veritabanı açılışları temizliği yeniden dener. Dosya sistemi sorunu sürüyorsa kuyrukta kalır. Bu dosyalar aktif belge yedeğine dahil değildir; yedekte kuyruk kaydı bulunup dosya bulunmazsa yeniden açılışta ENOENT başarılı temizleme olarak ele alınır. Fiziksel silmenin anında tamamlandığı varsayılmamalı; işletim kontrolünde kuyruk büyüklüğü ve volume izinleri izlenmelidir.
|
||||
|
||||
Kaynak gözlem geçmişi `IHALEIST_SHARED_DATA_DIR/tender-history.sqlite` içinde saklanır (değişken yoksa `server/data`). Kullanıcı verisi yedek aracı bu depoyu kapsamaz; tam yayın/geri dönüş provasında ortak veri volume'uyla birlikte ayrıca yedeklenmelidir.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observeTenders, tenderHistory } from './tender-history.mjs';
|
||||
import { requestLifetime } from './request-lifetime.mjs';
|
||||
import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
|
||||
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
|
||||
@@ -582,6 +583,11 @@ async function getDetail(ilanId) {
|
||||
const raw = await ilanDetail(ilanId);
|
||||
const text = htmlToText(raw.content || '');
|
||||
const detail = { ...announcementFields(text), adNo: raw.adNo || null };
|
||||
const sourceTender=cache.tenders.find(t=>t.id===`ilan-${ilanId}`);
|
||||
if(sourceTender) {
|
||||
try { observeTenders([{...sourceTender,...detail,id:`ilan-${ilanId}:detail`}]); }
|
||||
catch { detail.sourceWarning='İlan alındı ancak kaynak geçmişi kaydedilemedi.'; }
|
||||
}
|
||||
cache.detail.set(ilanId, detail);
|
||||
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
|
||||
return detail;
|
||||
@@ -661,6 +667,8 @@ async function tendersPayload(force = false) {
|
||||
}
|
||||
|
||||
const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]);
|
||||
let historyWarning=null;
|
||||
try { observeTenders(combined); } catch { historyWarning='Kaynak geçmişi kaydedilemedi; değişiklik arşivi eksik olabilir.'; };
|
||||
return {
|
||||
success: true,
|
||||
source: sources.join(' + '),
|
||||
@@ -669,6 +677,7 @@ async function tendersPayload(force = false) {
|
||||
ilanCount: c.totalCount,
|
||||
todayCount: bugunEklenenSayisi(combined),
|
||||
loadedUniqueCount: combined.length,
|
||||
historyWarning,
|
||||
dtCount: dt.totalCount,
|
||||
historyCount: hist.totalCount,
|
||||
tenders: combined,
|
||||
@@ -1476,6 +1485,15 @@ const server = http.createServer(async (req, res) => {
|
||||
} finally { lifetime.dispose(); }
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tenders/source-history') {
|
||||
if (!userFromAuthHeader(req.headers.authorization)) return json(res,401,{success:false,error:'Oturum gerekli.'});
|
||||
if (req.method !== 'GET') return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
|
||||
const before=url.searchParams.get('before');
|
||||
if(before && (!/^\d+$/.test(before) || !Number.isSafeInteger(Number(before)))) return json(res,400,{success:false,error:'Geçersiz sayfa.'});
|
||||
try { return json(res,200,{success:true,...tenderHistory({ikn:url.searchParams.get('ikn'),kurum:url.searchParams.get('kurum')},{before:before?Number(before):null})}); }
|
||||
catch { return json(res,503,{success:false,error:'Kaynak geçmişi alınamadı. Yeniden deneyin.'}); }
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tenders') {
|
||||
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
|
||||
return json(res, 200, out);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {mkdirSync} from 'node:fs';
|
||||
import {dirname,join} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createHash} from 'node:crypto';
|
||||
const root=process.env.IHALEIST_SHARED_DATA_DIR || join(dirname(fileURLToPath(import.meta.url)),'data');
|
||||
const fields=['baslik','kurum','tarih','saat','durum','alimTuru','aciklama','documentVersion'];
|
||||
function key(t) {
|
||||
const ikn=String(t.ikn || '').trim();
|
||||
if(!ikn)return null;
|
||||
return /^\d{4}\/\d+$/.test(ikn) ? ikn : `DT:${ikn}:${String(t.kurum || '').trim()}`;
|
||||
}
|
||||
function open() {
|
||||
mkdirSync(root,{recursive:true});
|
||||
const db=new DatabaseSync(join(root,'tender-history.sqlite'));
|
||||
try { db.exec(`PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,tender_key TEXT NOT NULL,source_id TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL,content_hash TEXT NOT NULL,snapshot TEXT NOT NULL,changes TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS observation_tender ON observations(tender_key,id);
|
||||
CREATE INDEX IF NOT EXISTS observation_source ON observations(tender_key,source_id,id);`);
|
||||
return db;
|
||||
} catch(error) {db.close();throw error;}
|
||||
}
|
||||
export function observeTenders(tenders) {
|
||||
const db=open();
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const last=db.prepare('SELECT content_hash,snapshot FROM observations WHERE tender_key=? AND source_id=? ORDER BY id DESC LIMIT 1');
|
||||
const insert=db.prepare('INSERT INTO observations(tender_key,source_id,observed_at,content_hash,snapshot,changes) VALUES (?,?,?,?,?,?)');
|
||||
for(const t of tenders) {
|
||||
const k=key(t);if(!k)continue;
|
||||
const source=String(t.id || t.source || 'unknown');
|
||||
const prev=last.get(k,source);
|
||||
// A list response missing detail text must not erase an earlier observed value.
|
||||
const snapshot={...(prev ? JSON.parse(prev.snapshot) : {})};
|
||||
for(const field of fields)if(t[field]!=null && t[field]!=='')snapshot[field]=String(t[field]);
|
||||
const canonical=JSON.stringify(Object.fromEntries(fields.filter(f=>snapshot[f]!=null).map(f=>[f,snapshot[f]])));
|
||||
const hash=createHash('sha256').update(canonical).digest('hex');
|
||||
if(prev?.content_hash===hash)continue;
|
||||
const before=prev ? JSON.parse(prev.snapshot) : {};
|
||||
const changes=prev ? fields.filter(f=>before[f]!==snapshot[f]).map(field=>({field,before:before[field]??null,after:snapshot[field]??null})) : [];
|
||||
insert.run(k,source,new Date().toISOString(),hash,canonical,JSON.stringify(changes));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch(error) {db.exec('ROLLBACK');throw error;} finally {db.close();}
|
||||
}
|
||||
export function tenderHistory(tender,{before=null,limit=20}={}) {
|
||||
const k=key(tender);if(!k)throw new Error('İhale kimliği gerekli.');
|
||||
const db=open();
|
||||
try {
|
||||
const rows=db.prepare('SELECT id,source_id,observed_at,changes FROM observations WHERE tender_key=? AND id<? ORDER BY id DESC LIMIT ?')
|
||||
.all(k,before || Number.MAX_SAFE_INTEGER,limit+1);
|
||||
const more=rows.length>limit, page=rows.slice(0,limit);
|
||||
return {records:page.map(r=>({id:r.id,sourceId:r.source_id,observedAt:r.observed_at,changes:JSON.parse(r.changes)})),nextCursor:more?page.at(-1).id:null};
|
||||
} finally {db.close();}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TenderSourceHistory } from './TenderSourceHistory';
|
||||
import React, { useState, useEffect, useRef, useId } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { BidPackageBuilder } from './BidPackageBuilder';
|
||||
@@ -105,6 +106,7 @@ export const TenderDetailModal = () => {
|
||||
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby={dialogTitleId} tabIndex={-1} onKeyDown={handleDialogKey} className="bg-white w-full max-w-6xl rounded-2xl shadow-2xl border border-slate-200 overflow-hidden my-auto flex flex-col max-h-[92vh]">
|
||||
|
||||
{(actionError || saveError) && <p role="alert" className="p-3 bg-red-50 text-red-900">{actionError || saveError}</p>}
|
||||
{tender.ikn && <TenderSourceHistory key={`${authUser?.email}:${tender.ikn}:${tender.kurum}`} tender={tender} />}
|
||||
{tender.sourceChanges?.length > 0 && <div role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">
|
||||
<p className="font-bold">Son kaydettiğiniz bilgilere göre ihale değişmiş</p>
|
||||
<ul className="list-disc pl-5">{tender.sourceChanges.map(change => <li key={change.field}>{change.label}: {change.before} → {change.after}</li>)}</ul>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, {useEffect,useRef,useState} from 'react';
|
||||
import {getToken} from '../services/auth';
|
||||
const labels={baslik:'Başlık',kurum:'İdare',tarih:'Tarih',saat:'Saat',durum:'Durum',alimTuru:'Alım türü',aciklama:'İlan metni',documentVersion:'Doküman sürümü'};
|
||||
export function TenderSourceHistory({tender}) {
|
||||
const [state,setState]=useState({records:[],nextCursor:null,loaded:false,error:null,loading:false});
|
||||
const active=useRef(null);
|
||||
useEffect(()=>()=>{const pending=active.current;active.current=null;pending?.abort();},[]);
|
||||
const load=async(before=null)=>{
|
||||
if(active.current)return;
|
||||
const controller=new AbortController();active.current=controller;
|
||||
const token=getToken(),timer=setTimeout(()=>controller.abort(),30000);
|
||||
setState(s=>({...s,loading:true,error:null}));
|
||||
try {
|
||||
const query=new URLSearchParams({ikn:tender.ikn,kurum:tender.kurum || ''});if(before)query.set('before',before);
|
||||
const res=await fetch('/api/tenders/source-history?'+query,{headers:{Authorization:`Bearer ${token}`},signal:controller.signal});
|
||||
const data=await res.json();
|
||||
if(!res.ok || !data.success || !Array.isArray(data.records))throw new Error(data.error || 'Kaynak geçmişi alınamadı.');
|
||||
if(!controller.signal.aborted && getToken()===token)setState(s=>({records:before?[...s.records,...data.records]:data.records,nextCursor:data.nextCursor,loaded:true,error:null,loading:false}));
|
||||
} catch(error) {
|
||||
if(active.current===controller && getToken()===token)setState(s=>({...s,loading:false,error:controller.signal.aborted?'Kaynak geçmişi zamanında alınamadı. Yeniden deneyin.':error.message}));
|
||||
} finally {clearTimeout(timer);if(active.current===controller)active.current=null;}
|
||||
};
|
||||
return <details className="p-3 border-b text-sm">
|
||||
<summary className="cursor-pointer font-semibold">Kaynak değişiklik geçmişi</summary>
|
||||
<p className="mt-2 text-xs text-slate-600">Uygulamanın gözlemlediği sürümler gösterilir. Gözlem zamanı resmi değişiklik tarihi değildir; kayıt öncesindeki veya iki kontrol arasındaki değişiklikler bulunmayabilir. Farklı kaynaklar ayrı izlenir.</p>
|
||||
{!state.loaded && <button onClick={()=>load()} disabled={state.loading} className="mt-2 px-3 py-2 border rounded">Geçmişi yükle</button>}
|
||||
{state.loading && <p role="status">Kaynak geçmişi yükleniyor…</p>}
|
||||
{state.error && <div role="alert"><p>{state.error}</p><button onClick={()=>load(state.loaded?state.nextCursor:null)} disabled={state.loading} className="underline py-2">Yeniden dene</button></div>}
|
||||
{state.loaded && !state.records.length && <p className="mt-2">Henüz gözlem kaydı yok. Bu, ihale bilgilerinin hiç değişmediğini göstermez.</p>}
|
||||
<ol className="space-y-3 mt-3">{state.records.map(record=><li key={record.id} className="border rounded p-2">
|
||||
<p>{new Date(record.observedAt).toLocaleString('tr-TR')} · {record.sourceId}</p>
|
||||
{!record.changes.length?<p>Bu kaynak için ilk gözlem.</p>:record.changes.map(change=><details key={change.field} className="mt-1">
|
||||
<summary className="cursor-pointer">{labels[change.field] || change.field} değişti</summary>
|
||||
<p className="whitespace-pre-wrap break-words">Önce: {change.before ?? 'Bilinmiyor'}</p>
|
||||
<p className="whitespace-pre-wrap break-words">Sonra: {change.after ?? 'Bilinmiyor'}</p>
|
||||
</details>)}
|
||||
</li>)}</ol>
|
||||
{state.nextCursor && <button onClick={()=>load(state.nextCursor)} disabled={state.loading} className="mt-2 px-3 py-2 border rounded">Daha eski kayıtlar</button>}
|
||||
</details>;
|
||||
}
|
||||
@@ -268,6 +268,7 @@ export const AppProvider = ({ children }) => {
|
||||
const data = await fetchLiveTenders({ refresh });
|
||||
setTenders(prev => applyTenderStates(mergeUserState(data.tenders, prev), savedTenderStates.current));
|
||||
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
|
||||
if(data.historyWarning)setLiveDataError(data.historyWarning);
|
||||
setLiveStats({
|
||||
toplamIhale: data.totalCount ?? 0,
|
||||
bugunEklenen: data.todayCount ?? 0,
|
||||
|
||||
@@ -172,3 +172,11 @@ test('concurrent API key requests never exceed quota and malformed input creates
|
||||
assert.equal((await request('/api/me/apikeys','POST',{ad:'replacement'},auth)).status,200);
|
||||
assert.equal((await request('/api/me/apikeys','PUT',{},auth)).status,405);
|
||||
});
|
||||
|
||||
test('source history requires authentication and distinguishes empty results from invalid pagination',async()=>{
|
||||
assert.equal((await request('/api/tenders/source-history?ikn=2026/123','GET',null,null)).status,401);
|
||||
const response=await request('/api/tenders/source-history?ikn=2026/123');
|
||||
assert.equal(response.status,200);assert.deepEqual(response.data.records,[]);assert.equal(response.data.nextCursor,null);
|
||||
assert.equal((await request('/api/tenders/source-history?ikn=2026/123&before=bad')).status,400);
|
||||
assert.equal((await request('/api/tenders/source-history?ikn=2026/123','POST',{})).status,405);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test,{after} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
const root=mkdtempSync(join(tmpdir(),'ihaleist-history-'));
|
||||
process.env.IHALEIST_SHARED_DATA_DIR=root;
|
||||
const {observeTenders,tenderHistory}=await import('../server/tender-history.mjs');
|
||||
after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
test('source observations persist changes, ignore identical reloads, and retain reversions',()=>{
|
||||
const t={id:'ilan-1',ikn:'2026/100',kurum:'A',tarih:'10.09.2026',saat:'10:00',aciklama:'Tam ilan metni'};
|
||||
observeTenders([t]);observeTenders([{...t,notes:['private'],aciklama:undefined}]);
|
||||
assert.equal(tenderHistory(t).records.length,1);
|
||||
observeTenders([{...t,saat:'11:00'}]);observeTenders([t]);
|
||||
const history=tenderHistory(t,{limit:2});
|
||||
assert.equal(history.records.length,2);assert.ok(history.nextCursor);
|
||||
assert.deepEqual(history.records[0].changes,[{field:'saat',before:'11:00',after:'10:00'}]);
|
||||
assert.equal(tenderHistory(t,{before:history.nextCursor}).records.length,1);
|
||||
});
|
||||
test('different sources and institutional DT identifiers cannot overwrite one another',()=>{
|
||||
const a={id:'dt-1',ikn:'26DT123',kurum:'A',durum:'Açık'};
|
||||
const b={...a,kurum:'B',durum:'İptal'};
|
||||
observeTenders([a,b]);assert.equal(tenderHistory(a).records.length,1);
|
||||
assert.equal(tenderHistory(b).records.length,1);
|
||||
const c={id:'ilan-2',ikn:'2026/200',kurum:'A',durum:'Açık'};
|
||||
observeTenders([c,{...c,id:'history-2',durum:'Sonuçlandı'},c]);
|
||||
assert.equal(tenderHistory(c).records.length,2);
|
||||
assert.ok(tenderHistory(c).records.every(r=>r.changes.length===0));
|
||||
});
|
||||
Reference in New Issue
Block a user