Dataforth UI push + dedup + refactor, GuruRMM roadmap evolution, Azure signing setup
Dataforth (projects/dataforth-dos/): - UI feature: row coloring + PUSH/RE-PUSH buttons + Website Status filter - Database dedup to one row per SN (2.89M -> 469K rows, UNIQUE constraint added) - Import logic handles FAIL -> PASS retest transition - Refactored upload-to-api.js to render datasheets in-memory (dropped For_Web filesystem dep) - Bulk pushed 170,984 records to Hoffman API - Statistical sanity check: 100/100 stamped SNs verified on Hoffman GuruRMM (projects/msp-tools/guru-rmm/): - ROADMAP.md: added Terminology (5-tier hierarchy), Tunnel Channels Phase 2, Logging/Audit/Observability, Multi-tenancy, Modular Architecture, Protocol Versioning, Certificates sections + Decisions Log - CONTEXT.md: hierarchy table, new anti-patterns (bootstrap sacred, no cross-module imports), revised next-steps priorities Session logs for both projects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* PostgreSQL Database Abstraction Layer
|
||||
*
|
||||
* Provides a connection pool and helper methods for the TestDataDB app.
|
||||
* Replaces better-sqlite3 singleton with pg.Pool.
|
||||
*
|
||||
* Environment variables (all optional, defaults connect to local PG):
|
||||
* PGHOST (default: localhost)
|
||||
* PGPORT (default: 5432)
|
||||
* PGUSER (default: testdatadb_app)
|
||||
* PGPASSWORD (default: DfTestDB2026!)
|
||||
* PGDATABASE (default: testdatadb)
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.PGHOST || 'localhost',
|
||||
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||
user: process.env.PGUSER || 'testdatadb_app',
|
||||
password: process.env.PGPASSWORD || 'DfTestDB2026!',
|
||||
database: process.env.PGDATABASE || 'testdatadb',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`[${new Date().toISOString()}] [PG POOL ERROR] ${err.message}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert SQLite-style ? placeholders to PostgreSQL $1, $2, ... placeholders.
|
||||
* Skips ? inside single-quoted strings.
|
||||
*/
|
||||
function convertPlaceholders(sql) {
|
||||
let idx = 0;
|
||||
let inString = false;
|
||||
let result = '';
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const ch = sql[i];
|
||||
if (ch === "'" && (i === 0 || sql[i - 1] !== '\\')) {
|
||||
inString = !inString;
|
||||
result += ch;
|
||||
} else if (ch === '?' && !inString) {
|
||||
idx++;
|
||||
result += '$' + idx;
|
||||
} else {
|
||||
result += ch;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query, return all rows.
|
||||
* @param {string} sql - SQL with ? or $N placeholders
|
||||
* @param {Array} params - Parameter values
|
||||
* @returns {Promise<Array>} rows
|
||||
*/
|
||||
async function query(sql, params = []) {
|
||||
const pgSql = sql.includes('?') ? convertPlaceholders(sql) : sql;
|
||||
const result = await pool.query(pgSql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query, return the first row or null.
|
||||
*/
|
||||
async function queryOne(sql, params = []) {
|
||||
const rows = await query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a statement (INSERT/UPDATE/DELETE), return { rowCount }.
|
||||
*/
|
||||
async function execute(sql, params = []) {
|
||||
const pgSql = sql.includes('?') ? convertPlaceholders(sql) : sql;
|
||||
const result = await pool.query(pgSql, params);
|
||||
return { rowCount: result.rowCount, rows: result.rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function inside a transaction.
|
||||
* The callback receives a client with query/execute helpers.
|
||||
* @param {Function} fn - async (client) => result
|
||||
* @returns {Promise<*>} result of fn
|
||||
*/
|
||||
async function transaction(fn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const txClient = {
|
||||
async query(sql, params = []) {
|
||||
const pgSql = sql.includes('?') ? convertPlaceholders(sql) : sql;
|
||||
const result = await client.query(pgSql, params);
|
||||
return result.rows;
|
||||
},
|
||||
async queryOne(sql, params = []) {
|
||||
const rows = await txClient.query(sql, params);
|
||||
return rows[0] || null;
|
||||
},
|
||||
async execute(sql, params = []) {
|
||||
const pgSql = sql.includes('?') ? convertPlaceholders(sql) : sql;
|
||||
const result = await client.query(pgSql, params);
|
||||
return { rowCount: result.rowCount, rows: result.rows };
|
||||
},
|
||||
// Direct pg client access for COPY or other advanced operations
|
||||
raw: client,
|
||||
};
|
||||
|
||||
const result = await fn(txClient);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the pool (for graceful shutdown).
|
||||
*/
|
||||
async function close() {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw pool (for advanced use like COPY).
|
||||
*/
|
||||
function getPool() {
|
||||
return pool;
|
||||
}
|
||||
|
||||
module.exports = { query, queryOne, execute, transaction, close, getPool, convertPlaceholders };
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Export Datasheets
|
||||
*
|
||||
* Generates TXT datasheets for unexported PASS records and writes them to X:\For_Web\.
|
||||
* Updates forweb_exported_at after successful export.
|
||||
*
|
||||
* Usage:
|
||||
* node export-datasheets.js Export all pending (batch mode)
|
||||
* node export-datasheets.js --limit 100 Export up to 100 records
|
||||
* node export-datasheets.js --file <paths> Export records matching specific source files
|
||||
* node export-datasheets.js --serial 178439-1 Export a specific serial number
|
||||
* node export-datasheets.js --dry-run Show what would be exported without writing
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
|
||||
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
||||
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
||||
|
||||
// Configuration
|
||||
const OUTPUT_DIR = 'X:\\For_Web';
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
async function run() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const limitIdx = args.indexOf('--limit');
|
||||
const limit = limitIdx >= 0 ? parseInt(args[limitIdx + 1]) : 0;
|
||||
const serialIdx = args.indexOf('--serial');
|
||||
const serial = serialIdx >= 0 ? args[serialIdx + 1] : null;
|
||||
const fileIdx = args.indexOf('--file');
|
||||
const files = fileIdx >= 0 ? args.slice(fileIdx + 1).filter(f => !f.startsWith('--')) : null;
|
||||
|
||||
console.log('========================================');
|
||||
console.log('Datasheet Export');
|
||||
console.log('========================================');
|
||||
console.log(`Output: ${OUTPUT_DIR}`);
|
||||
console.log(`Dry run: ${dryRun}`);
|
||||
if (limit) console.log(`Limit: ${limit}`);
|
||||
if (serial) console.log(`Serial: ${serial}`);
|
||||
console.log(`Start: ${new Date().toISOString()}`);
|
||||
|
||||
if (!dryRun && !fs.existsSync(OUTPUT_DIR)) {
|
||||
console.error(`ERROR: Output directory does not exist: ${OUTPUT_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nLoading model specs...');
|
||||
const specMap = loadAllSpecs();
|
||||
|
||||
// Build query
|
||||
const conditions = [`overall_result = 'PASS'`, `forweb_exported_at IS NULL`];
|
||||
const params = [];
|
||||
let paramIdx = 0;
|
||||
|
||||
if (serial) {
|
||||
paramIdx++;
|
||||
conditions.push(`serial_number = $${paramIdx}`);
|
||||
params.push(serial);
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const placeholders = files.map(() => { paramIdx++; return `$${paramIdx}`; }).join(',');
|
||||
conditions.push(`source_file IN (${placeholders})`);
|
||||
params.push(...files);
|
||||
}
|
||||
|
||||
let sql = `SELECT * FROM test_records WHERE ${conditions.join(' AND ')} ORDER BY test_date DESC`;
|
||||
|
||||
if (limit) {
|
||||
paramIdx++;
|
||||
sql += ` LIMIT $${paramIdx}`;
|
||||
params.push(limit);
|
||||
}
|
||||
|
||||
const records = await db.query(sql, params);
|
||||
console.log(`\nFound ${records.length} records to export`);
|
||||
|
||||
if (records.length === 0) {
|
||||
console.log('Nothing to export.');
|
||||
await db.close();
|
||||
return { exported: 0, skipped: 0, errors: 0 };
|
||||
}
|
||||
|
||||
let exported = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let noSpecs = 0;
|
||||
let pendingUpdates = [];
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
const filename = record.serial_number + '.TXT';
|
||||
const outputPath = path.join(OUTPUT_DIR, filename);
|
||||
|
||||
// VASLOG_ENG: verbatim byte-for-byte copy of the original file.
|
||||
// Using fs.copyFileSync avoids any utf-8 round-trip that would
|
||||
// corrupt non-ASCII bytes (CP1252 etc.) in customer datasheets.
|
||||
// Fall back to writing raw_data if the source file is gone.
|
||||
if (record.log_type === 'VASLOG_ENG') {
|
||||
if (dryRun) {
|
||||
console.log(` [DRY RUN] Would copy: ${record.source_file} -> ${filename}`);
|
||||
exported++;
|
||||
continue;
|
||||
}
|
||||
if (record.source_file && fs.existsSync(record.source_file)) {
|
||||
fs.copyFileSync(record.source_file, outputPath);
|
||||
} else {
|
||||
console.warn(`[WARN] source file missing, writing decoded raw_data for ${record.serial_number}`);
|
||||
if (!record.raw_data) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(outputPath, record.raw_data, 'utf8');
|
||||
}
|
||||
pendingUpdates.push(record.id);
|
||||
exported++;
|
||||
|
||||
if (pendingUpdates.length >= BATCH_SIZE) {
|
||||
await flushUpdates(pendingUpdates);
|
||||
pendingUpdates = [];
|
||||
process.stdout.write(`\r Exported: ${exported} / ${records.length}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Template-generated datasheet path.
|
||||
const specs = getSpecs(specMap, record.model_number);
|
||||
if (!specs) {
|
||||
noSpecs++;
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const txt = generateExactDatasheet(record, specs);
|
||||
if (!txt) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(` [DRY RUN] Would write: ${filename}`);
|
||||
exported++;
|
||||
} else {
|
||||
fs.writeFileSync(outputPath, txt, 'utf8');
|
||||
pendingUpdates.push(record.id);
|
||||
exported++;
|
||||
|
||||
// Batch commit
|
||||
if (pendingUpdates.length >= BATCH_SIZE) {
|
||||
await flushUpdates(pendingUpdates);
|
||||
pendingUpdates = [];
|
||||
process.stdout.write(`\r Exported: ${exported} / ${records.length}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n ERROR exporting ${record.serial_number}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining updates
|
||||
if (pendingUpdates.length > 0) {
|
||||
await flushUpdates(pendingUpdates);
|
||||
}
|
||||
|
||||
console.log(`\n\n========================================`);
|
||||
console.log(`Export Complete`);
|
||||
console.log(`========================================`);
|
||||
console.log(`Exported: ${exported}`);
|
||||
console.log(`Skipped: ${skipped} (${noSpecs} missing specs)`);
|
||||
console.log(`Errors: ${errors}`);
|
||||
console.log(`End: ${new Date().toISOString()}`);
|
||||
|
||||
await db.close();
|
||||
return { exported, skipped, errors };
|
||||
}
|
||||
|
||||
async function flushUpdates(ids) {
|
||||
const now = new Date().toISOString();
|
||||
await db.transaction(async (txClient) => {
|
||||
for (const id of ids) {
|
||||
await txClient.execute(
|
||||
'UPDATE test_records SET forweb_exported_at = $1 WHERE id = $2',
|
||||
[now, id]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export function for use by import.js (no db argument -- uses shared pool)
|
||||
async function exportNewRecords(specMap, filePaths) {
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
console.log(`[EXPORT] Output directory not found: ${OUTPUT_DIR}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const conditions = [`overall_result = 'PASS'`, `forweb_exported_at IS NULL`];
|
||||
const params = [];
|
||||
let paramIdx = 0;
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const placeholders = filePaths.map(() => { paramIdx++; return `$${paramIdx}`; }).join(',');
|
||||
conditions.push(`source_file IN (${placeholders})`);
|
||||
params.push(...filePaths);
|
||||
}
|
||||
|
||||
const sql = `SELECT * FROM test_records WHERE ${conditions.join(' AND ')}`;
|
||||
const records = await db.query(sql, params);
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
let exported = 0;
|
||||
|
||||
await db.transaction(async (txClient) => {
|
||||
for (const record of records) {
|
||||
const filename = record.serial_number + '.TXT';
|
||||
const outputPath = path.join(OUTPUT_DIR, filename);
|
||||
|
||||
try {
|
||||
// VASLOG_ENG: verbatim copy, preserving original bytes.
|
||||
if (record.log_type === 'VASLOG_ENG') {
|
||||
if (record.source_file && fs.existsSync(record.source_file)) {
|
||||
fs.copyFileSync(record.source_file, outputPath);
|
||||
} else {
|
||||
console.warn(`[WARN] source file missing, writing decoded raw_data for ${record.serial_number}`);
|
||||
if (!record.raw_data) continue;
|
||||
fs.writeFileSync(outputPath, record.raw_data, 'utf8');
|
||||
}
|
||||
} else {
|
||||
const specs = getSpecs(specMap, record.model_number);
|
||||
if (!specs) continue;
|
||||
const txt = generateExactDatasheet(record, specs);
|
||||
if (!txt) continue;
|
||||
fs.writeFileSync(outputPath, txt, 'utf8');
|
||||
}
|
||||
|
||||
await txClient.execute(
|
||||
'UPDATE test_records SET forweb_exported_at = $1 WHERE id = $2',
|
||||
[new Date().toISOString(), record.id]
|
||||
);
|
||||
exported++;
|
||||
} catch (err) {
|
||||
console.error(`[EXPORT] Error writing ${filename}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[EXPORT] Generated ${exported} datasheet(s)`);
|
||||
return exported;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
run().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { exportNewRecords };
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Data Import Script
|
||||
* Imports test data from DAT and SHT files into PostgreSQL database
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
|
||||
const { parseMultilineFile, extractTestStation } = require('../parsers/multiline');
|
||||
const { parseCsvFile } = require('../parsers/csvline');
|
||||
const { parseShtFile } = require('../parsers/shtfile');
|
||||
const { parseVaslogEngTxt } = require('../parsers/vaslog-engtxt');
|
||||
|
||||
// Data source paths
|
||||
const TEST_PATH = 'C:/Shares/test';
|
||||
const RECOVERY_PATH = 'C:/Shares/Recovery-TEST';
|
||||
const HISTLOGS_PATH = path.join(TEST_PATH, 'Ate/HISTLOGS');
|
||||
|
||||
// Log types and their parsers.
|
||||
// NOTE: `recursive` defaults to TRUE when absent (walk subfolders by default,
|
||||
// preserving pre-existing production behavior for DSCLOG/5BLOG/8BLOG/PWRLOG/
|
||||
// SCTLOG/7BLOG). Set it to FALSE explicitly on VASLOG so the .DAT walk does
|
||||
// NOT descend into the "VASLOG - Engineering Tested" subfolder (belt-and-
|
||||
// suspenders: the .DAT glob wouldn't match .txt, but be explicit anyway).
|
||||
// VASLOG_ENG also sets recursive:false -- the eng-tested dir is flat.
|
||||
const LOG_TYPES = {
|
||||
'DSCLOG': { parser: 'multiline', ext: '.DAT' },
|
||||
'5BLOG': { parser: 'multiline', ext: '.DAT' },
|
||||
'8BLOG': { parser: 'multiline', ext: '.DAT' },
|
||||
'PWRLOG': { parser: 'multiline', ext: '.DAT' },
|
||||
'SCTLOG': { parser: 'multiline', ext: '.DAT' },
|
||||
'VASLOG': { parser: 'multiline', ext: '.DAT', recursive: false },
|
||||
'7BLOG': { parser: 'csvline', ext: '.DAT' },
|
||||
// Engineering-tested SCMHVAS pre-rendered datasheets live under VASLOG/"VASLOG - Engineering Tested"/
|
||||
'VASLOG_ENG': { parser: 'vaslog-engtxt', ext: '.txt', dir: 'VASLOG/VASLOG - Engineering Tested', recursive: false }
|
||||
};
|
||||
|
||||
// Find all files of a specific type in a directory
|
||||
function findFiles(dir, pattern, recursive = true) {
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item.name);
|
||||
|
||||
if (item.isDirectory() && recursive) {
|
||||
results.push(...findFiles(fullPath, pattern, recursive));
|
||||
} else if (item.isFile()) {
|
||||
if (pattern.test(item.name)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore permission errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Parse records from a file (sync -- file I/O only)
|
||||
function parseFile(filePath, logType, parser) {
|
||||
const testStation = extractTestStation(filePath);
|
||||
|
||||
switch (parser) {
|
||||
case 'multiline':
|
||||
return parseMultilineFile(filePath, logType, testStation);
|
||||
case 'csvline':
|
||||
return parseCsvFile(filePath, testStation);
|
||||
case 'shtfile':
|
||||
return parseShtFile(filePath, testStation);
|
||||
case 'vaslog-engtxt':
|
||||
return parseVaslogEngTxt(filePath, testStation);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert records into PostgreSQL
|
||||
async function insertBatch(txClient, records) {
|
||||
let imported = 0;
|
||||
for (const record of records) {
|
||||
try {
|
||||
const result = await txClient.execute(
|
||||
`INSERT INTO test_records
|
||||
(log_type, model_number, serial_number, test_date, test_station, overall_result, raw_data, source_file)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (log_type, model_number, serial_number, test_date, test_station)
|
||||
DO UPDATE SET raw_data = EXCLUDED.raw_data, overall_result = EXCLUDED.overall_result`,
|
||||
[
|
||||
record.log_type,
|
||||
record.model_number,
|
||||
record.serial_number,
|
||||
record.test_date,
|
||||
record.test_station,
|
||||
record.overall_result,
|
||||
record.raw_data,
|
||||
record.source_file
|
||||
]
|
||||
);
|
||||
if (result.rowCount > 0) imported++;
|
||||
} catch (err) {
|
||||
// Constraint error - skip
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
// Import records from a file
|
||||
async function importFile(txClient, filePath, logType, parser) {
|
||||
let records = [];
|
||||
|
||||
try {
|
||||
records = parseFile(filePath, logType, parser);
|
||||
const imported = await insertBatch(txClient, records);
|
||||
return { total: records.length, imported };
|
||||
} catch (err) {
|
||||
console.error(`Error importing ${filePath}: ${err.message}`);
|
||||
return { total: 0, imported: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Import from HISTLOGS (master consolidated logs)
|
||||
async function importHistlogs(txClient) {
|
||||
console.log('\n=== Importing from HISTLOGS ===');
|
||||
|
||||
let totalImported = 0;
|
||||
let totalRecords = 0;
|
||||
|
||||
for (const [logType, config] of Object.entries(LOG_TYPES)) {
|
||||
const subdir = config.dir || logType;
|
||||
const logDir = path.join(HISTLOGS_PATH, subdir);
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
console.log(` ${logType}: directory not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = findFiles(logDir, new RegExp(`\\${config.ext}$`, 'i'), config.recursive !== false);
|
||||
console.log(` ${logType}: found ${files.length} files`);
|
||||
|
||||
for (const file of files) {
|
||||
const { total, imported } = await importFile(txClient, file, logType, config.parser);
|
||||
totalRecords += total;
|
||||
totalImported += imported;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` HISTLOGS total: ${totalImported} records imported (${totalRecords} parsed)`);
|
||||
return totalImported;
|
||||
}
|
||||
|
||||
// Import from test station logs
|
||||
async function importStationLogs(txClient, basePath, label) {
|
||||
console.log(`\n=== Importing from ${label} ===`);
|
||||
|
||||
let totalImported = 0;
|
||||
let totalRecords = 0;
|
||||
|
||||
const stationPattern = /^TS-\d+[LR]?$/i;
|
||||
let stations = [];
|
||||
|
||||
try {
|
||||
const items = fs.readdirSync(basePath, { withFileTypes: true });
|
||||
stations = items
|
||||
.filter(i => i.isDirectory() && stationPattern.test(i.name))
|
||||
.map(i => i.name);
|
||||
} catch (err) {
|
||||
console.log(` Error reading ${basePath}: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(` Found stations: ${stations.join(', ')}`);
|
||||
|
||||
for (const station of stations) {
|
||||
const logsDir = path.join(basePath, station, 'LOGS');
|
||||
|
||||
if (!fs.existsSync(logsDir)) continue;
|
||||
|
||||
for (const [logType, config] of Object.entries(LOG_TYPES)) {
|
||||
const subdir = config.dir || logType;
|
||||
const logDir = path.join(logsDir, subdir);
|
||||
|
||||
if (!fs.existsSync(logDir)) continue;
|
||||
|
||||
const files = findFiles(logDir, new RegExp(`\\${config.ext}$`, 'i'), config.recursive !== false);
|
||||
|
||||
for (const file of files) {
|
||||
const { total, imported } = await importFile(txClient, file, logType, config.parser);
|
||||
totalRecords += total;
|
||||
totalImported += imported;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also import SHT files
|
||||
const shtFiles = findFiles(basePath, /\.SHT$/i, true);
|
||||
console.log(` Found ${shtFiles.length} SHT files`);
|
||||
|
||||
for (const file of shtFiles) {
|
||||
const { total, imported } = await importFile(txClient, file, 'SHT', 'shtfile');
|
||||
totalRecords += total;
|
||||
totalImported += imported;
|
||||
}
|
||||
|
||||
console.log(` ${label} total: ${totalImported} records imported (${totalRecords} parsed)`);
|
||||
return totalImported;
|
||||
}
|
||||
|
||||
// Import from Recovery-TEST backups (newest first)
|
||||
async function importRecoveryBackups(txClient) {
|
||||
console.log('\n=== Importing from Recovery-TEST backups ===');
|
||||
|
||||
if (!fs.existsSync(RECOVERY_PATH)) {
|
||||
console.log(' Recovery-TEST directory not found');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const backups = fs.readdirSync(RECOVERY_PATH, { withFileTypes: true })
|
||||
.filter(i => i.isDirectory() && /^\d{2}-\d{2}-\d{2}$/.test(i.name))
|
||||
.map(i => i.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
console.log(` Found backup dates: ${backups.join(', ')}`);
|
||||
|
||||
let totalImported = 0;
|
||||
|
||||
for (const backup of backups) {
|
||||
const backupPath = path.join(RECOVERY_PATH, backup);
|
||||
const imported = await importStationLogs(txClient, backupPath, `Recovery-TEST/${backup}`);
|
||||
totalImported += imported;
|
||||
}
|
||||
|
||||
return totalImported;
|
||||
}
|
||||
|
||||
// Main import function
|
||||
async function runImport() {
|
||||
console.log('========================================');
|
||||
console.log('Test Data Import');
|
||||
console.log('========================================');
|
||||
console.log(`Start time: ${new Date().toISOString()}`);
|
||||
|
||||
let grandTotal = 0;
|
||||
|
||||
await db.transaction(async (txClient) => {
|
||||
grandTotal += await importHistlogs(txClient);
|
||||
grandTotal += await importRecoveryBackups(txClient);
|
||||
grandTotal += await importStationLogs(txClient, TEST_PATH, 'test');
|
||||
});
|
||||
|
||||
const stats = await db.queryOne('SELECT COUNT(*) as count FROM test_records');
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('Import Complete');
|
||||
console.log('========================================');
|
||||
console.log(`Total records in database: ${stats.count}`);
|
||||
console.log(`End time: ${new Date().toISOString()}`);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
// Import a single file (for incremental imports from sync)
|
||||
async function importSingleFile(filePath) {
|
||||
console.log(`Importing: ${filePath}`);
|
||||
|
||||
let logType = null;
|
||||
let parser = null;
|
||||
|
||||
// VASLOG_ENG subpath must be checked before VASLOG (substring overlap).
|
||||
if (filePath.includes('VASLOG - Engineering Tested')) {
|
||||
logType = 'VASLOG_ENG';
|
||||
parser = LOG_TYPES['VASLOG_ENG'].parser;
|
||||
} else {
|
||||
for (const [type, config] of Object.entries(LOG_TYPES)) {
|
||||
if (type === 'VASLOG_ENG') continue;
|
||||
if (filePath.includes(type)) {
|
||||
logType = type;
|
||||
parser = config.parser;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!logType) {
|
||||
if (/\.SHT$/i.test(filePath)) {
|
||||
logType = 'SHT';
|
||||
parser = 'shtfile';
|
||||
} else {
|
||||
console.log(` Unknown log type for: ${filePath}`);
|
||||
return { total: 0, imported: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
let result;
|
||||
await db.transaction(async (txClient) => {
|
||||
result = await importFile(txClient, filePath, logType, parser);
|
||||
});
|
||||
|
||||
console.log(` Imported ${result.imported} of ${result.total} records`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Import multiple files (for batch incremental imports)
|
||||
async function importFiles(filePaths) {
|
||||
console.log(`\n========================================`);
|
||||
console.log(`Incremental Import: ${filePaths.length} files`);
|
||||
console.log(`========================================`);
|
||||
|
||||
let totalImported = 0;
|
||||
let totalRecords = 0;
|
||||
|
||||
await db.transaction(async (txClient) => {
|
||||
for (const filePath of filePaths) {
|
||||
let logType = null;
|
||||
let parser = null;
|
||||
|
||||
// VASLOG_ENG subpath must be checked before the generic loop --
|
||||
// otherwise `includes('VASLOG')` hits first and the eng .txt gets
|
||||
// dispatched to the multiline parser. Mirror importSingleFile().
|
||||
if (filePath.includes('VASLOG - Engineering Tested')) {
|
||||
logType = 'VASLOG_ENG';
|
||||
parser = LOG_TYPES['VASLOG_ENG'].parser;
|
||||
} else {
|
||||
for (const [type, config] of Object.entries(LOG_TYPES)) {
|
||||
if (type === 'VASLOG_ENG') continue;
|
||||
if (filePath.includes(type)) {
|
||||
logType = type;
|
||||
parser = config.parser;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!logType) {
|
||||
if (/\.SHT$/i.test(filePath)) {
|
||||
logType = 'SHT';
|
||||
parser = 'shtfile';
|
||||
} else {
|
||||
console.log(` Skipping unknown type: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const { total, imported } = await importFile(txClient, filePath, logType, parser);
|
||||
totalRecords += total;
|
||||
totalImported += imported;
|
||||
console.log(` ${path.basename(filePath)}: ${imported}/${total} records`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nTotal: ${totalImported} records imported (${totalRecords} parsed)`);
|
||||
|
||||
// Export datasheets for newly imported records
|
||||
if (totalImported > 0) {
|
||||
try {
|
||||
const { loadAllSpecs } = require('../parsers/spec-reader');
|
||||
const { exportNewRecords } = require('./export-datasheets');
|
||||
const specMap = loadAllSpecs();
|
||||
await exportNewRecords(specMap, filePaths);
|
||||
} catch (err) {
|
||||
console.error(`[EXPORT] Datasheet export failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { total: totalRecords, imported: totalImported };
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length > 0 && args[0] === '--file') {
|
||||
const files = args.slice(1);
|
||||
if (files.length === 0) {
|
||||
console.log('Usage: node import.js --file <file1> [file2] ...');
|
||||
process.exit(1);
|
||||
}
|
||||
importFiles(files).then(() => db.close()).catch(console.error);
|
||||
} else if (args.length > 0 && args[0] === '--help') {
|
||||
console.log('Usage:');
|
||||
console.log(' node import.js Full import from all sources');
|
||||
console.log(' node import.js --file <f> Import specific file(s)');
|
||||
process.exit(0);
|
||||
} else {
|
||||
runImport().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runImport, importSingleFile, importFiles };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* API Routes for Test Data Database
|
||||
*
|
||||
* PostgreSQL version - uses pg.Pool via database/db.js.
|
||||
* All route handlers are async. FTS uses tsvector/plainto_tsquery.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const db = require('../database/db');
|
||||
const { generateDatasheet } = require('../templates/datasheet');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAX_LIMIT = 1000;
|
||||
|
||||
function clampLimit(value) {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed) || parsed < 1) return 100;
|
||||
return Math.min(parsed, MAX_LIMIT);
|
||||
}
|
||||
|
||||
function clampOffset(value) {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed) || parsed < 0) return 0;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/search
|
||||
// Search test records
|
||||
// Query params: serial, model, from, to, result, q, station, logtype, limit, offset
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { serial, model, from, to, result, q, station, logtype, workorder } = req.query;
|
||||
const limit = clampLimit(req.query.limit || 100);
|
||||
const offset = clampOffset(req.query.offset || 0);
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIdx = 0;
|
||||
|
||||
const addParam = (val) => {
|
||||
paramIdx++;
|
||||
params.push(val);
|
||||
return '$' + paramIdx;
|
||||
};
|
||||
|
||||
if (q) {
|
||||
// Full-text search using tsvector
|
||||
conditions.push(`search_vector @@ plainto_tsquery('english', ${addParam(q)})`);
|
||||
}
|
||||
|
||||
if (serial) {
|
||||
const val = serial.includes('%') ? serial : `%${serial}%`;
|
||||
conditions.push(`serial_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (workorder) {
|
||||
conditions.push(`work_order = ${addParam(workorder)}`);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
const val = model.includes('%') ? model : `%${model}%`;
|
||||
conditions.push(`model_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (from) {
|
||||
conditions.push(`test_date >= ${addParam(from)}`);
|
||||
}
|
||||
|
||||
if (to) {
|
||||
conditions.push(`test_date <= ${addParam(to)}`);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
conditions.push(`overall_result = ${addParam(result.toUpperCase())}`);
|
||||
}
|
||||
|
||||
if (station) {
|
||||
conditions.push(`test_station = ${addParam(station)}`);
|
||||
}
|
||||
|
||||
if (logtype) {
|
||||
conditions.push(`log_type = ${addParam(logtype)}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
|
||||
const dataSql = `SELECT * FROM test_records ${where} ORDER BY test_date DESC, serial_number LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}`;
|
||||
const countSql = `SELECT COUNT(*) as count FROM test_records ${where}`;
|
||||
const countParams = params.slice(0, paramIdx - 2); // exclude limit/offset
|
||||
|
||||
const [records, countRow] = await Promise.all([
|
||||
db.query(dataSql, params),
|
||||
db.queryOne(countSql, countParams),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
records,
|
||||
total: countRow?.count ? parseInt(countRow.count, 10) : records.length,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [SEARCH ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/record/:id
|
||||
// Get single record by ID
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/record/:id', async (req, res) => {
|
||||
try {
|
||||
const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
res.json(record);
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [RECORD ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/datasheet/:id
|
||||
// Generate datasheet for a record
|
||||
// Query params: format (html, txt)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/datasheet/:id', async (req, res) => {
|
||||
try {
|
||||
const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
const format = req.query.format || 'html';
|
||||
|
||||
// Try exact-match formatter first
|
||||
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
||||
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
||||
const specMap = loadAllSpecs();
|
||||
const specs = getSpecs(specMap, record.model_number);
|
||||
const exactTxt = generateExactDatasheet(record, specs);
|
||||
|
||||
if (exactTxt && format === 'html') {
|
||||
// Render exact-match TXT as styled HTML page
|
||||
const escaped = exactTxt
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Data Sheet - ${record.serial_number}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.page {
|
||||
background: white;
|
||||
padding: 40px 30px;
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
pre {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.toolbar button:hover { background: #e0e0e0; }
|
||||
@media print {
|
||||
body { background: white; padding: 0; }
|
||||
.page { box-shadow: none; border: none; padding: 0; }
|
||||
.toolbar { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button onclick="window.print()">Print</button>
|
||||
<button onclick="window.open('/api/datasheet/${record.id}/pdf')">Download PDF</button>
|
||||
<button onclick="window.close()">Close</button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<pre>${escaped}</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
res.type('html').send(html);
|
||||
} else if (exactTxt && format === 'txt') {
|
||||
res.type('text/plain').send(exactTxt);
|
||||
} else {
|
||||
// Fall back to generic template
|
||||
const datasheet = generateDatasheet(record, format);
|
||||
if (format === 'html') {
|
||||
res.type('html').send(datasheet);
|
||||
} else {
|
||||
res.type('text/plain').send(datasheet);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [DATASHEET ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/datasheet/:id/pdf
|
||||
// Generate PDF datasheet for a record (on-demand download)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/datasheet/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
||||
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
||||
const PDFDocument = require('pdfkit');
|
||||
|
||||
const specMap = loadAllSpecs();
|
||||
const specs = getSpecs(specMap, record.model_number);
|
||||
let txt = generateExactDatasheet(record, specs);
|
||||
|
||||
// Fall back to generic datasheet if exact-match formatter doesn't support this family
|
||||
if (!txt) {
|
||||
txt = generateDatasheet(record, 'txt');
|
||||
}
|
||||
|
||||
if (!txt) {
|
||||
return res.status(422).json({ error: 'Could not generate datasheet (missing specs or data)' });
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: 'LETTER',
|
||||
margins: { top: 36, bottom: 36, left: 36, right: 36 }
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${record.serial_number}.pdf"`);
|
||||
doc.pipe(res);
|
||||
|
||||
doc.font('Courier').fontSize(9.5);
|
||||
const lines = txt.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
doc.text(line, { lineGap: 1 });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [PDF ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/stats
|
||||
// Get database statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const [totalRow, byLogType, byResult, byStation, dateRange, recentSerials] = await Promise.all([
|
||||
db.queryOne('SELECT COUNT(*) as count FROM test_records'),
|
||||
db.query('SELECT log_type, COUNT(*) as count FROM test_records GROUP BY log_type ORDER BY count DESC'),
|
||||
db.query('SELECT overall_result, COUNT(*) as count FROM test_records GROUP BY overall_result'),
|
||||
db.query(`SELECT test_station, COUNT(*) as count FROM test_records
|
||||
WHERE test_station IS NOT NULL AND test_station != ''
|
||||
GROUP BY test_station ORDER BY test_station`),
|
||||
db.queryOne('SELECT MIN(test_date) as oldest, MAX(test_date) as newest FROM test_records'),
|
||||
db.query(`SELECT DISTINCT serial_number, model_number, test_date
|
||||
FROM test_records ORDER BY test_date DESC LIMIT 10`),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
total_records: parseInt(totalRow.count, 10),
|
||||
by_log_type: byLogType.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
by_result: byResult.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
by_station: byStation.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
date_range: dateRange,
|
||||
recent_serials: recentSerials,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [STATS ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/filters
|
||||
// Get available filter options (test stations, log types, models)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/filters', async (req, res) => {
|
||||
try {
|
||||
const [stations, logTypes, models] = await Promise.all([
|
||||
db.query(`SELECT DISTINCT test_station FROM test_records
|
||||
WHERE test_station IS NOT NULL AND test_station != ''
|
||||
ORDER BY test_station`),
|
||||
db.query('SELECT DISTINCT log_type FROM test_records ORDER BY log_type'),
|
||||
db.query(`SELECT DISTINCT model_number, COUNT(*) as count FROM test_records
|
||||
GROUP BY model_number ORDER BY count DESC LIMIT 500`),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
stations: stations.map(r => r.test_station),
|
||||
log_types: logTypes.map(r => r.log_type),
|
||||
models: models.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [FILTERS ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/export
|
||||
// Export search results as CSV
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const { serial, model, from, to, result, station, logtype } = req.query;
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIdx = 0;
|
||||
|
||||
const addParam = (val) => {
|
||||
paramIdx++;
|
||||
params.push(val);
|
||||
return '$' + paramIdx;
|
||||
};
|
||||
|
||||
if (serial) {
|
||||
const val = serial.includes('%') ? serial : `%${serial}%`;
|
||||
conditions.push(`serial_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
const val = model.includes('%') ? model : `%${model}%`;
|
||||
conditions.push(`model_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (from) {
|
||||
conditions.push(`test_date >= ${addParam(from)}`);
|
||||
}
|
||||
|
||||
if (to) {
|
||||
conditions.push(`test_date <= ${addParam(to)}`);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
conditions.push(`overall_result = ${addParam(result.toUpperCase())}`);
|
||||
}
|
||||
|
||||
if (station) {
|
||||
conditions.push(`test_station = ${addParam(station)}`);
|
||||
}
|
||||
|
||||
if (logtype) {
|
||||
conditions.push(`log_type = ${addParam(logtype)}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const sql = `SELECT * FROM test_records ${where} ORDER BY test_date DESC, serial_number LIMIT 10000`;
|
||||
|
||||
const records = await db.query(sql, params);
|
||||
|
||||
// Generate CSV
|
||||
const headers = ['id', 'log_type', 'model_number', 'serial_number', 'test_date', 'test_station', 'overall_result', 'source_file'];
|
||||
let csv = headers.join(',') + '\n';
|
||||
|
||||
for (const record of records) {
|
||||
const row = headers.map(h => {
|
||||
const val = record[h] || '';
|
||||
return `"${String(val).replace(/"/g, '""')}"`;
|
||||
});
|
||||
csv += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=test_records.csv');
|
||||
res.send(csv);
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [EXPORT ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/workorder/:wo
|
||||
// Get work order details and all associated test lines
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/workorder/:wo', async (req, res) => {
|
||||
try {
|
||||
const wo = req.params.wo;
|
||||
|
||||
const [header, lines, testRecords] = await Promise.all([
|
||||
db.queryOne('SELECT * FROM work_orders WHERE wo_number = $1', [wo]),
|
||||
db.query('SELECT * FROM work_order_lines WHERE wo_number = $1 ORDER BY test_date, test_time', [wo]),
|
||||
db.query(
|
||||
'SELECT id, log_type, model_number, serial_number, test_date, test_station, overall_result, work_order FROM test_records WHERE work_order = $1 ORDER BY serial_number',
|
||||
[wo]
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
work_order: header || { wo_number: wo },
|
||||
lines,
|
||||
test_records: testRecords,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [WO ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/workorder-search?q=<query>
|
||||
// Search work orders by number (prefix match)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/workorder-search', async (req, res) => {
|
||||
try {
|
||||
const q = req.query.q || '';
|
||||
if (q.length < 2) {
|
||||
return res.json({ results: [] });
|
||||
}
|
||||
|
||||
const results = await db.query(
|
||||
'SELECT wo_number, wo_date, program, test_station FROM work_orders WHERE wo_number LIKE $1 ORDER BY wo_date DESC LIMIT 50',
|
||||
[q + '%']
|
||||
);
|
||||
|
||||
res.json({ results });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup function for graceful shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
async function cleanup() {
|
||||
try {
|
||||
await db.close();
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [CLEANUP ERROR] ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.cleanup = cleanup;
|
||||
Reference in New Issue
Block a user