diff --git a/TASKS.md b/TASKS.md index f7b5577..777954d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -68,14 +68,14 @@ - **Deps:** F-004 | **Est:** 4 hrs | **Status:** ✅ Complete ### F-006: Epicor MSSQL Connection Service -- [ ] `src/lib/epicor.ts` — connection pool with `mssql` package -- [ ] `execStoredProc()` helper with typed params -- [ ] `execQuery()` helper for raw SQL queries -- [ ] Connection health check function -- [ ] Graceful error handling (connection timeout, query failure) -- [ ] Type definitions for Epicor query results in `src/types/epicor.ts` -- [ ] Test connection against Epicor10Live with `portal` user -- **Deps:** F-001 | **Est:** 3 hrs +- [x] `src/lib/epicor.ts` — connection pool with `mssql` package +- [x] `execStoredProc()` helper with typed params +- [x] `execQuery()` helper for raw SQL queries +- [x] Connection health check function +- [x] Graceful error handling (connection timeout, query failure) +- [x] Type definitions for Epicor query results in `src/types/epicor.ts` +- [~] Test connection against Epicor10Live with `portal` user (pending actual credentials) +- **Deps:** F-001 | **Est:** 3 hrs | **Status:** ✅ Complete ### F-007: Better Auth Setup - [ ] Install and configure Better Auth diff --git a/src/lib/epicor.ts b/src/lib/epicor.ts index 6075b84..83a2088 100644 --- a/src/lib/epicor.ts +++ b/src/lib/epicor.ts @@ -1,4 +1,33 @@ -import sql from 'mssql'; +import sql, { IResult, IProcedureResult } from 'mssql'; + +// ============================================================================= +// Types & Errors +// ============================================================================= + +export class EpicorConnectionError extends Error { + constructor(message: string, public originalError?: unknown) { + super(message); + this.name = 'EpicorConnectionError'; + } +} + +export class EpicorQueryError extends Error { + constructor( + message: string, + public query?: string, + public originalError?: unknown + ) { + super(message); + this.name = 'EpicorQueryError'; + } +} + +export class EpicorTimeoutError extends Error { + constructor(message: string, public timeoutMs: number) { + super(message); + this.name = 'EpicorTimeoutError'; + } +} type EpicorConfig = { server: string; @@ -9,9 +38,15 @@ type EpicorConfig = { options: { encrypt: boolean; trustServerCertificate: boolean; + connectTimeout: number; + requestTimeout: number; }; }; +// ============================================================================= +// Configuration +// ============================================================================= + const config: EpicorConfig = { server: process.env.MSSQL_HOST || '', database: process.env.MSSQL_DATABASE || '', @@ -21,35 +56,92 @@ const config: EpicorConfig = { options: { encrypt: false, trustServerCertificate: true, + connectTimeout: 30000, // 30 seconds + requestTimeout: 60000, // 60 seconds }, }; +// ============================================================================= +// Connection Pool Management +// ============================================================================= + let pool: sql.ConnectionPool | null = null; +let connectionPromise: Promise | null = null; async function getPool(): Promise { - if (!pool) { - pool = await new sql.ConnectionPool(config).connect(); + // If we have an active pool, return it + if (pool && pool.connected) { + return pool; } - return pool; + + // If connection is in progress, wait for it + if (connectionPromise) { + return connectionPromise; + } + + // Validate configuration + if (!config.server || !config.database || !config.user) { + throw new EpicorConnectionError( + 'Epicor database configuration is incomplete. Check MSSQL_* environment variables.' + ); + } + + // Create new connection + connectionPromise = new sql.ConnectionPool(config) + .connect() + .then((connectedPool) => { + pool = connectedPool; + connectionPromise = null; + console.log('Epicor SQL Server connection established'); + return connectedPool; + }) + .catch((error) => { + connectionPromise = null; + pool = null; + throw new EpicorConnectionError( + 'Failed to connect to Epicor SQL Server', + error + ); + }); + + return connectionPromise; } +// ============================================================================= +// Query Execution +// ============================================================================= + export async function execStoredProc( procedureName: string, - params: Record + params?: Record ): Promise { try { const connectionPool = await getPool(); const request = connectionPool.request(); - // Add parameters - for (const [key, value] of Object.entries(params)) { - request.input(key, value); + // Add parameters if provided + if (params) { + for (const [key, value] of Object.entries(params)) { + request.input(key, value); + } } - const result = await request.execute(procedureName); + const result: IProcedureResult = await request.execute(procedureName); return result.recordset as T; } catch (error) { - console.error(`Error executing stored procedure ${procedureName}:`, error); + if (error instanceof Error) { + if (error.message.includes('timeout')) { + throw new EpicorTimeoutError( + `Stored procedure ${procedureName} timed out`, + config.options.requestTimeout + ); + } + throw new EpicorQueryError( + `Error executing stored procedure: ${procedureName}`, + procedureName, + error + ); + } throw error; } } @@ -69,28 +161,114 @@ export async function execQuery( } } - const result = await request.query(query); + const result: IResult = await request.query(query); return result.recordset as T; } catch (error) { - console.error('Error executing query:', error); + if (error instanceof Error) { + if (error.message.includes('timeout')) { + throw new EpicorTimeoutError( + 'Query execution timed out', + config.options.requestTimeout + ); + } + throw new EpicorQueryError('Error executing SQL query', query, error); + } throw error; } } -export async function checkConnection(): Promise { +// ============================================================================= +// Health Check & Utilities +// ============================================================================= + +export async function checkConnection(): Promise<{ + connected: boolean; + error?: string; + server?: string; + database?: string; +}> { try { - await getPool(); - return true; + const connectionPool = await getPool(); + + // Try a simple query to verify connection is actually working + const request = connectionPool.request(); + await request.query('SELECT 1 AS test'); + + return { + connected: true, + server: config.server, + database: config.database, + }; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; console.error('Epicor connection check failed:', error); - return false; + + return { + connected: false, + error: errorMessage, + server: config.server, + database: config.database, + }; } } -// Graceful shutdown -process.on('SIGINT', async () => { +export async function closeConnection(): Promise { if (pool) { - await pool.close(); - console.log('Epicor connection pool closed'); + try { + await pool.close(); + console.log('Epicor connection pool closed'); + } catch (error) { + console.error('Error closing Epicor connection pool:', error); + } finally { + pool = null; + connectionPromise = null; + } } +} + +// ============================================================================= +// Common Query Helpers +// ============================================================================= + +/** + * Execute a stored procedure with standard Epicor portal parameters + */ +export async function execPortalStoredProc( + procedureName: string, + custId: string, + dbName: string, + sub: 0 | 1, + additionalParams?: Record +): Promise { + const params = { + CustID: custId, + DBNAME: dbName, + sub: sub, + ...additionalParams, + }; + + return execStoredProc(procedureName, params); +} + +/** + * Get the portal database name with brackets + */ +export function getPortalDbName(): string { + const dbName = process.env.PORTAL_DB_NAME || 'VorteqPortal'; + return `[${dbName}]`; +} + +// ============================================================================= +// Graceful Shutdown +// ============================================================================= + +process.on('SIGINT', async () => { + await closeConnection(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + await closeConnection(); + process.exit(0); }); diff --git a/src/types/epicor.ts b/src/types/epicor.ts index 067f106..569ee18 100644 --- a/src/types/epicor.ts +++ b/src/types/epicor.ts @@ -1,23 +1,306 @@ -// Epicor query result types -// To be populated as we implement services in F-006 and beyond +// ============================================================================= +// Epicor Query Types +// ============================================================================= -export type EpicorQueryParams = { +// Standard parameters for portal stored procedures +export type EpicorPortalParams = { CustID: string; DBNAME: string; - sub: 0 | 1; + sub: 0 | 1; // IsSubUser flag }; +// ============================================================================= +// Inventory Types +// ============================================================================= + export type InventorySummaryRow = { - // To be defined based on stored procedure results + PartNum: string; + PartDescription: string; + Plant: string; + Warehouse: string; + OnHandQty: number; + AllocatedQty?: number; + AvailableQty?: number; + UOM?: string; + LotNum?: string; + PaintCode?: string; + // Additional fields vary by inventory type [key: string]: unknown; }; +export type InventoryDetailRow = { + PartNum: string; + PartDescription: string; + LotNum: string; + Plant: string; + Warehouse: string; + BinNum?: string; + OnHandQty: number; + AllocatedQty?: number; + AvailableQty?: number; + Weight?: number; + Width?: number; + Length?: number; + Thickness?: number; + PaintCode?: string; + // Additional fields vary by inventory type + [key: string]: unknown; +}; + +// ============================================================================= +// Order Types +// ============================================================================= + export type OrderRow = { - // To be defined based on portal_Orders view + OrderNum: number; + PONum: string; + CustID: string; + CustomerName: string; + PartNum: string; + CustomerPartNum?: string; + VorteqPartNum?: string; + OrderQty: number; + ShippedQty?: number; + RemainingQty?: number; + OrderDate: Date; + RequestDate?: Date; + NeedByDate?: Date; + ShipByDate?: Date; + OrderStatus: string; + LineStatus?: string; + // Additional fields from portal_Orders view [key: string]: unknown; }; -export type ShipmentRow = { - // To be defined based on portal_GetShipmentsV1 +export type OrderAcknowledgement = { + OrderNum: number; + PONum: string; + Customer: string; + CustomerAddress?: string; + OrderDate: Date; + RequestDate?: Date; + Lines: OrderAcknowledgementLine[]; + // Additional header fields + [key: string]: unknown; +}; + +export type OrderAcknowledgementLine = { + LineNum: number; + PartNum: string; + PartDescription: string; + OrderQty: number; + UnitPrice?: number; + ExtendedPrice?: number; + RequestDate?: Date; + PromiseDate?: Date; + // Additional line fields + [key: string]: unknown; +}; + +// ============================================================================= +// Shipment Types +// ============================================================================= + +export type ShipmentRow = { + PackNum: number; + InvoiceNum?: number; + BOLNum?: string; + CustID: string; + CustomerName: string; + ShipDate: Date; + ShipToNum: string; + ShipToLoc: string; + TotalWeight?: number; + TotalPackages?: number; + TrackingNumber?: string; + // Additional fields from portal_GetShipmentsV1 + [key: string]: unknown; +}; + +export type BOLDetail = { + BOLNum: string; + ShipDate: Date; + CustID: string; + CustomerName: string; + ShipToAddress: string; + Lines: BOLLine[]; + TotalWeight: number; + TotalPackages: number; + // Additional header fields + [key: string]: unknown; +}; + +export type BOLLine = { + PackLine: number; + PartNum: string; + PartDescription: string; + OrderNum: number; + PONum: string; + ShipQty: number; + Weight?: number; + LotNum?: string; + // Additional line fields + [key: string]: unknown; +}; + +// ============================================================================= +// Coil Activity Types +// ============================================================================= + +export type CoilActivityUsageRow = { + PartNum: string; + PartDescription: string; + LotNum: string; + DateUsed: Date; + Weight?: number; + Width?: number; + Thickness?: number; + Length?: number; + OnHandQty?: number; // Only for most recent DateUsed per LotNum + Plant: string; + // Additional fields + [key: string]: unknown; +}; + +export type CoilActivityReceiptRow = { + PartNum: string; + PartDescription: string; + LotNum: string; + ReceivedDate: Date; + ReceivedQty: number; + Weight?: number; + Width?: number; + Thickness?: number; + PONum?: string; + VendorID?: string; + // Additional fields + [key: string]: unknown; +}; + +export type CoilByCoilRow = { + JobNum: string; + PartNum: string; + LotNum: string; + CoilNum: string; + Weight: number; + Width?: number; + Thickness?: number; + Length?: number; + Status?: string; + // Additional fields + [key: string]: unknown; +}; + +// ============================================================================= +// Job Types +// ============================================================================= + +export type JobStatusRow = { + JobNum: string; + PartNum: string; + PartDescription: string; + Plant: string; + JobStatus: string; + OrderNum?: number; + PONum?: string; + Quantity: number; + CompletedQty?: number; + StartDate?: Date; + DueDate?: Date; + // Additional fields + [key: string]: unknown; +}; + +export type JobTravelerRow = { + JobNum: string; + PartNum: string; + PartDescription: string; + Operations: JobOperation[]; + Materials: JobMaterial[]; + // Additional fields + [key: string]: unknown; +}; + +export type JobOperation = { + OprSeq: number; + OpCode: string; + OpDesc: string; + SetupHours?: number; + ProdHours?: number; + CompletedQty?: number; + // Additional fields + [key: string]: unknown; +}; + +export type JobMaterial = { + MtlSeq: number; + PartNum: string; + Description: string; + RequiredQty: number; + IssuedQty?: number; + // Additional fields + [key: string]: unknown; +}; + +// ============================================================================= +// Customer/Address Types +// ============================================================================= + +export type CustomerShipToAddress = { + CustID: string; + ShipToNum: string; + Name: string; + Address1?: string; + Address2?: string; + Address3?: string; + City?: string; + State?: string; + ZIP?: string; + Country?: string; + FullAddress: string; // Formatted address + // Additional fields + [key: string]: unknown; +}; + +// ============================================================================= +// Invoice Types +// ============================================================================= + +export type InvoiceCustomerMatch = { + InvoiceNum: string; + CustID: string; + CustomerName: string; +}; + +export type InvoiceJobMatch = { + InvoiceNum: string; + JobNum: string; +}; + +// ============================================================================= +// Shipping Report Types +// ============================================================================= + +export type ShippingForMonth = { + Month: string; + Year: number; + CustID: string; + CustomerName: string; + TotalShipped: number; + TotalWeight?: number; + TotalValue?: number; + // Additional fields + [key: string]: unknown; +}; + +export type ShippingYTD = { + Year: number; + CustID: string; + CustomerName: string; + TotalShipped: number; + TotalWeight?: number; + TotalValue?: number; + MonthlyBreakdown?: Record; + // Additional fields [key: string]: unknown; };