fix: separate private company documents from shared tender item imports
This commit is contained in:
@@ -279,3 +279,11 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont
|
||||
- Oluşan blob bağlantısı yeni dosya hazırlanırken veya ekran kapanırken temizleniyor. Oturum değişince eski hazır dosya bağlantısı gösterilmiyor. Çıktı tıklama anındaki sonuç kümesini temsil ediyor; yeni filtre için yeniden hazırlama gerekiyor.
|
||||
- Tarayıcıda beş sentetik ihale için dosya hazır durumu ve `.xlsx` bağlantısı görüldü, doğrudan bağlantı tıklandı. Kanıt: `evidence/qa-excel-ready-link.txt`. Üretim derlemesi geçti; dosya içeriği için önceki XLSX roundtrip regresyonu geçerlidir.
|
||||
- İndirme geçmişini kontrol etmek üzere chrome://downloads erişimi tarayıcı URL güvenlik politikası tarafından engellendi; başka yoldan bu geçmişe erişilmedi. Diskte nihai dosya ve masaüstü Excel açılışı doğrulanmış sayılmıyor. Önceki indirme olayı zaman aşımının kesin kök nedeni henüz belirlenmedi.
|
||||
|
||||
### D01 / D06 — Firma belgesi ve ortak kalem havuzu ayrımı
|
||||
|
||||
- Belgelerde kalıcı `tur` alanı (firma/ihale) ve idempotent migration eklendi. Eski belgeler firma türünde kalıyor; kategori adından yayın yetkisi türetilmiyor. Liste türü açıkça gösteriyor.
|
||||
- Genel belge yüklemesi firma olarak kaydediliyor ve otomatik kalem yayınlamıyor. İhale detayındaki doküman yükleme ve açık toplu ihale yükleme akışları ihale türünü belirtiyor.
|
||||
- /api/kalem/cikar artık belge kimliği, sahiplik, ihale türü ve aynı İKN bağı şartlarını kontrol ediyor. Başka hesaba ait dosya, farklı ihale, firma belgesi veya belgesiz metin ortak havuza yazılamıyor. PDF istemcileri yüklenen belge kimliğini iletiyor.
|
||||
- Kanıt: gerçek HTTP akışında firma ve ihale dosyası yükleme; kategori adını değiştirerek yükseltme girişimi; farklı hesap/İKN ve belgesiz çağrı reddi; doğru dokümanla boş cetvel ayrıştırma başarı yolu test edildi. Eski şema migration testinde varsayılan firma türü doğrulandı. 70/70 test ve build geçti.
|
||||
- Yayın notu: frontend ve backend birlikte yayımlanmalı; eski istemcinin belgesiz kalem POST’u artık reddedilir. Önceden yüklenmiş resmi dokümanlar otomatik ortak yayın yetkisi kazanmaz; kontrollü yeniden sınıflandırma akışı henüz yok. Geçmişte havuza yazılmış kayıtların köken denetimi ve gerçek PDF/OCR bütünlüğü ayrıca açık.
|
||||
|
||||
+8
-2
@@ -746,6 +746,9 @@ const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const body = await readBody(req, 4 * 1024 * 1024);
|
||||
if (!body.ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
|
||||
const sourceDocument = body.documentId && store.getDocument(user.email,String(body.documentId));
|
||||
if (!sourceDocument || sourceDocument.tur !== 'ihale' || sourceDocument.ikn !== String(body.ikn))
|
||||
return json(res,403,{success:false,error:'Kalem aktarımı için bu ihaleye bağlı, size ait ihale dokümanı gerekli. Firma belgeleri ortak havuza aktarılamaz.'});
|
||||
const { kalemler, yontem } = kalemleriCikar(body.metin || '');
|
||||
if (!kalemler.length) {
|
||||
return json(res, 200, { success: true, kalemler: [], yontem, not: 'Metinde teklif cetveli tablosu bulunamadı.' });
|
||||
@@ -954,6 +957,8 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!body.ad || !body.icerikBase64) {
|
||||
return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' });
|
||||
}
|
||||
const documentType = body.documentType ?? 'firma';
|
||||
if (!['firma','ihale'].includes(documentType)) throw new Error('Geçersiz belge türü.');
|
||||
const metadata = documentMetadataPatch(body);
|
||||
const buf = decodeDocumentUpload(body.icerikBase64);
|
||||
const kullanimDurumu = store.documentUsage(user.email);
|
||||
@@ -967,20 +972,21 @@ const server = http.createServer(async (req, res) => {
|
||||
let ofisMetni = null;
|
||||
if (sunucuOkuyabilir(body.ad)) {
|
||||
try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { /* metin çıkmazsa devam */ }
|
||||
if (!ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni);
|
||||
if (documentType === 'ihale' && !ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni);
|
||||
}
|
||||
const id = store.saveDocumentFile(user.email, {
|
||||
ad: String(body.ad).slice(0, 200),
|
||||
kategori: metadata.kategori || null,
|
||||
mime: body.mime || null,
|
||||
sonKullanma: metadata.sonKullanma || null,
|
||||
tur: documentType,
|
||||
ikn, // ihaleye bağlı belge
|
||||
}, buf);
|
||||
// Yükleme anında otomatik kalem çıkarımı — AI kullanmaz, kullanıcıdan
|
||||
// tıklama beklemez. PDF'ler tarayıcıda çözüldüğü için istemci
|
||||
// metni /api/kalem/cikar'a gönderir.
|
||||
let kalemBilgi = null;
|
||||
if (ikn && ofisMetni) {
|
||||
if (documentType === 'ihale' && ikn && ofisMetni) {
|
||||
try {
|
||||
const { kalemler, yontem } = kalemleriCikar(ofisMetni);
|
||||
if (kalemler.length) {
|
||||
|
||||
@@ -97,10 +97,13 @@ export function openDb() {
|
||||
for (const column of ['ikn', 'son_kullanma']) {
|
||||
if (!columns.has(column)) db.exec(`ALTER TABLE belgeler ADD COLUMN ${column} TEXT`);
|
||||
}
|
||||
if (!columns.has('tur')) db.exec("ALTER TABLE belgeler ADD COLUMN tur TEXT NOT NULL DEFAULT 'firma'");
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_belge_ikn ON belgeler(email, ikn)');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`);
|
||||
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
|
||||
.run('20260906_document_columns', new Date().toISOString());
|
||||
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
|
||||
.run('20260906_document_type', new Date().toISOString());
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ }
|
||||
@@ -241,11 +244,12 @@ export function verifyApiKey(ham) {
|
||||
|
||||
// --- Belgeler (gerçek dosya, gerçek boyut) ---------------------------------
|
||||
|
||||
export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma, ikn }) {
|
||||
export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma, ikn, tur = 'firma' }) {
|
||||
if (!['firma','ihale'].includes(tur)) throw new Error('Geçersiz belge türü.');
|
||||
const id = crypto.randomUUID();
|
||||
withDb((db) => db.prepare(
|
||||
'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,ikn,olusturma) VALUES (?,?,?,?,?,?,?,?,?,?)'
|
||||
).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, ikn || null, now()));
|
||||
'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,ikn,olusturma,tur) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
|
||||
).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, ikn || null, now(),tur));
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -265,7 +269,7 @@ export function saveDocumentFile(email, metadata, bytes) {
|
||||
}
|
||||
|
||||
export function listDocuments(email, { ikn } = {}) {
|
||||
const alanlar = 'id, ad, kategori, boyut, mime, son_kullanma, ikn, olusturma, dosya';
|
||||
const alanlar = 'id, ad, kategori, boyut, mime, son_kullanma, ikn, olusturma, dosya, tur';
|
||||
return withDb((db) => (ikn
|
||||
? db.prepare(`SELECT ${alanlar} FROM belgeler WHERE email=? AND ikn=? ORDER BY olusturma DESC`).all(email, ikn)
|
||||
: db.prepare(`SELECT ${alanlar} FROM belgeler WHERE email=? ORDER BY olusturma DESC`).all(email)
|
||||
|
||||
@@ -47,13 +47,13 @@ export const TenderDocuments = ({ tender }) => {
|
||||
try {
|
||||
// Sunucu docx/xlsx/zip için teklif cetveli kalemlerini yüklemede
|
||||
// otomatik çıkarır. PDF tarayıcıda çözüldüğü için metni biz göndeririz.
|
||||
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn);
|
||||
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, {documentType:'ihale'});
|
||||
let kalem = cevap?.kalem?.sayi || null;
|
||||
if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) {
|
||||
try {
|
||||
const { text } = await extractText(f);
|
||||
const { kalemCikar } = await import('../services/kalem');
|
||||
const d = await kalemCikar({ ikn, metin: text, dosya: f.name });
|
||||
const d = await kalemCikar({ ikn, metin: text, dosya: f.name, documentId:cevap.id });
|
||||
kalem = d.sayi || null;
|
||||
} catch { /* kalem çıkmazsa yükleme yine de başarılı sayılır */ }
|
||||
}
|
||||
|
||||
@@ -64,13 +64,13 @@ export const DocumentVault = () => {
|
||||
metin = (await extractText(f)).text;
|
||||
ikn = iknCikar(metin);
|
||||
}
|
||||
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, { otoIkn: true });
|
||||
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, { otoIkn: true, documentType:'ihale' });
|
||||
ikn = cevap?.ikn || ikn;
|
||||
let kalem = cevap?.kalem?.sayi || null;
|
||||
if (ikn && !kalem && metin) {
|
||||
try {
|
||||
const { kalemCikar } = await import('../../services/kalem');
|
||||
kalem = (await kalemCikar({ ikn, metin, dosya: f.name })).sayi || null;
|
||||
kalem = (await kalemCikar({ ikn, metin, dosya: f.name, documentId:cevap.id })).sayi || null;
|
||||
} catch { /* kalem çıkmazsa yükleme yine başarılı */ }
|
||||
}
|
||||
guncelle(i, { durum: 'tamam', ikn, kalem });
|
||||
@@ -250,7 +250,7 @@ export const DocumentVault = () => {
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-slate-900 truncate">{d.ad}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
{d.kategori || 'Diğer'} · <strong>{formatBayt(d.boyut)}</strong>
|
||||
{d.tur === 'ihale' ? 'İhale dokümanı' : 'Özel firma belgesi'} · {d.kategori || 'Diğer'} · <strong>{formatBayt(d.boyut)}</strong>
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||
{new Date(d.olusturma).toLocaleString('tr-TR')}
|
||||
|
||||
@@ -27,11 +27,11 @@ export async function kalemAra(q) {
|
||||
* Tarayıcıda çıkarılmış metinden (PDF) kalem ayrıştırıp havuza yazar.
|
||||
* docx/xlsx/zip yüklemede sunucu bunu zaten kendisi yapıyor.
|
||||
*/
|
||||
export async function kalemCikar({ ikn, metin, dosya }) {
|
||||
export async function kalemCikar({ ikn, metin, dosya, documentId }) {
|
||||
const r = await fetch('/api/kalem/cikar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ ikn, metin, dosya }),
|
||||
body: JSON.stringify({ ikn, metin, dosya, documentId }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!d.success) throw new Error(d.error || 'kalem çıkarılamadı');
|
||||
|
||||
@@ -69,3 +69,22 @@ test('HTTP file upload, owned download, metadata update and deletion operate on
|
||||
assert.equal((await request('/api/me/documents?id='+id,'DELETE')).status,200);
|
||||
assert.equal((await request('/api/me/documents/download?id='+id)).status,404);
|
||||
});
|
||||
|
||||
test('shared item extraction requires an owned tender document and matching tender',async()=>{
|
||||
const body={ad:'scope.txt',icerikBase64:Buffer.from('Yalnız test dokümanı, cetvel yok.').toString('base64'),ikn:'2026/123456'};
|
||||
const company=await request('/api/me/documents','POST',body);
|
||||
assert.equal(company.status,200);
|
||||
assert.equal(company.data.belgeler.find(d=>d.id===company.data.id).tur,'firma');
|
||||
const extract=id=>({documentId:id,ikn:body.ikn,metin:'Cetvel yok.'});
|
||||
assert.equal((await request('/api/kalem/cikar','POST',extract(company.data.id))).status,403);
|
||||
await request('/api/me/documents/meta','PATCH',{id:company.data.id,kategori:'İhale Dokümanı'});
|
||||
assert.equal((await request('/api/kalem/cikar','POST',extract(company.data.id))).status,403);
|
||||
const tender=await request('/api/me/documents','POST',{...body,documentType:'ihale'});
|
||||
assert.equal(tender.status,200);
|
||||
assert.equal((await request('/api/kalem/cikar','POST',extract(tender.data.id),other)).status,403);
|
||||
assert.equal((await request('/api/kalem/cikar','POST',{...extract(tender.data.id),ikn:'2026/999999'})).status,403);
|
||||
assert.equal((await request('/api/kalem/cikar','POST',extract(tender.data.id))).status,200);
|
||||
assert.equal((await request('/api/kalem/cikar','POST',{ikn:body.ikn,metin:'test'})).status,403);
|
||||
await request('/api/me/documents?id='+company.data.id,'DELETE');
|
||||
await request('/api/me/documents?id='+tender.data.id,'DELETE');
|
||||
});
|
||||
|
||||
@@ -15,11 +15,12 @@ after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
test('old volume migrates before indexing, preserves files and migration is repeatable', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const db = store.openDb();
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 2);
|
||||
assert.ok(db.prepare('PRAGMA table_info(belgeler)').all().some(c => c.name === 'ikn'));
|
||||
db.close();
|
||||
}
|
||||
assert.equal(store.listDocuments('a@example.test')[0].ad, 'Eski belge');
|
||||
assert.equal(store.listDocuments('a@example.test')[0].tur, 'firma');
|
||||
assert.deepEqual(store.listDocuments('b@example.test'), []);
|
||||
});
|
||||
test('five preparation records persist across DB close/reopen and stay account scoped', () => {
|
||||
|
||||
Reference in New Issue
Block a user