- 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>
191 lines
6.7 KiB
TypeScript
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 };
|
|
}
|