quest-vorteq/src/services/shipments.ts
Lorentz Hinrichsen 06221d2d9c feat: add Redis caching layer, fix connection pools, add loading skeletons
- Create src/lib/redis.ts (singleton client) and src/lib/cache.ts
  (cachedQuery with graceful degradation on Redis failure)
- Wrap all 12 API routes and 7 RSC pages with cachedQuery (TTLs:
  120s-900s by data type — dashboard 3min, inventory/orders/shipments
  5min, BOL/order-ack/traveler 10min, ship-to 15min, avail-coils 2min)
- Fix redundant connection pools in dashboard.ts and shipments.ts
  (were creating their own sql.connect instead of using shared pool)
- Add pool tuning to epicor.ts (max:15, min:2, 60s idle, 30s acquire)
- Parallelize session checks (getQuestSession + getActiveCompany +
  isSubUser) with Promise.all across all inventory and orders pages
- Add loading.tsx skeletons for dashboard, inventory detail, orders,
  and shipments pages for instant shell rendering via Next.js streaming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 07:40:07 -05:00

191 lines
6.7 KiB
TypeScript

/**
* Shipments Service
*
* Fetches shipment data from Epicor via the portal_GetShipmentsV1 stored procedure.
* SP columns are PascalCase; we map them to snake_case for the UI.
*/
import { execQuery, execStoredProc, getPortalDbName } from '@/lib/epicor';
import type { BOLData } from '@/types/shipments';
export type ShipmentRow = {
bol_num: string;
ship_date: string;
ship_to: string;
plant: string;
weight: number;
};
/**
* Get top 100 shipments for a customer.
*
* Uses the shared connection pool via execStoredProc, then immediately extracts
* only the fields we need into plain objects (RSC-safe).
*/
export async function getTop100Shipments(
custId: string
): Promise<ShipmentRow[]> {
const dbName = getPortalDbName();
const result = await execStoredProc<Record<string, unknown>[]>(
'portal_GetShipmentsV1',
{ CustID: custId, DBNAME: dbName }
);
// Immediately extract only the fields we need into plain objects.
// This prevents mssql row metadata from leaking into RSC serialization.
const rows: ShipmentRow[] = result.map((r) => ({
bol_num: String(r.BOLNum ?? ''),
ship_date: r.ShipDate ? new Date(r.ShipDate as string).toISOString() : '',
ship_to: String(r.ShipToLoc ?? '').replace(/, /g, '\n'),
plant: String(r.PlantName ?? ''),
weight: Number(r.Pounds ?? 0),
}));
// Sort by ship date descending and take top 100
rows.sort((a, b) => b.ship_date.localeCompare(a.ship_date));
return rows.slice(0, 100);
}
/**
* Get BOL (Bill of Lading) detail for a specific BOL number.
* Uses the legacy portal's getBOL.sql query.
*/
export async function getBOLDetail(
bolNum: number,
custId: string
): Promise<BOLData | null> {
const queryCustId = custId === 'HDC' ? 'HDM' : custId;
const mainSql = `
SELECT DISTINCT
BOLDetail.ClassRate,
OurInventoryShipQty AS CoilWeight_c,
[Customer].[CustID] AS [Customer_CustID],
[Customer].[Name] AS [Customer_Name],
[BOLHead].[BOLNum] AS [BOLHead_BOLNum],
[BOLHead].[ShipDate] AS [BOLHead_ShipDate],
[BOLHead].[Carrier] AS [BOLHead_Carrier],
[ShipTo].[Name] AS [ShipTo_Name],
[ShipTo].[Address1] AS [ShipTo_Address1],
[ShipTo].[Address2] AS [ShipTo_Address2],
[ShipTo].[City] AS [ShipTo_City],
[ShipTo].[State] AS [ShipTo_State],
[ShipTo].[ZIP] AS [ShipTo_ZIP],
[Plant].[Name] AS [Plant_Name],
[Plant].[Address1] AS [Plant_Address1],
[Plant].[City] AS [Plant_City],
[Plant].[State] AS [Plant_State],
[Plant].[Zip] AS [Plant_Zip],
[ShipDtl].[PartNum] AS [ShipDtl_PartNum],
[ShipDtl].[LineDesc] AS [ShipDtl_LineDesc],
[ShipDtl].[IUM] AS [ShipDtl_IUM],
[ShipDtl].[PackNum] AS [ShipDtl_PackNum],
CustXPrt.XPartNum AS CustPartNum,
PartLot.LotNum,
erp.OrderHed.OrderNum,
ShipVia.Description AS ShipViaCode,
UD01.ShortChar03 AS CustomerPoNum,
CASE WHEN SkidNum_c IS NULL OR SkidNum_c = '' THEN
BOLDetail.Weight
ELSE
[UD01].[Number02]
END AS Weight
FROM Erp.BOLHead AS BOLHead
INNER JOIN Erp.BOLDetail AS BOLDetail
ON BOLDetail.Company = BOLHead.Company
AND BOLDetail.BOLNum = BOLHead.BOLNum
AND (NOT BOLDetail.ClassRate = 'METAL SCRAP' AND NOT BOLDetail.ClassRate = 'ALUM SCRAP')
INNER JOIN Erp.Plant AS Plant
ON Plant.Company = BOLHead.Company
AND Plant.Plant = BOLHead.Plant
CROSS JOIN Erp.Customer AS Customer
INNER JOIN Erp.ShipTo AS ShipTo
ON BOLHead.CustNum = ShipTo.CustNum
AND BOLHead.ShipToNum = ShipTo.ShipToNum
AND ShipTo.Company = Customer.Company
AND ShipTo.CustNum = Customer.CustNum
INNER JOIN Erp.ShipDtl
ON BOLDetail.ClassRate = CAST(ShipDtl.PackNum AS VARCHAR(50))
INNER JOIN Erp.PartLot
ON ShipDtl.Company = PartLot.Company
AND ShipDtl.PartNum = PartLot.PartNum
AND ShipDtl.LotNum = PartLot.LotNum
INNER JOIN Erp.PartLot_UD
ON PartLot_UD.ForeignSysRowID = PartLot.SysRowID
INNER JOIN Ice.UD01
ON PartLot_UD.SkidNum_c = UD01.Key1
INNER JOIN erp.Part ON PartLot.PartNum = Erp.Part.PartNum
LEFT OUTER JOIN Erp.CustXPrt AS CustXPrt
ON Part.Company = CustXPrt.Company
AND Part.PartNum = CustXPrt.PartNum
LEFT OUTER JOIN erp.OrderHed ON erp.ShipDtl.OrderNum = erp.OrderHed.OrderNum
LEFT OUTER JOIN erp.JobProd ON erp.ShipDtl.JobNum = erp.JobProd.JobNum
LEFT JOIN Erp.ShipVia ON ShipVia.ShipViaCode = OrderHed.ShipViaCode
WHERE BOLHead.BOLNum = @BOLNum AND [Customer].[CustID] = @CustID
ORDER BY BOLDetail.ClassRate, ShipDtl.PartNum
`;
const rows = await execQuery<Record<string, unknown>[]>(mainSql, {
BOLNum: bolNum,
CustID: queryCustId,
});
if (rows.length === 0) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const first = rows[0]!;
// Calculate totals
let totalWeight = 0;
const lines: BOLData['lines'] = [];
for (const r of rows) {
const weight = Number(r.Weight ?? 0);
totalWeight += weight;
lines.push({
pack_line: Number(r.ShipDtl_PackNum ?? 0),
part_num: String(r.ShipDtl_PartNum ?? ''),
part_description: String(r.ShipDtl_LineDesc ?? ''),
order_num: Number(r.OrderNum ?? 0),
order_line: 0, // Not in this query
po_num: String(r.CustomerPoNum ?? ''),
ship_qty: Number(r.CoilWeight_c ?? 0),
lot_num: String(r.LotNum ?? ''),
net_weight: weight,
uom: String(r.ShipDtl_IUM ?? ''),
cust_part_num: String(r.CustPartNum ?? ''),
revision: '', // Not in this query
});
}
const header: BOLData['header'] = {
bol_num: Number(first.BOLHead_BOLNum ?? 0),
pack_num: Number(first.ShipDtl_PackNum ?? 0),
ship_date: first.BOLHead_ShipDate
? new Date(first.BOLHead_ShipDate as string).toISOString()
: '',
customer_name: String(first.Customer_Name ?? ''),
cust_id: String(first.Customer_CustID ?? ''),
ship_to_name: String(first.ShipTo_Name ?? ''),
ship_to_address1: String(first.ShipTo_Address1 ?? ''),
ship_to_address2: String(first.ShipTo_Address2 ?? ''),
ship_to_city: String(first.ShipTo_City ?? ''),
ship_to_state: String(first.ShipTo_State ?? ''),
ship_to_zip: String(first.ShipTo_ZIP ?? ''),
plant_name: String(first.Plant_Name ?? ''),
plant_address1: String(first.Plant_Address1 ?? ''),
plant_address2: '', // Not in this query
plant_city: String(first.Plant_City ?? ''),
plant_state: String(first.Plant_State ?? ''),
plant_zip: String(first.Plant_Zip ?? ''),
ship_via: String(first.ShipViaCode ?? ''),
total_weight: Math.round(totalWeight),
total_lines: lines.length,
};
return { header, lines };
}