= await request.query(query);
- return result.recordset as T;
+ return [...result.recordset] as T;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('timeout')) {
diff --git a/src/lib/pdf-templates/bol.ts b/src/lib/pdf-templates/bol.ts
new file mode 100644
index 0000000..41007c4
--- /dev/null
+++ b/src/lib/pdf-templates/bol.ts
@@ -0,0 +1,210 @@
+/**
+ * Bill of Lading PDF HTML Template
+ *
+ * Renders a complete HTML document matching the order-ack layout pattern.
+ * Uses inline CSS only (no external stylesheets) since Puppeteer
+ * renders from an in-memory HTML string.
+ */
+
+import type { BOLData, BOLLine } from '@/types/shipments';
+
+function fmtDate(iso: string): string {
+ if (!iso) return '';
+ const d = new Date(iso + 'T00:00:00Z'); // Force UTC
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(d.getUTCDate()).padStart(2, '0');
+ const yyyy = d.getUTCFullYear();
+ return `${mm}/${dd}/${yyyy}`;
+}
+
+function fmtAddress(
+ name: string,
+ addr1: string,
+ addr2: string,
+ city: string,
+ state: string,
+ zip: string
+): string {
+ const lines = [];
+ if (name) lines.push(name);
+ if (addr1) lines.push(addr1);
+ if (addr2) lines.push(addr2);
+ const cityLine = [city, state].filter(Boolean).join(', ');
+ if (cityLine || zip) lines.push(`${cityLine} ${zip}`.trim());
+ return lines.join('
');
+}
+
+function fmtQty(qty: number): string {
+ return qty.toLocaleString('en-US', { minimumFractionDigits: 2 });
+}
+
+function renderLineHtml(line: BOLLine): string {
+ const customerPartLine =
+ line.cust_part_num
+ ? `
+ Customer Part: ${line.cust_part_num}
+
`
+ : '';
+
+ const orderRefLine =
+ line.order_num && line.order_line
+ ? `
+ Order ${line.order_num} / Line ${line.order_line}${line.po_num ? ` / PO ${line.po_num}` : ''}
+
`
+ : '';
+
+ return `
+
+ | ${line.pack_line} |
+
+ ${line.part_num}
+ ${line.part_description}
+ ${customerPartLine}
+ ${orderRefLine}
+ |
+ ${line.revision || ''} |
+ ${line.lot_num || ''} |
+ ${fmtQty(line.ship_qty)} |
+ ${line.uom} |
+ ${fmtQty(line.net_weight)} |
+
+ `;
+}
+
+// Vorteq Q logo as inline SVG matching the brand
+const VORTEQ_LOGO_SVG = `
+
+`;
+
+export function renderBOLHtml(data: BOLData): string {
+ const { header, lines } = data;
+
+ const shipFrom = fmtAddress(
+ header.plant_name,
+ header.plant_address1,
+ header.plant_address2,
+ header.plant_city,
+ header.plant_state,
+ header.plant_zip
+ );
+
+ const shipTo = fmtAddress(
+ header.ship_to_name,
+ header.ship_to_address1,
+ header.ship_to_address2,
+ header.ship_to_city,
+ header.ship_to_state,
+ header.ship_to_zip
+ );
+
+ const lineRows = lines.map(renderLineHtml).join('');
+
+ return `
+
+
+
+
+
+
+
+
+
+ |
+ ${VORTEQ_LOGO_SVG}
+ VORTEQ
+ |
+
+ 11440 W Addison St
+ Franklin Park, IL 60131
+ |
+
+ Bill of Lading
+ Phone: 847-455-7200
+ Fax: 847-455-7608
+ |
+
+
+
+
+
+
BOL #: ${header.bol_num}
+
Pack #: ${header.pack_num}
+
Ship Date: ${fmtDate(header.ship_date)}
+
+
+
+
+
+ |
+ Ship From:
+ ${shipFrom}
+ |
+
+ Ship To:
+ ${shipTo}
+ |
+
+
+
+
+
+
+ |
+ Ship Via: ${header.ship_via || '-'}
+ |
+
+ Customer: ${header.customer_name}
+ |
+
+
+
+
+
+
+
+ | Line |
+ Part Number/Description |
+ Rev |
+ Lot # |
+ Qty Shipped |
+ UOM |
+ Weight |
+
+
+
+ ${lineRows}
+
+
+
+
+
+
+ | Total Lines: |
+
+ ${header.total_lines}
+ |
+
+
+ | Total Weight: |
+
+ ${fmtQty(header.total_weight)} lbs
+ |
+
+
+
+`;
+}
diff --git a/src/lib/pdf-templates/order-acknowledgement.ts b/src/lib/pdf-templates/order-acknowledgement.ts
new file mode 100644
index 0000000..979a143
--- /dev/null
+++ b/src/lib/pdf-templates/order-acknowledgement.ts
@@ -0,0 +1,278 @@
+/**
+ * Order Acknowledgement PDF HTML Template
+ *
+ * Renders a complete HTML document matching the legacy PDF layout.
+ * Reference: docs/order-acknowledgement-po-233782.pdf
+ *
+ * Uses inline CSS only (no external stylesheets) since Puppeteer
+ * renders from an in-memory HTML string.
+ */
+
+import type {
+ OrderAcknowledgementData,
+ OrderAckLine,
+ OrderAckRelease,
+} from '@/types/orders';
+
+function fmtDate(iso: string): string {
+ if (!iso) return '';
+ const d = new Date(iso);
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const yyyy = d.getFullYear();
+ return `${mm}/${dd}/${yyyy}`;
+}
+
+function fmtCurrency(amount: number): string {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ }).format(amount);
+}
+
+function fmtQty(qty: number, um: string): string {
+ return `${qty.toLocaleString('en-US')} ${um}`;
+}
+
+function fmtUnitPrice(price: number): string {
+ return `.${(price * 100000).toFixed(0).padStart(5, '0')} / 1`;
+}
+
+function fmtAddress(
+ name: string,
+ addr1: string,
+ addr2: string,
+ city: string,
+ state: string,
+ zip: string
+): string {
+ const lines = [];
+ if (name) lines.push(name);
+ if (addr1) lines.push(addr1);
+ if (addr2) lines.push(addr2);
+ const cityLine = [city, state].filter(Boolean).join(', ');
+ if (cityLine || zip) lines.push(`${cityLine} ${zip}`.trim());
+ return lines.join('
');
+}
+
+function renderReleasesHtml(releases: OrderAckRelease[]): string {
+ if (releases.length === 0) return '';
+
+ const rows = releases
+ .map(
+ (r) => `
+
+ | ${r.release_num} |
+ ${fmtDate(r.need_by_date)} |
+ ${r.quantity.toLocaleString('en-US', { minimumFractionDigits: 2 })} |
+ ${r.job_num || ''} |
+
+ `
+ )
+ .join('');
+
+ return `
+
+
+ | Rel |
+ Date |
+ Quantity |
+ Job Number |
+
+ ${rows}
+
+ `;
+}
+
+function renderLineHtml(line: OrderAckLine): string {
+ const paintCodes = [line.top_finish, line.bottom_finish]
+ .filter(Boolean)
+ .join(' / ');
+
+ const customerPartLine =
+ line.customer_part
+ ? `
+ Our Part: ${line.customer_part}${line.customer_part_desc ? ` / ${line.customer_part_desc}` : ''}
+
`
+ : '';
+
+ const paintLine = paintCodes
+ ? `${paintCodes}
`
+ : '';
+
+ const commentLine = line.comment
+ ? `
+ ${line.comment}
+
`
+ : '';
+
+ return `
+
+ | ${line.order_line} |
+
+ ${line.part_num}
+ ${line.part_description}
+ ${customerPartLine}
+ ${paintLine}
+ ${renderReleasesHtml(line.releases)}
+ ${commentLine}
+ |
+ ${line.revision || ''} |
+ ${fmtQty(line.order_qty, line.unit_of_measure)} |
+ ${fmtUnitPrice(line.unit_price)} |
+ ${fmtCurrency(line.extended_price)} |
+
+ `;
+}
+
+// Vorteq Q logo as inline SVG matching the brand
+const VORTEQ_LOGO_SVG = `
+
+`;
+
+export function renderOrderAckHtml(data: OrderAcknowledgementData): string {
+ const { header, lines } = data;
+
+ const soldTo = fmtAddress(
+ header.customer_name,
+ header.customer_address1,
+ header.customer_address2,
+ header.customer_city,
+ header.customer_state,
+ header.customer_zip
+ );
+
+ const shipTo = fmtAddress(
+ header.ship_to_name,
+ header.ship_to_address1,
+ header.ship_to_address2,
+ header.ship_to_city,
+ header.ship_to_state,
+ header.ship_to_zip
+ );
+
+ const lineTotalPrice = lines.reduce((s, l) => s + l.extended_price, 0);
+ const lineMiscCharges = lines.reduce((s, l) => s + l.line_misc_charges, 0);
+ const um = lines[0]?.unit_of_measure || 'LB';
+
+ const lineRows = lines.map(renderLineHtml).join('');
+
+ return `
+
+
+
+
+
+
+
+
+
+ |
+ ${VORTEQ_LOGO_SVG}
+ VORTEQ
+ |
+
+ 11440 W Addison St
+ Franklin Park, IL 60131
+ |
+
+ Sales Order Acknowledgment
+ Phone: 847-455-7200
+ Fax: 847-455-7608
+ |
+
+
+
+
+
+
${header.order_type}
+
Sales Order: ${header.order_num}
+
+
+
+
+
+ |
+ Sold To:
+ ${soldTo}
+ |
+
+ Ship To:
+ ${shipTo}
+ |
+
+
+
+
+
+
+ |
+ Order Date: ${fmtDate(header.order_date)}
+ Need By: ${fmtDate(header.need_by_date)}
+ Payment Terms: ${header.payment_terms || '-'}
+ |
+
+ PO Number: ${header.po_num}
+ Sales Person: ${header.sales_person}
+ Ship Via: ${header.ship_via || '-'}
+ |
+
+ FOB: ${header.fob || '-'}
+ |
+
+
+
+
+ ${header.currency}
+
+
+
+ | Line |
+ Part Number/Description |
+ Rev |
+ Order Qty |
+ Unit Price |
+ Ext. Price |
+
+
+
+ ${lineRows}
+
+
+
+
+
+
+ | Line Total: |
+ ${fmtCurrency(lineTotalPrice)} |
+
+
+ | Line Miscellaneous Charges: |
+ ${fmtCurrency(lineMiscCharges)} |
+
+
+ | Order Miscellaneous Charges: |
+ ${fmtCurrency(data.order_misc_charges)} |
+
+
+ | Order Total: |
+
+ ${data.order_total_qty.toLocaleString('en-US')}${um} ${fmtCurrency(data.order_total_price)}
+ |
+
+
+
+`;
+}
diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts
new file mode 100644
index 0000000..ddfbf27
--- /dev/null
+++ b/src/lib/pdf.ts
@@ -0,0 +1,82 @@
+/**
+ * PDF Generation Service
+ *
+ * Provides Puppeteer-based HTML-to-PDF generation.
+ * Replaces the legacy wkhtmltopdf approach.
+ *
+ * Usage:
+ * import { generatePdfFromHtml } from '@/lib/pdf';
+ * const buffer = await generatePdfFromHtml(htmlString);
+ */
+
+import puppeteer from 'puppeteer';
+import type { OrderAcknowledgementData } from '@/types/orders';
+import { renderOrderAckHtml } from '@/lib/pdf-templates/order-acknowledgement';
+import { renderBOLHtml } from '@/lib/pdf-templates/bol';
+import type { BOLData } from '@/types/shipments';
+
+export type PdfOptions = {
+ format?: 'Letter' | 'A4';
+ landscape?: boolean;
+ margin?: {
+ top?: string;
+ right?: string;
+ bottom?: string;
+ left?: string;
+ };
+};
+
+/**
+ * Generate a PDF from an HTML string using Puppeteer.
+ *
+ * Launches a headless Chromium instance, renders the HTML,
+ * and returns the PDF as a Buffer.
+ */
+export async function generatePdfFromHtml(
+ html: string,
+ options?: PdfOptions
+): Promise {
+ const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
+ });
+
+ try {
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: 'networkidle0' });
+
+ const pdfUint8 = await page.pdf({
+ format: options?.format ?? 'Letter',
+ landscape: options?.landscape ?? false,
+ printBackground: true,
+ margin: options?.margin ?? {
+ top: '0.4in',
+ right: '0.5in',
+ bottom: '0.4in',
+ left: '0.5in',
+ },
+ });
+
+ return Buffer.from(pdfUint8);
+ } finally {
+ await browser.close();
+ }
+}
+
+/**
+ * Generate an Order Acknowledgement PDF.
+ */
+export async function generateOrderAckPdf(
+ data: OrderAcknowledgementData
+): Promise {
+ const html = renderOrderAckHtml(data);
+ return generatePdfFromHtml(html, { format: 'Letter' });
+}
+
+/**
+ * Generate a BOL PDF from data.
+ */
+export async function generateBOLPdf(data: BOLData): Promise {
+ const html = renderBOLHtml(data);
+ return generatePdfFromHtml(html, { format: 'Letter' });
+}
diff --git a/src/services/coil-activity.ts b/src/services/coil-activity.ts
new file mode 100644
index 0000000..66b6da1
--- /dev/null
+++ b/src/services/coil-activity.ts
@@ -0,0 +1,258 @@
+/**
+ * Coil Activity Service
+ *
+ * Fetches coil usage and receipts data from Epicor.
+ * Ported from legacy PHP: EpicorDatabase.php + CoilActivityHelper.php
+ */
+
+import { execQuery } from '@/lib/epicor';
+import type { CoilUsageRow, CoilReceiptRow } from '@/types/coil-activity';
+
+// =============================================================================
+// Coil Usage
+// =============================================================================
+
+/**
+ * Get coil usage data for a customer within a date range.
+ * Returns usage details with weight, on-hand quantity, and job/order information.
+ *
+ * HDC customer exception: maps 'HDC' → 'HDM' for the query.
+ * Post-query deduplication: ensures only the most recent record for each LotNum
+ * retains its OnHandQty value.
+ */
+export async function getCoilUsage(
+ custId: string,
+ startDate: string,
+ endDate: string
+): Promise {
+ // HDC → HDM mapping
+ const queryCustId = custId === 'HDC' ? 'HDM' : custId;
+
+ const sql = `
+ SELECT
+ Part.UserChar2 AS CustID,
+ [PartTran].[TranDate] AS [DateUsed],
+ PartTran.[PartNum] AS [VorteqPartNum],
+ CustXPrt.XPartNum AS [CustomerPartNum],
+ PartTran.[PartDescription] AS [PartDesc],
+ [PartTran].[LotNum] AS [LotNum],
+ SUM([PartTran].[TranQty]) AS [Weight],
+ [Plant].Name AS [PlantName],
+ [PartTran].[JobNum] AS [JobNum],
+ OrderHed.PONum AS [CustomerPO],
+ PartBin.OnhandQty AS [OnHandQty],
+ PartLot.MfgLot
+ FROM Erp.Part AS Part
+ INNER JOIN Erp.PartTran AS PartTran ON Part.Company = PartTran.Company AND Part.PartNum = PartTran.PartNum
+ AND (PartTran.TranQty <> 0.0000 AND PartTran.TranType = 'STK-MTL' AND PartTran.UM = 'LB')
+ LEFT OUTER JOIN Erp.JobHead AS JobHead ON PartTran.Company = JobHead.Company AND PartTran.JobNum = JobHead.JobNum AND PartTran.Plant = JobHead.Plant
+ LEFT OUTER JOIN Erp.Plant AS Plant ON Plant.Company = PartTran.Company AND Plant.Plant = PartTran.Plant
+ LEFT OUTER JOIN Erp.CustXPrt AS CustXPrt ON PartTran.Company = CustXPrt.Company AND PartTran.PartNum = CustXPrt.PartNum
+ LEFT OUTER JOIN Erp.JobProd AS JobProd ON JobHead.Company = JobProd.Company AND JobHead.JobNum = JobProd.JobNum
+ INNER JOIN Erp.OrderHed AS OrderHed ON JobProd.Company = OrderHed.Company AND JobProd.OrderNum = OrderHed.OrderNum
+ FULL OUTER JOIN Erp.PartBin AS PartBin ON PartTran.PartNum = PartBin.PartNum AND PartTran.LotNum = PartBin.LotNum
+ INNER JOIN Erp.JobMtl as JobMtl ON JobHead.Company = JobMtl.Company AND JobHead.JobNum = JobMtl.JobNum AND (JobMtl.IUM = 'LB')
+ LEFT OUTER JOIN Erp.PartLot as PartLot ON PartBin.LotNum = PartLot.LotNum AND PartBin.PartNum = PartLot.PartNum AND PartLot.MfgLot IS NOT null
+ WHERE Part.UserChar2 = @CustID AND [PartTran].[TranDate] >= @StartDate AND [PartTran].[TranDate] <= @EndDate
+ GROUP BY Part.UserChar2, [PartTran].[TranDate], PartTran.[PartNum], CustXPrt.XPartNum, PartTran.[PartDescription], [PartTran].[LotNum], [Plant].Name, [PartTran].[JobNum], OrderHed.PONum, PartBin.OnhandQty, PartLot.MfgLot
+ ORDER BY [PartTran].[TranDate] DESC
+ `;
+
+ const rawRows = await execQuery[]>(sql, {
+ CustID: queryCustId,
+ StartDate: startDate,
+ EndDate: endDate,
+ });
+
+ // Map to typed rows
+ const mappedRows = rawRows.map((r) => mapCoilUsageRow(r));
+
+ // Apply post-query deduplication
+ return deduplicateOnHandQty(mappedRows);
+}
+
+/**
+ * Map raw SQL result to CoilUsageRow
+ */
+function mapCoilUsageRow(raw: Record): CoilUsageRow {
+ return {
+ date_used: raw.DateUsed
+ ? new Date(raw.DateUsed as string).toISOString()
+ : '',
+ vorteq_part_num: String(raw.VorteqPartNum ?? ''),
+ customer_part_num: raw.CustomerPartNum
+ ? String(raw.CustomerPartNum)
+ : null,
+ part_desc: String(raw.PartDesc ?? ''),
+ lot_num: String(raw.LotNum ?? ''),
+ mfg_lot: raw.MfgLot ? String(raw.MfgLot) : null,
+ weight: raw.Weight !== null ? Number(raw.Weight) : null,
+ plant_name: String(raw.PlantName ?? ''),
+ job_num: String(raw.JobNum ?? ''),
+ customer_po: String(raw.CustomerPO ?? ''),
+ on_hand_qty: raw.OnHandQty !== null ? Number(raw.OnHandQty) : null,
+ };
+}
+
+/**
+ * Deduplicate on_hand_qty per lot_num, keeping only the most recent date.
+ * Also nulls out zero weights.
+ *
+ * Ported from legacy PHP getCoilActivityUsageData() logic:
+ * - For each row with on_hand_qty > 0:
+ * - Track lot_num → { date, index }
+ * - If lot_num already seen, compare dates:
+ * - Null out on_hand_qty on the older row
+ * - Keep the newer row's on_hand_qty
+ * - Set weight to null if it equals 0
+ */
+function deduplicateOnHandQty(rows: CoilUsageRow[]): CoilUsageRow[] {
+ const result: CoilUsageRow[] = rows.map((r) => ({ ...r }));
+ const foundRows: Record = {};
+
+ for (let i = 0; i < result.length; i++) {
+ const row = result[i] as CoilUsageRow;
+ const onHandQty = row.on_hand_qty;
+
+ if (onHandQty != null && onHandQty > 0) {
+ const lotNum = row.lot_num;
+ const rowDate = new Date(row.date_used).getTime();
+ const existing = foundRows[lotNum];
+
+ if (existing) {
+ if (rowDate < existing.date) {
+ // Current row is older — null it out
+ result[i] = { ...row, on_hand_qty: null };
+ } else {
+ // Current row is newer — null out the previous one
+ const prev = result[existing.index] as CoilUsageRow;
+ result[existing.index] = { ...prev, on_hand_qty: null };
+ foundRows[lotNum] = { date: rowDate, index: i };
+ }
+ } else {
+ foundRows[lotNum] = { date: rowDate, index: i };
+ }
+ }
+
+ // Null out zero weight
+ const current = result[i] as CoilUsageRow;
+ if (current.weight === 0) {
+ result[i] = { ...current, weight: null };
+ }
+ }
+
+ return result;
+}
+
+// =============================================================================
+// Coil Receipts
+// =============================================================================
+
+/**
+ * Get coil receipt data for a customer within a date range.
+ * Returns receipt details with supplier, alloy, and mill order information.
+ *
+ * VGL customer exception: uses a special SQL query instead of the standard view.
+ * HDC customer exception: maps 'HDC' → 'HDM' for the query.
+ */
+export async function getCoilReceipts(
+ custId: string,
+ startDate: string,
+ endDate: string
+): Promise {
+ // HDC → HDM mapping
+ const queryCustId = custId === 'HDC' ? 'HDM' : custId;
+
+ // VGL uses a special query
+ if (queryCustId === 'VGL') {
+ return getCoilReceiptsVGL(startDate, endDate);
+ }
+
+ // Standard query for all other customers
+ const sql = `
+ SELECT DateReceived, VorteqPartNum, CustomerPartNum, PartDesc, ManufacturerLotNum, PlantName, PackingSlip, SupplierName, MillOrderNum, Alloy
+ FROM dbo.portal_CoilActivityReceipts
+ WHERE CustID = @CustID AND DateReceived >= @StartDate AND DateReceived <= @EndDate
+ ORDER BY DateReceived
+ `;
+
+ const rawRows = await execQuery[]>(sql, {
+ CustID: queryCustId,
+ StartDate: startDate,
+ EndDate: endDate,
+ });
+
+ return rawRows.map((r) => mapCoilReceiptRow(r));
+}
+
+/**
+ * Get coil receipts for VGL customer using the special SQL query.
+ * VGL query joins directly to Epicor tables instead of using the portal view.
+ */
+async function getCoilReceiptsVGL(
+ startDate: string,
+ endDate: string
+): Promise {
+ const sql = `
+ SELECT
+ [Part].[UserChar2] AS [CustID],
+ [Customer].[Name] AS [Customer_Name],
+ [RcvHead].[ReceiptDate] AS [DateReceived],
+ [RcvDtl].[PartNum] AS [VorteqPartNum],
+ [CustXPrt].[XPartNum] AS [CustomerPartNum],
+ [RcvDtl].[PartDescription] AS [PartDesc],
+ [RcvDtl].[LotNum] AS [ManufacturerLotNum],
+ [Plant].[Name] AS [PlantName],
+ [RcvHead].[PackSlip] AS [PackingSlip],
+ [PartLot].[PartLotDescription] AS [SupplierName],
+ [PartLot].[Batch] AS [MillOrderNum],
+ [PartLot].[MfgLot] AS [Alloy],
+ [PartLot].[HeatNum] AS [Temper],
+ [PartLot].[FirmWare] AS [CoilsPerSkid],
+ [RcvDtl].[OurQty] AS [Weight]
+ FROM Erp.RcvHead AS RcvHead
+ INNER JOIN Erp.RcvDtl AS RcvDtl ON RcvHead.Company = RcvDtl.Company AND RcvHead.VendorNum = RcvDtl.VendorNum AND RcvHead.PurPoint = RcvDtl.PurPoint AND RcvHead.PackSlip = RcvDtl.PackSlip
+ INNER JOIN Erp.Vendor AS Vendor ON RcvHead.Company = Vendor.Company AND RcvHead.VendorNum = Vendor.VendorNum
+ INNER JOIN Erp.PartLot AS PartLot ON RcvDtl.Company = PartLot.Company AND RcvDtl.PartNum = PartLot.PartNum AND RcvDtl.LotNum = PartLot.LotNum
+ INNER JOIN Erp.Part AS Part ON RcvDtl.Company = Part.Company AND RcvDtl.PartNum = Part.PartNum AND (Part.UserChar2 = 'VGL')
+ INNER JOIN Erp.Plant AS Plant ON RcvDtl.Company = Plant.Company AND RcvHead.Plant = Plant.Plant
+ INNER JOIN Erp.Customer AS Customer ON Part.UserChar2 = Customer.CustID AND RcvDtl.Company = Customer.Company
+ LEFT OUTER JOIN Erp.CustXPrt AS CustXPrt ON RcvDtl.Company = CustXPrt.Company AND RcvDtl.PartNum = CustXPrt.PartNum AND Part.UserChar2 = CustXPrt.CustID
+ WHERE [RcvHead].[ReceiptDate] >= @StartDate AND [RcvHead].[ReceiptDate] <= @EndDate
+ ORDER BY [RcvHead].[ReceiptDate]
+ `;
+
+ // VGL query only takes date parameters — CustID is hardcoded in the JOIN
+ const rawRows = await execQuery[]>(sql, {
+ StartDate: startDate,
+ EndDate: endDate,
+ });
+
+ return rawRows.map((r) => mapCoilReceiptRow(r));
+}
+
+/**
+ * Map raw SQL result to CoilReceiptRow.
+ * Works for both the standard view query and the VGL special query.
+ * VGL query returns extra columns (Weight, Temper, CoilsPerSkid) but we ignore them.
+ */
+function mapCoilReceiptRow(raw: Record): CoilReceiptRow {
+ return {
+ date_received: raw.DateReceived
+ ? new Date(raw.DateReceived as string).toISOString()
+ : '',
+ vorteq_part_num: String(raw.VorteqPartNum ?? ''),
+ customer_part_num: raw.CustomerPartNum
+ ? String(raw.CustomerPartNum)
+ : null,
+ part_desc: String(raw.PartDesc ?? ''),
+ manufacturer_lot_num: raw.ManufacturerLotNum
+ ? String(raw.ManufacturerLotNum)
+ : null,
+ alloy: raw.Alloy ? String(raw.Alloy) : null,
+ plant_name: String(raw.PlantName ?? ''),
+ packing_slip: String(raw.PackingSlip ?? ''),
+ supplier_name: raw.SupplierName ? String(raw.SupplierName) : null,
+ mill_order_num: raw.MillOrderNum ? String(raw.MillOrderNum) : null,
+ };
+}
diff --git a/src/services/dashboard.ts b/src/services/dashboard.ts
index ffd3de6..84be6be 100644
--- a/src/services/dashboard.ts
+++ b/src/services/dashboard.ts
@@ -1,28 +1,34 @@
/**
* Dashboard Service
*
- * Fetches summary data for the dashboard page
+ * Fetches summary data for the dashboard page.
+ *
+ * IMPORTANT: All rows from execQuery must be JSON-sanitized before returning.
+ * The mssql driver attaches prototype metadata to recordset rows that causes
+ * Next.js RSC serialization to blow the stack. We extract only the fields
+ * we need into plain objects.
*/
-import { execQuery } from '@/lib/epicor';
+import sql from 'mssql';
+import { execQuery, getPortalDbName } from '@/lib/epicor';
+import { getTop100Shipments } from '@/services/shipments';
export type DashboardOrder = {
order_num: string;
po_num: string;
customer_part: string;
vorteq_part: string;
- order_date: Date;
- need_by_date: Date;
+ order_date: string;
+ need_by_date: string;
qty: number;
status: string;
};
export type DashboardShipment = {
bol_num: string;
- pack_num: string;
- ship_date: Date;
+ ship_date: string;
ship_to: string;
- carrier: string;
+ plant: string;
weight: number;
};
@@ -39,7 +45,7 @@ export type InventorySummary = {
export async function getRecentOrders(
custId: string
): Promise {
- const sql = `
+ const query = `
SELECT TOP 5
oh.OrderNum as order_num,
oh.PONum as po_num,
@@ -47,8 +53,7 @@ export async function getRecentOrders(
od.PartNum as vorteq_part,
oh.OrderDate as order_date,
oh.NeedByDate as need_by_date,
- od.SellingQuantity as qty,
- oh.OpenOrder as is_open
+ od.SellingQuantity as qty
FROM Erp.OrderHed oh
INNER JOIN Erp.OrderDtl od ON oh.Company = od.Company AND oh.OrderNum = od.OrderNum
INNER JOIN Erp.Customer c ON oh.Company = c.Company AND oh.CustNum = c.CustNum
@@ -57,92 +62,140 @@ export async function getRecentOrders(
ORDER BY oh.OrderDate DESC
`;
- const result = await execQuery(sql, {
+ const result = await execQuery[]>(query, {
custId,
});
- return result.map((row) => ({
- ...row,
+ // Extract only needed fields into plain objects (RSC-safe)
+ return result.map((r) => ({
+ order_num: String(r.order_num ?? ''),
+ po_num: String(r.po_num ?? ''),
+ customer_part: String(r.customer_part ?? ''),
+ vorteq_part: String(r.vorteq_part ?? ''),
+ order_date: r.order_date
+ ? new Date(r.order_date as string).toISOString()
+ : '',
+ need_by_date: r.need_by_date
+ ? new Date(r.need_by_date as string).toISOString()
+ : '',
+ qty: Number(r.qty ?? 0),
status: 'Open',
}));
}
/**
- * Get top 5 recent shipments for dashboard
+ * Get top 5 recent shipments for dashboard.
+ *
+ * Reuses getTop100Shipments from the shipments service which already handles
+ * the portal_GetShipmentsV1 SP and returns RSC-safe plain objects.
+ * We just take the first 5 (already sorted by date desc).
*/
export async function getRecentShipments(
custId: string
): Promise {
- const sql = `
- SELECT TOP 5
- sh.PackNum as bol_num,
- sh.PackNum as pack_num,
- sh.ShipDate as ship_date,
- CONCAT(st.Name, ', ', st.City, ', ', st.State) as ship_to,
- COALESCE(sh.CarrierName, 'N/A') as carrier,
- sh.Weight as weight
- FROM Erp.ShipHead sh
- INNER JOIN Erp.Customer c ON sh.Company = c.Company AND sh.CustNum = c.CustNum
- LEFT JOIN Erp.ShipTo st ON sh.Company = st.Company AND sh.CustNum = st.CustNum AND sh.ShipToNum = st.ShipToNum
- WHERE c.CustID = @custId
- AND sh.ShipDate IS NOT NULL
- ORDER BY sh.ShipDate DESC
- `;
+ const shipments = await getTop100Shipments(custId);
- const result = await execQuery(sql, {
- custId,
- });
-
- return result;
+ return shipments.slice(0, 5).map((s) => ({
+ bol_num: s.bol_num,
+ ship_date: s.ship_date,
+ ship_to: s.ship_to,
+ plant: s.plant,
+ weight: s.weight,
+ }));
}
/**
- * Get inventory summary counts for dashboard
+ * Get inventory summary counts for dashboard.
+ *
+ * Calls the real portal inventory stored procedures:
+ * - PortalWorkInProgressInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER)
+ * - PortalFinishedGoodsInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER)
+ * - PortalUnprocessedInventorySummary (@CUSTID, @DBNAME)
+ *
+ * Each returns rows per product with Rows (coil count) and OnHandQty (lbs).
+ * We aggregate them into totals for the dashboard cards.
*/
export async function getInventorySummary(
custId: string
): Promise {
- // This is a simplified query - actual implementation would call
- // the inventory stored procedures to get accurate counts
+ const dbName = getPortalDbName();
- const sql = `
- SELECT
- COUNT(CASE WHEN jh.JobClosed = 0 THEN 1 END) as wip_count,
- COUNT(CASE WHEN pd.OnHandQty > 0 AND jh.JobClosed = 1 THEN 1 END) as finished_goods_count,
- SUM(COALESCE(pd.OnHandQty, 0)) as total_weight
- FROM Erp.JobHead jh
- INNER JOIN Erp.PartDtl pd ON jh.Company = pd.Company AND jh.JobNum = pd.JobNum
- INNER JOIN Erp.Customer c ON jh.Company = c.Company AND jh.CustNum = c.CustNum
- WHERE c.CustID = @custId
- `;
-
- const result = await execQuery<
- Array<{
- wip_count: number;
- finished_goods_count: number;
- total_weight: number;
- }>
- >(sql, {
- custId,
- });
-
- if (result.length === 0 || !result[0]) {
- return {
- wip_count: 0,
- finished_goods_count: 0,
- unprocessed_count: 0,
- total_weight: 0,
- };
- }
-
- const firstResult = result[0];
-
- return {
- wip_count: firstResult.wip_count || 0,
- finished_goods_count: firstResult.finished_goods_count || 0,
- total_weight: firstResult.total_weight || 0,
- unprocessed_count: 0, // Would need separate query
+ const config = {
+ server: process.env.MSSQL_HOST || '',
+ database: process.env.MSSQL_DATABASE || '',
+ user: process.env.MSSQL_USER || '',
+ password: process.env.MSSQL_PASSWORD || '',
+ port: parseInt(process.env.MSSQL_PORT || '1433', 10),
+ options: {
+ encrypt: false,
+ trustServerCertificate: true,
+ connectTimeout: 30000,
+ requestTimeout: 120000,
+ },
};
+
+ const pool = await sql.connect(config);
+ try {
+ // Run all three inventory SPs in parallel
+ const [wipResult, fgResult, unprocessedResult] = await Promise.all([
+ pool
+ .request()
+ .input('Customer', custId)
+ .input('DBNAME', dbName)
+ .input('SUBUSER', 0)
+ .execute('PortalWorkInProgressInventorySummaryV6')
+ .catch(() => null),
+ pool
+ .request()
+ .input('Customer', custId)
+ .input('DBNAME', dbName)
+ .input('SUBUSER', 0)
+ .execute('PortalFinishedGoodsInventorySummaryV6')
+ .catch(() => null),
+ pool
+ .request()
+ .input('CUSTID', custId)
+ .input('DBNAME', dbName)
+ .execute('PortalUnprocessedInventorySummary')
+ .catch(() => null),
+ ]);
+
+ // Aggregate: sum Rows for counts, sum OnHandQty for total weight
+ let wipCount = 0;
+ let fgCount = 0;
+ let unprocessedCount = 0;
+ let totalWeight = 0;
+
+ if (wipResult) {
+ for (const r of wipResult.recordset) {
+ wipCount += Number(r.Rows ?? 0);
+ totalWeight += Number(r.OnHandQty ?? 0);
+ }
+ }
+
+ if (fgResult) {
+ for (const r of fgResult.recordset) {
+ fgCount += Number(r.Rows ?? 0);
+ totalWeight += Number(r.OnHandQty ?? 0);
+ }
+ }
+
+ if (unprocessedResult) {
+ for (const r of unprocessedResult.recordset) {
+ unprocessedCount += Number(r.Rows ?? 0);
+ totalWeight += Number(r.OnHandQty ?? 0);
+ }
+ }
+
+ return {
+ wip_count: wipCount,
+ finished_goods_count: fgCount,
+ unprocessed_count: unprocessedCount,
+ total_weight: totalWeight,
+ };
+ } finally {
+ // Don't close the pool — mssql reuses it globally
+ }
}
/**
diff --git a/src/services/orders.ts b/src/services/orders.ts
index 2b34807..e73175f 100644
--- a/src/services/orders.ts
+++ b/src/services/orders.ts
@@ -1,9 +1,20 @@
/**
* Orders Service
- * Handles order data retrieval from Epicor using the portal_Orders view
+ * Handles order data retrieval from Epicor using the portal_Orders view.
+ *
+ * IMPORTANT: All rows from execQuery must be JSON-sanitized before returning.
+ * The mssql driver attaches prototype metadata to recordset rows that causes
+ * Next.js RSC serialization to blow the stack. We extract only the fields
+ * we need into plain objects.
*/
import { execQuery } from '@/lib/epicor';
+import type {
+ OrderAcknowledgementData,
+ OrderAckHeader,
+ OrderAckLine,
+ OrderAckRelease,
+} from '@/types/orders';
export type OrderRow = {
order_num: number;
@@ -140,3 +151,283 @@ export async function getOrderDetails(orderNum: number): Promise {
return result;
}
+
+// =============================================================================
+// Order Acknowledgement (C-005)
+// =============================================================================
+
+/**
+ * Get full order acknowledgement data for a specific order.
+ *
+ * Queries Epicor OrderHed/OrderDtl/OrderRel/Customer/ShipTo/Terms/ShipVia
+ * and groups the flat SQL result into a nested OrderAcknowledgementData structure.
+ *
+ * Security: filters by CustID to ensure a customer can only view their own orders.
+ */
+export async function getOrderAcknowledgement(
+ orderNum: number,
+ custId: string
+): Promise {
+ const queryCustId = custId === 'HDC' ? 'HDM' : custId;
+
+ // Main query: header + lines + releases in one shot
+ const mainSql = `
+ SELECT
+ -- Header
+ oh.OrderNum,
+ oh.PONum,
+ oh.OrderDate,
+ oh.NeedByDate,
+ oh.OrderComment AS order_comment,
+ oh.FOB,
+ plt.Name AS fob_description,
+ oh.CurrencyCode,
+ t.Description AS payment_terms,
+ sv.Description AS ship_via,
+ -- Customer (Sold To)
+ c.Name AS customer_name,
+ c.CustID AS cust_id,
+ c.Address1 AS cust_address1,
+ c.Address2 AS cust_address2,
+ c.City AS cust_city,
+ c.State AS cust_state,
+ c.Zip AS cust_zip,
+ -- Ship To
+ st.Name AS ship_to_name,
+ st.Address1 AS ship_to_address1,
+ st.Address2 AS ship_to_address2,
+ st.City AS ship_to_city,
+ st.State AS ship_to_state,
+ st.ZIP AS ship_to_zip,
+ -- Line Detail
+ od.OrderLine,
+ od.PartNum,
+ od.XPartNum,
+ od.LineDesc,
+ od.OrderQty,
+ od.UnitPrice,
+ (od.OrderQty * od.UnitPrice) AS extended_price,
+ od.IUM,
+ od.RevisionNum,
+ od.OrderComment AS line_comment,
+ -- Release
+ orel.OrderRelNum,
+ orel.ReqDate AS rel_need_by_date,
+ orel.OurReqQty AS rel_quantity,
+ jp.JobNum AS rel_job_num
+ FROM Erp.OrderHed oh
+ INNER JOIN Erp.OrderDtl od ON oh.Company = od.Company AND oh.OrderNum = od.OrderNum
+ INNER JOIN Erp.Customer c ON oh.Company = c.Company AND oh.CustNum = c.CustNum
+ LEFT JOIN Erp.ShipTo st ON oh.Company = st.Company AND oh.CustNum = st.CustNum AND oh.ShipToNum = st.ShipToNum
+ LEFT JOIN Erp.Terms t ON oh.Company = t.Company AND oh.TermsCode = t.TermsCode
+ LEFT JOIN Erp.ShipVia sv ON oh.Company = sv.Company AND oh.ShipViaCode = sv.ShipViaCode
+ LEFT JOIN Erp.Plant plt ON oh.Company = plt.Company AND oh.FOB = plt.Plant
+ LEFT JOIN Erp.OrderRel orel ON od.Company = orel.Company AND od.OrderNum = orel.OrderNum AND od.OrderLine = orel.OrderLine
+ LEFT JOIN Erp.JobProd jp ON orel.Company = jp.Company AND orel.OrderNum = jp.OrderNum AND orel.OrderLine = jp.OrderLine AND orel.OrderRelNum = jp.OrderRelNum
+ WHERE oh.OrderNum = @OrderNum
+ AND c.CustID = @CustID
+ ORDER BY od.OrderLine, orel.OrderRelNum
+ `;
+
+ const rows = await execQuery[]>(mainSql, {
+ OrderNum: orderNum,
+ CustID: queryCustId,
+ });
+
+ if (rows.length === 0) {
+ return null;
+ }
+
+ // Fetch misc charges
+ const miscSql = `
+ SELECT
+ om.OrderLine,
+ om.MiscAmt
+ FROM Erp.OrderMisc om
+ WHERE om.OrderNum = @OrderNum
+ `;
+
+ const miscRows = await execQuery[]>(miscSql, {
+ OrderNum: orderNum,
+ }).catch(() => [] as Record[]);
+
+ // Aggregate misc charges by line (OrderLine = 0 means order-level)
+ const lineMiscMap = new Map();
+ let orderMiscCharges = 0;
+ for (const mr of miscRows) {
+ const line = Number(mr.OrderLine ?? 0);
+ const amt = Number(mr.MiscAmt ?? 0);
+ if (line === 0) {
+ orderMiscCharges += amt;
+ } else {
+ lineMiscMap.set(line, (lineMiscMap.get(line) ?? 0) + amt);
+ }
+ }
+
+ // Collect unique part numbers for paint code lookup
+ const partNums = new Set();
+ for (const r of rows) {
+ const partNum = String(r.PartNum ?? '');
+ if (partNum) partNums.add(partNum);
+ }
+
+ // Fetch paint codes
+ const paintMap = await getPaintCodes(Array.from(partNums));
+
+ // Build header from first row (we already returned null above if rows is empty)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const first = rows[0]!;
+ const header: OrderAckHeader = {
+ order_num: Number(first.OrderNum ?? 0),
+ order_type: 'Production Order',
+ po_num: String(first.PONum ?? ''),
+ order_date: first.OrderDate
+ ? new Date(first.OrderDate as string).toISOString()
+ : '',
+ need_by_date: first.NeedByDate
+ ? new Date(first.NeedByDate as string).toISOString()
+ : '',
+ payment_terms: String(first.payment_terms ?? ''),
+ sales_person: 'Vorteq Coil Finishers, LLC',
+ ship_via: String(first.ship_via ?? ''),
+ fob: first.fob_description
+ ? (String(first.fob_description).endsWith('Plant')
+ ? String(first.fob_description)
+ : `${String(first.fob_description)} Plant`)
+ : String(first.FOB ?? ''),
+ currency: String(first.CurrencyCode ?? 'USD'),
+ order_comment: String(first.order_comment ?? ''),
+ customer_name: String(first.customer_name ?? ''),
+ cust_id: String(first.cust_id ?? ''),
+ customer_address1: String(first.cust_address1 ?? ''),
+ customer_address2: String(first.cust_address2 ?? ''),
+ customer_city: String(first.cust_city ?? ''),
+ customer_state: String(first.cust_state ?? ''),
+ customer_zip: String(first.cust_zip ?? ''),
+ ship_to_name: String(first.ship_to_name ?? ''),
+ ship_to_address1: String(first.ship_to_address1 ?? ''),
+ ship_to_address2: String(first.ship_to_address2 ?? ''),
+ ship_to_city: String(first.ship_to_city ?? ''),
+ ship_to_state: String(first.ship_to_state ?? ''),
+ ship_to_zip: String(first.ship_to_zip ?? ''),
+ };
+
+ // Group rows into lines and releases
+ const linesMap = new Map();
+
+ for (const r of rows) {
+ const lineNum = Number(r.OrderLine ?? 0);
+ const partNum = String(r.PartNum ?? '');
+ const paint = paintMap.get(partNum);
+
+ if (!linesMap.has(lineNum)) {
+ // Build the customer part description from XPartNum + LineDesc
+ const xPartNum = String(r.XPartNum ?? '');
+ const lineDesc = String(r.LineDesc ?? '');
+ // "Our Part" line: customer_part / customer_part_desc
+ // In the legacy PDF: "Our Part: ACM3ARA01724BKRB / 017 X 24.00 BLACK / BROWN"
+ const customerPartDesc = lineDesc;
+
+ linesMap.set(lineNum, {
+ order_line: lineNum,
+ part_num: partNum,
+ part_description: lineDesc,
+ revision: String(r.RevisionNum ?? ''),
+ order_qty: Number(r.OrderQty ?? 0),
+ unit_of_measure: String(r.IUM ?? ''),
+ unit_price: Number(r.UnitPrice ?? 0),
+ extended_price: Number(r.extended_price ?? 0),
+ customer_part: xPartNum,
+ customer_part_desc: customerPartDesc,
+ top_finish: paint?.top_finish ?? '',
+ bottom_finish: paint?.bottom_finish ?? '',
+ comment: String(r.line_comment ?? ''),
+ releases: [],
+ line_misc_charges: lineMiscMap.get(lineNum) ?? 0,
+ });
+ }
+
+ // Add release if present
+ const relNum = r.OrderRelNum;
+ if (relNum != null && Number(relNum) > 0) {
+ const line = linesMap.get(lineNum)!;
+ // Avoid duplicate releases (multiple rows can produce dupes due to JOINs)
+ const alreadyAdded = line.releases.some(
+ (rel) => rel.release_num === Number(relNum)
+ );
+ if (!alreadyAdded) {
+ line.releases.push({
+ release_num: Number(relNum),
+ need_by_date: r.rel_need_by_date
+ ? new Date(r.rel_need_by_date as string).toISOString()
+ : '',
+ quantity: Number(r.rel_quantity ?? 0),
+ job_num: String(r.rel_job_num ?? ''),
+ });
+ }
+ }
+ }
+
+ const lines = Array.from(linesMap.values());
+
+ // Calculate totals
+ const orderTotalQty = lines.reduce((sum, l) => sum + l.order_qty, 0);
+ const orderTotalPrice = lines.reduce((sum, l) => sum + l.extended_price, 0);
+
+ return {
+ header,
+ lines,
+ order_misc_charges: orderMiscCharges,
+ order_total_qty: orderTotalQty,
+ order_total_price: orderTotalPrice,
+ };
+}
+
+/**
+ * Batch lookup paint codes for a list of part numbers.
+ * Queries Erp.Part + Erp.Part_UD joined via SysRowID/ForeignSysRowID.
+ */
+type PaintCodeInfo = {
+ top_finish: string;
+ bottom_finish: string;
+};
+
+async function getPaintCodes(
+ partNums: string[]
+): Promise