Update Tabellen und Suchalgo
@@ -197,15 +197,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
|
||||
case "search_parts": {
|
||||
const { search_term, limit = 50 } = args;
|
||||
const escaped = String(search_term).replace(/'/g, "''");
|
||||
const pattern = `N'%${escaped}%'`;
|
||||
const searchQuery = `
|
||||
SELECT TOP ${parseInt(limit)}
|
||||
'TEILE' AS TableName, Teil, Art, Gruppe, Bez, Bez2, Hersteller, WarenNr
|
||||
FROM TEILE
|
||||
SELECT TOP ${Math.max(1, parseInt(limit, 10) || 50)}
|
||||
t.Teil,
|
||||
t.Bez,
|
||||
t.Bez2,
|
||||
t.Ben7,
|
||||
t.Ben8,
|
||||
t.Hersteller,
|
||||
txt.Text AS [Text],
|
||||
ts.PrsVk AS PrsVK
|
||||
FROM TEILE t WITH (NOLOCK)
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 [Text]
|
||||
FROM TEXT WITH (NOLOCK)
|
||||
WHERE TextId = t.MemoID
|
||||
ORDER BY LfdNr
|
||||
) txt
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 PrsVk
|
||||
FROM TSSAEF WITH (NOLOCK)
|
||||
WHERE Teil = t.Teil
|
||||
ORDER BY ISN
|
||||
) ts
|
||||
WHERE
|
||||
LOWER(CAST(Teil AS NVARCHAR(MAX))) LIKE LOWER('%${search_term.replace(/'/g, "''")}%')
|
||||
OR LOWER(CAST(Bez AS NVARCHAR(MAX))) LIKE LOWER('%${search_term.replace(/'/g, "''")}%')
|
||||
OR LOWER(CAST(Bez2 AS NVARCHAR(MAX))) LIKE LOWER('%${search_term.replace(/'/g, "''")}%')
|
||||
OR LOWER(CAST(Hersteller AS NVARCHAR(MAX))) LIKE LOWER('%${search_term.replace(/'/g, "''")}%')
|
||||
t.Teil LIKE ${pattern} COLLATE SQL_Latin1_General_CP1_CI_AS
|
||||
OR t.Bez LIKE ${pattern} COLLATE SQL_Latin1_General_CP1_CI_AS
|
||||
OR t.Bez2 LIKE ${pattern} COLLATE SQL_Latin1_General_CP1_CI_AS
|
||||
OR t.Ben7 LIKE ${pattern} COLLATE SQL_Latin1_General_CP1_CI_AS
|
||||
OR t.Ben8 LIKE ${pattern} COLLATE SQL_Latin1_General_CP1_CI_AS
|
||||
OR t.Hersteller LIKE ${pattern} COLLATE SQL_Latin1_General_CP1_CI_AS
|
||||
`;
|
||||
const rows = await runQuery(searchQuery);
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="/images/icons/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/images/icons/icon-180x180.png">
|
||||
<title>Infraviewer - Volltextsuche</title>
|
||||
<style>
|
||||
* {
|
||||
@@ -385,19 +387,31 @@
|
||||
resultsDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// Feste Spaltenreihenfolge und Anzeige-Labels (siehe Spalten.txt)
|
||||
const DISPLAY_COLUMNS = [
|
||||
{ key: 'Teil', label: 'Teilenummer' },
|
||||
{ key: 'Bez', label: 'Bezeichnung' },
|
||||
{ key: 'Bez2', label: 'Bezeichnung 2' },
|
||||
{ key: 'Ben7', label: 'Englischer Text' },
|
||||
{ key: 'Ben8', label: 'Deutscher Text' },
|
||||
{ key: 'Hersteller', label: 'Hersteller' },
|
||||
{ key: 'Text', label: 'Zusatztext' },
|
||||
{ key: 'PrsVK', label: 'Verkaufspreis in €' }
|
||||
];
|
||||
|
||||
function renderRecord(record) {
|
||||
let html = '<div class="record">';
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const displayValue = value === null ? '<em>NULL</em>' : highlightSearchTerm(String(value));
|
||||
for (const { key, label } of DISPLAY_COLUMNS) {
|
||||
if (!(key in record)) continue;
|
||||
const value = record[key];
|
||||
const displayValue = value === null || value === undefined ? '' : highlightSearchTerm(String(value));
|
||||
html += `
|
||||
<div class="record-field">
|
||||
<span class="field-name">${escapeHtml(key)}:</span>
|
||||
<span class="field-name">${escapeHtml(label)}:</span>
|
||||
<span class="field-value">${displayValue}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
@@ -405,8 +419,12 @@
|
||||
function highlightSearchTerm(text) {
|
||||
if (!currentSearchTerm) return escapeHtml(text);
|
||||
|
||||
const words = currentSearchTerm.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return escapeHtml(text);
|
||||
|
||||
const escapedText = escapeHtml(text);
|
||||
const searchRegex = new RegExp(`(${escapeRegex(currentSearchTerm)})`, 'gi');
|
||||
const pattern = words.map(w => escapeRegex(w)).join('|');
|
||||
const searchRegex = new RegExp(`(${pattern})`, 'gi');
|
||||
return escapedText.replace(searchRegex, '<span class="highlight">$1</span>');
|
||||
}
|
||||
|
||||
|
||||
BIN
public/images/icons/favicon.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/icons/icon-120x120.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
public/images/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/icons/icon-167x167.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/icons/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Infraviewer - Wartung</title>
|
||||
<link rel="icon" type="image/png" href="/images/icons/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/images/icons/icon-180x180.png">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ const searchRoutes = require('./routes/search');
|
||||
const app = express();
|
||||
// Standardmäßig läuft der Entwicklungsserver lokal auf 3002.
|
||||
// Im Docker-Container wird der Port über die Umgebungsvariable PORT (z.B. 3001) gesetzt.
|
||||
const PORT = process.env.PORT || 3002;
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
|
||||
@@ -10,60 +10,55 @@ const { sql, getConnection } = require('../config/database');
|
||||
* primaryKey: 'ID' // Primärschlüssel der Tabelle
|
||||
* }
|
||||
*/
|
||||
// Gemeinsame Spalten für die TEILE Tabelle
|
||||
// WICHTIG:
|
||||
// - Die Teilenummer steht in der Spalte `Teil`
|
||||
// - Es gibt KEINE Spalte `WarenNr` in TEILE, daher darf diese hier NICHT verwendet werden
|
||||
// - Spaltennamen müssen exakt den vorhandenen Spalten in TEILE entsprechen,
|
||||
// sonst schlägt die Suche mit "invalid column name" fehl.
|
||||
const COMMON_COLUMNS = [
|
||||
'Teil', // Teilenummer
|
||||
'Art', // Art
|
||||
'Gruppe', // Gruppe
|
||||
'Bez', // Bezeichnung
|
||||
'Bez2', // Bezeichnung 2
|
||||
'Hersteller' // Hersteller
|
||||
// Weitere Felder (z.B. SuchL, SuchR, EUWarenNr, Ben-Felder) können bei Bedarf ergänzt werden
|
||||
// Spalten für die Volltextsuche in TEILE (WHERE-Klausel)
|
||||
const TEILE_SEARCH_COLUMNS = [
|
||||
'Teil',
|
||||
'Bez',
|
||||
'Bez2',
|
||||
'Ben7',
|
||||
'Ben8',
|
||||
'Hersteller'
|
||||
];
|
||||
|
||||
// Erweiterte Spalten (falls benötigt) für spezielle Varianten mit zusätzlichen Feldern
|
||||
// Basieren auf den gemeinsamen Spalten der TEILE Tabelle
|
||||
const COLUMNS_EL = [
|
||||
...COMMON_COLUMNS,
|
||||
'AUF', // Auftragsfeld
|
||||
'AUF1', // Auftragsfeld 1
|
||||
'AUF2', // Auftragsfeld 2
|
||||
'AUF3' // Auftragsfeld 3
|
||||
// Ausgabe-Spalten für Suchergebnisse (nur diese 8 Felder, siehe Spalten.txt)
|
||||
// Text aus TEXT (TEILE.MemoID = TEXT.TextId), PrsVK aus TSSAEF (Teil = Teil)
|
||||
const TEILE_OUTPUT_COLUMNS = [
|
||||
'Teil',
|
||||
'Bez',
|
||||
'Bez2',
|
||||
'Ben7',
|
||||
'Ben8',
|
||||
'Hersteller',
|
||||
'Text', // aus Tabelle TEXT
|
||||
'PrsVK' // aus Tabelle TSSAEF
|
||||
];
|
||||
|
||||
const SEARCH_CONFIG = [
|
||||
{
|
||||
tableName: 'TEILE',
|
||||
columns: COMMON_COLUMNS,
|
||||
primaryKey: 'ISN' // technisch primärer Schlüssel der Tabelle
|
||||
columns: TEILE_SEARCH_COLUMNS,
|
||||
primaryKey: 'ISN'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Erstellt eine SQL WHERE-Klausel für die Volltextsuche
|
||||
* Findet Teilübereinstimmungen in allen Spalten (case-insensitive)
|
||||
* Optimiert durch:
|
||||
* - Verwendung von COLLATE für case-insensitive Vergleich (schneller als LOWER)
|
||||
* - Effiziente CAST-Größen statt NVARCHAR(MAX)
|
||||
* - Vermeidung von redundanten Funktionsaufrufen
|
||||
* Erstellt eine SQL-Bedingung für ein einzelnes Wort: mindestens eine Spalte muss das Wort enthalten
|
||||
* @param {Array} columns - Array von Spaltennamen
|
||||
* @param {string} searchTerm - Suchbegriff
|
||||
* @returns {string} WHERE-Klausel
|
||||
* @param {string} paramName - Name des SQL-Parameters (z. B. "searchWord0")
|
||||
* @param {string} [tableAlias] - Optionaler Tabellenalias (z. B. "t" für t.[Teil])
|
||||
* @returns {string} Bedingung (OR über alle Spalten)
|
||||
*/
|
||||
const buildSearchCondition = (columns, searchTerm) => {
|
||||
const buildSingleWordCondition = (columns, paramName, tableAlias = '') => {
|
||||
const prefix = tableAlias ? `${tableAlias}.` : '';
|
||||
return columns
|
||||
.map(col => `[${col}] LIKE @searchPattern COLLATE SQL_Latin1_General_CP1_CI_AS`)
|
||||
.map(col => `${prefix}[${col}] LIKE @${paramName} COLLATE SQL_Latin1_General_CP1_CI_AS`)
|
||||
.join(' OR ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Führt eine Volltextsuche über alle konfigurierten Tabellen durch
|
||||
* Findet alle Datensätze, die den Suchbegriff als Teilstring enthalten (case-insensitive)
|
||||
* Jedes Wort der Eingabe wird als eigener Suchbegriff behandelt; ein Treffer muss alle Wörter enthalten (AND).
|
||||
* Findet Datensätze, in denen jedes Wort als Teilstring in mindestens einer Spalte vorkommt (case-insensitive)
|
||||
*
|
||||
* Optimierungen:
|
||||
* - Parallele Suche über alle Tabellen (Promise.all statt sequentiell)
|
||||
@@ -71,7 +66,7 @@ const buildSearchCondition = (columns, searchTerm) => {
|
||||
* - Case-insensitive Suche via COLLATE statt LOWER()
|
||||
* - Optimierte Query-Struktur mit TOP für bessere Performance
|
||||
*
|
||||
* @param {string} searchTerm - Der zu suchende Begriff
|
||||
* @param {string} searchTerm - Der zu suchende Begriff (mehrere Wörter möglich, getrennt durch Leerzeichen)
|
||||
* @returns {Promise<Array>} Array mit Suchergebnissen
|
||||
*/
|
||||
const fullTextSearch = async (searchTerm) => {
|
||||
@@ -79,29 +74,54 @@ const fullTextSearch = async (searchTerm) => {
|
||||
throw new Error('Suchbegriff darf nicht leer sein');
|
||||
}
|
||||
|
||||
const words = searchTerm.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) {
|
||||
throw new Error('Suchbegriff darf nicht leer sein');
|
||||
}
|
||||
|
||||
const pool = await getConnection();
|
||||
|
||||
// Pattern für Teilübereinstimmungen: findet den Begriff überall im Text
|
||||
// % = beliebige Zeichen davor/dahinter (SQL Wildcard)
|
||||
const searchPattern = `%${searchTerm.trim()}%`;
|
||||
|
||||
// OPTIMIERUNG: Parallele Suche über alle Tabellen statt sequentiell
|
||||
// TEILE: Feste 8 Spalten mit JOINs auf TEXT (MemoID=TextId) und TSSAEF (Teil)
|
||||
const searchPromises = SEARCH_CONFIG.map(async (tableConfig) => {
|
||||
try {
|
||||
const whereClause = buildSearchCondition(tableConfig.columns, searchTerm);
|
||||
// Pro Wort: (Spalte1 LIKE @searchWord0 OR ... OR SpalteN LIKE @searchWord0), alle mit AND verknüpft
|
||||
const wordConditions = words.map((_, i) => {
|
||||
const paramName = `searchWord${i}`;
|
||||
const cond = buildSingleWordCondition(tableConfig.columns, paramName, 't');
|
||||
return `(${cond})`;
|
||||
});
|
||||
const whereClause = wordConditions.join(' AND ');
|
||||
|
||||
// Optimierte Query:
|
||||
// - Verwendet COLLATE für case-insensitive Vergleich (schneller als LOWER)
|
||||
// - TOP 100 limitiert Ergebnisse früh im Execution Plan
|
||||
// - WITH (NOLOCK) für Read-Uncommitted (schneller, da keine Locks)
|
||||
const query = `
|
||||
SELECT TOP 100 *
|
||||
FROM [${tableConfig.tableName}] WITH (NOLOCK)
|
||||
SELECT TOP 100
|
||||
t.Teil,
|
||||
t.Bez,
|
||||
t.Bez2,
|
||||
t.Ben7,
|
||||
t.Ben8,
|
||||
t.Hersteller,
|
||||
txt.Text AS [Text],
|
||||
ts.PrsVk AS PrsVK
|
||||
FROM [${tableConfig.tableName}] t WITH (NOLOCK)
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 [Text]
|
||||
FROM [TEXT] WITH (NOLOCK)
|
||||
WHERE TextId = COALESCE(NULLIF(RTRIM(LTRIM(t.MemoID)), N''), N'MEM-' + t.Teil)
|
||||
ORDER BY LfdNr
|
||||
) txt
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 PrsVk
|
||||
FROM TSSAEF WITH (NOLOCK)
|
||||
WHERE Teil = t.Teil
|
||||
ORDER BY ISN
|
||||
) ts
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const request = pool.request();
|
||||
request.input('searchPattern', sql.NVarChar, searchPattern);
|
||||
words.forEach((word, i) => {
|
||||
request.input(`searchWord${i}`, sql.NVarChar, `%${word}%`);
|
||||
});
|
||||
|
||||
const result = await request.query(query);
|
||||
|
||||
@@ -112,11 +132,9 @@ const fullTextSearch = async (searchTerm) => {
|
||||
records: result.recordset
|
||||
};
|
||||
}
|
||||
return null; // Keine Ergebnisse gefunden
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Durchsuchen der Tabelle ${tableConfig.tableName}:`, error.message);
|
||||
// Tabelle existiert möglicherweise nicht - Fehler zurückgeben
|
||||
return {
|
||||
tableName: tableConfig.tableName,
|
||||
matchCount: 0,
|
||||
|
||||