fix: map Epicor SP column names and use portal_Orders view
Some checks failed
Build and Deploy / build (push) Successful in 4m42s
Build and Deploy / deploy (push) Failing after 18s

Inventory service now maps PascalCase SP columns (VorteqPartNum,
OnHandQty, etc.) to snake_case types. Fixed non-V6 SP parameter
from Customer to CUSTID. Orders service now uses the portal_Orders
view instead of raw table queries with nonexistent columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lorentz 2026-02-16 15:39:14 +00:00
parent 90ad1925e7
commit cf9af94f7d
4 changed files with 127 additions and 159 deletions

View file

@ -40,10 +40,7 @@ export function InventorySummaryTable({
'Description', 'Description',
'Plant', 'Plant',
'Warehouse', 'Warehouse',
'On Hand', 'On Hand Qty',
'Allocated',
'Available',
'UM',
]; ];
const rows = filteredData.map((row) => [ const rows = filteredData.map((row) => [
row.part_num, row.part_num,
@ -51,9 +48,6 @@ export function InventorySummaryTable({
row.plant, row.plant,
row.warehouse, row.warehouse,
row.on_hand_qty, row.on_hand_qty,
row.allocated_qty || 0,
row.available_qty || 0,
row.um,
]); ]);
const csvContent = [headers, ...rows] const csvContent = [headers, ...rows]
@ -105,10 +99,7 @@ export function InventorySummaryTable({
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead>Plant</TableHead> <TableHead>Plant</TableHead>
<TableHead>Warehouse</TableHead> <TableHead>Warehouse</TableHead>
<TableHead className="text-right">On Hand</TableHead> <TableHead className="text-right">On Hand Qty</TableHead>
<TableHead className="text-right">Allocated</TableHead>
<TableHead className="text-right">Available</TableHead>
<TableHead>UM</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -116,7 +107,7 @@ export function InventorySummaryTable({
<TableRow key={idx}> <TableRow key={idx}>
<TableCell> <TableCell>
<Link <Link
href={`/inventory/${category}/detail?part=${row.part_num}&plant=${row.plant}&warehouse=${row.warehouse}`} href={`/inventory/${category}/detail?part=${encodeURIComponent(row.part_num)}&plant=${encodeURIComponent(row.plant_key || row.plant)}&warehouse=${encodeURIComponent(row.warehouse)}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{row.part_num} {row.part_num}
@ -128,15 +119,8 @@ export function InventorySummaryTable({
<TableCell>{row.plant}</TableCell> <TableCell>{row.plant}</TableCell>
<TableCell>{row.warehouse}</TableCell> <TableCell>{row.warehouse}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{row.on_hand_qty.toLocaleString()} {Number(row.on_hand_qty).toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right">
{(row.allocated_qty || 0).toLocaleString()}
</TableCell>
<TableCell className="text-right">
{(row.available_qty || 0).toLocaleString()}
</TableCell>
<TableCell>{row.um}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import type { OrderRow } from '@/services/orders'; import type { OrderRow } from '@/services/orders';
import { import {
Card, Card,
@ -33,39 +32,37 @@ export function OrdersTable({ data }: Props) {
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
return ( return (
row.order_num?.toString().includes(searchLower) || row.order_num?.toString().includes(searchLower) ||
row.po_num?.toLowerCase().includes(searchLower) || row.customer_po?.toLowerCase().includes(searchLower) ||
row.customer_part?.toLowerCase().includes(searchLower) || row.vorteq_part?.toLowerCase().includes(searchLower) ||
row.vorteq_part?.toLowerCase().includes(searchLower) row.job_num?.toLowerCase().includes(searchLower)
); );
}); });
const handleExportCSV = () => { const handleExportCSV = () => {
const headers = [ const headers = [
'Order #', 'Order #',
'PO #', 'Customer PO',
'Order Date',
'Need By',
'Customer Part',
'Vorteq Part', 'Vorteq Part',
'Order Qty', 'Description',
'Shipped', 'Plant',
'Remaining', 'Warehouse',
'UM', 'Qty Completed',
'Status', 'Completion Date',
'Job #',
]; ];
const rows = filteredData.map((row) => [ const rows = filteredData.map((row) => [
row.order_num?.toString() || '', row.order_num?.toString() || '',
row.po_num || '', row.customer_po || '',
row.order_date ? new Date(row.order_date).toLocaleDateString() : '',
row.need_by_date ? new Date(row.need_by_date).toLocaleDateString() : '',
row.customer_part || '',
row.vorteq_part || '', row.vorteq_part || '',
row.order_qty?.toString() || '0', row.part_description || '',
row.shipped_qty?.toString() || '0', row.plant || '',
row.remaining_qty?.toString() || '0', row.warehouse || '',
row.um || '', row.qty_completed?.toString() || '0',
row.status || '', row.completion_date
? new Date(row.completion_date).toLocaleDateString()
: '',
row.job_num || '',
]); ]);
const csvContent = [headers, ...rows] const csvContent = [headers, ...rows]
@ -92,7 +89,7 @@ export function OrdersTable({ data }: Props) {
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Search order #, PO, customer part, or Vorteq part..." placeholder="Search order #, PO, part, or job..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8" className="pl-8"
@ -109,22 +106,21 @@ export function OrdersTable({ data }: Props) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Order #</TableHead> <TableHead>Order #</TableHead>
<TableHead>PO #</TableHead> <TableHead>Customer PO</TableHead>
<TableHead>Order Date</TableHead>
<TableHead>Need By</TableHead>
<TableHead>Customer Part</TableHead>
<TableHead>Vorteq Part</TableHead> <TableHead>Vorteq Part</TableHead>
<TableHead className="text-right">Qty</TableHead> <TableHead>Description</TableHead>
<TableHead className="text-right">Shipped</TableHead> <TableHead>Plant</TableHead>
<TableHead className="text-right">Remaining</TableHead> <TableHead>Warehouse</TableHead>
<TableHead>Status</TableHead> <TableHead className="text-right">Qty Completed</TableHead>
<TableHead>Completion Date</TableHead>
<TableHead>Job #</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={10} colSpan={9}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
No orders found No orders found
@ -133,49 +129,27 @@ export function OrdersTable({ data }: Props) {
) : ( ) : (
filteredData.map((row, i) => ( filteredData.map((row, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell> <TableCell className="font-medium">
<Link {row.order_num}
href={`/orders/${row.order_num}`}
className="font-medium hover:underline"
>
{row.order_num}
</Link>
</TableCell> </TableCell>
<TableCell>{row.po_num || '-'}</TableCell> <TableCell>{row.customer_po || '-'}</TableCell>
<TableCell>
{row.order_date
? new Date(row.order_date).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
{row.need_by_date
? new Date(row.need_by_date).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>{row.customer_part || '-'}</TableCell>
<TableCell className="font-mono"> <TableCell className="font-mono">
{row.vorteq_part || '-'} {row.vorteq_part || '-'}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="max-w-xs truncate">
{row.order_qty?.toFixed(0) || '0'} {row.part_description || '-'}
</TableCell> </TableCell>
<TableCell>{row.plant || '-'}</TableCell>
<TableCell>{row.warehouse || '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{row.shipped_qty?.toFixed(0) || '0'} {Number(row.qty_completed || 0).toLocaleString()}
</TableCell>
<TableCell className="text-right">
{row.remaining_qty?.toFixed(0) || '0'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span {row.completion_date
className={ ? new Date(row.completion_date).toLocaleDateString()
row.open_order : '-'}
? 'rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800'
: 'rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-800'
}
>
{row.status}
</span>
</TableCell> </TableCell>
<TableCell>{row.job_num || '-'}</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View file

@ -1,7 +1,8 @@
/** /**
* Inventory Service * Inventory Service
* *
* Handles inventory data retrieval from Epicor stored procedures * Handles inventory data retrieval from Epicor stored procedures.
* SP columns are PascalCase; we map them to snake_case for the UI.
*/ */
import { execStoredProc } from '@/lib/epicor'; import { execStoredProc } from '@/lib/epicor';
@ -10,11 +11,11 @@ export type InventorySummaryRow = {
part_num: string; part_num: string;
description: string; description: string;
plant: string; plant: string;
plant_key: string;
warehouse: string; warehouse: string;
on_hand_qty: number; on_hand_qty: number;
allocated_qty: number; paint_code?: string;
available_qty: number; cust_part_num?: string;
um: string; // Unit of measure
}; };
export type InventoryDetailRow = { export type InventoryDetailRow = {
@ -35,9 +36,25 @@ export type InventoryDetailRow = {
uom?: string; uom?: string;
receipt_date?: Date; receipt_date?: Date;
paint_code?: string; paint_code?: string;
[key: string]: unknown; // Allow additional fields from stored procedures [key: string]: unknown;
}; };
/**
* Map raw Epicor summary SP row to our normalized type
*/
function mapSummaryRow(raw: Record<string, unknown>): InventorySummaryRow {
return {
part_num: String(raw.VorteqPartNum ?? ''),
description: String(raw.VorteqPartDesc ?? ''),
plant: String(raw.Plant ?? ''),
plant_key: String(raw.PlantKey ?? ''),
warehouse: String(raw.Warehouse ?? ''),
on_hand_qty: Number(raw.OnHandQty ?? 0),
paint_code: raw.PaintCode ? String(raw.PaintCode) : undefined,
cust_part_num: raw.CustPartNum ? String(raw.CustPartNum) : undefined,
};
}
export type InventoryCategory = export type InventoryCategory =
| 'wip' | 'wip'
| 'finished-goods' | 'finished-goods'
@ -54,7 +71,7 @@ export async function getWorkInProgressSummary(
dbName: string, dbName: string,
sub: number sub: number
): Promise<InventorySummaryRow[]> { ): Promise<InventorySummaryRow[]> {
const result = await execStoredProc<InventorySummaryRow[]>( const result = await execStoredProc<Record<string, unknown>[]>(
'PortalWorkInProgressInventorySummaryV6', 'PortalWorkInProgressInventorySummaryV6',
{ {
Customer: custId, Customer: custId,
@ -63,7 +80,7 @@ export async function getWorkInProgressSummary(
} }
); );
return result; return result.map(mapSummaryRow);
} }
/** /**
@ -74,7 +91,7 @@ export async function getFinishedGoodsSummary(
dbName: string, dbName: string,
sub: number sub: number
): Promise<InventorySummaryRow[]> { ): Promise<InventorySummaryRow[]> {
const result = await execStoredProc<InventorySummaryRow[]>( const result = await execStoredProc<Record<string, unknown>[]>(
'PortalFinishedGoodsInventorySummaryV6', 'PortalFinishedGoodsInventorySummaryV6',
{ {
Customer: custId, Customer: custId,
@ -83,7 +100,7 @@ export async function getFinishedGoodsSummary(
} }
); );
return result; return result.map(mapSummaryRow);
} }
/** /**
@ -94,7 +111,7 @@ export async function getProcessedOtherSummary(
dbName: string, dbName: string,
sub: number sub: number
): Promise<InventorySummaryRow[]> { ): Promise<InventorySummaryRow[]> {
const result = await execStoredProc<InventorySummaryRow[]>( const result = await execStoredProc<Record<string, unknown>[]>(
'PortalProcessedOtherInventorySummaryV6', 'PortalProcessedOtherInventorySummaryV6',
{ {
Customer: custId, Customer: custId,
@ -103,12 +120,13 @@ export async function getProcessedOtherSummary(
} }
); );
return result; return result.map(mapSummaryRow);
} }
/** /**
* Get Unprocessed inventory summary * Get Unprocessed inventory summary
* Note: Sub-users are blocked from accessing this category * Note: Sub-users are blocked from accessing this category.
* Non-V6 SPs use @CUSTID parameter.
*/ */
export async function getUnprocessedSummary( export async function getUnprocessedSummary(
custId: string, custId: string,
@ -116,18 +134,18 @@ export async function getUnprocessedSummary(
isSubUser: boolean isSubUser: boolean
): Promise<InventorySummaryRow[]> { ): Promise<InventorySummaryRow[]> {
if (isSubUser) { if (isSubUser) {
return []; // Sub-users cannot access unprocessed inventory return [];
} }
const result = await execStoredProc<InventorySummaryRow[]>( const result = await execStoredProc<Record<string, unknown>[]>(
'PortalUnprocessedInventorySummary', 'PortalUnprocessedInventorySummary',
{ {
Customer: custId, CUSTID: custId,
DBNAME: dbName, DBNAME: dbName,
} }
); );
return result; return result.map(mapSummaryRow);
} }
/** /**
@ -139,18 +157,18 @@ export async function getUnprocessedRRSummary(
isSubUser: boolean isSubUser: boolean
): Promise<InventorySummaryRow[]> { ): Promise<InventorySummaryRow[]> {
if (isSubUser) { if (isSubUser) {
return []; // Sub-users cannot access R&R inventory return [];
} }
const result = await execStoredProc<InventorySummaryRow[]>( const result = await execStoredProc<Record<string, unknown>[]>(
'PortalUnprocessedInventoryRejectsAndReturnsSummary', 'PortalUnprocessedInventoryRejectsAndReturnsSummary',
{ {
Customer: custId, CUSTID: custId,
DBNAME: dbName, DBNAME: dbName,
} }
); );
return result; return result.map(mapSummaryRow);
} }
/** /**
@ -162,18 +180,18 @@ export async function getProcessedRRSummary(
isSubUser: boolean isSubUser: boolean
): Promise<InventorySummaryRow[]> { ): Promise<InventorySummaryRow[]> {
if (isSubUser) { if (isSubUser) {
return []; // Sub-users cannot access R&R inventory return [];
} }
const result = await execStoredProc<InventorySummaryRow[]>( const result = await execStoredProc<Record<string, unknown>[]>(
'PortalProcessedInventoryRejectsAndReturnsSummary', 'PortalProcessedInventoryRejectsAndReturnsSummary',
{ {
Customer: custId, CUSTID: custId,
DBNAME: dbName, DBNAME: dbName,
} }
); );
return result; return result.map(mapSummaryRow);
} }
/** /**
@ -236,10 +254,12 @@ export async function getInventoryDetails(
DBNAME: dbName, DBNAME: dbName,
}; };
// V6 procedures use Customer, non-V6 also use Customer // V6 procedures use @Customer, non-V6 use @CUSTID
params.Customer = custId;
if (procConfig.isV6) { if (procConfig.isV6) {
params.Customer = custId;
params.SUBUSER = sub; params.SUBUSER = sub;
} else {
params.CUSTID = custId;
} }
// Add filters if provided (only for filtered variant) // Add filters if provided (only for filtered variant)

View file

@ -1,70 +1,60 @@
/** /**
* Orders Service * Orders Service
* Handles order data retrieval from Epicor * Handles order data retrieval from Epicor using the portal_Orders view
*/ */
import { execQuery } from '@/lib/epicor'; import { execQuery } from '@/lib/epicor';
export type OrderRow = { export type OrderRow = {
order_num: number; order_num: number;
po_num: string; customer_po: string;
order_date: Date;
need_by_date: Date;
customer_part: string;
vorteq_part: string; vorteq_part: string;
order_qty: number; part_description: string;
shipped_qty: number; plant: string;
remaining_qty: number; warehouse: string;
um: string; qty_completed: number;
open_order: boolean; completion_date: Date;
status: string; job_num: string;
ship_to_name?: string;
[key: string]: unknown; [key: string]: unknown;
}; };
/** /**
* Get top 100 orders for a customer * Map raw portal_Orders view row to our normalized type
* Special handling for HDC customer */
function mapOrderRow(raw: Record<string, unknown>): OrderRow {
return {
order_num: Number(raw.VorteqSalesOrderNum ?? 0),
customer_po: String(raw.CustomerPO ?? ''),
vorteq_part: String(raw.VorteqPartNum ?? ''),
part_description: String(raw.PartDescription ?? ''),
plant: String(raw.Plant ?? ''),
warehouse: String(raw.Warehouse ?? ''),
qty_completed: Number(raw.QuantityCompleted ?? 0),
completion_date: raw.CompletionDate as Date,
job_num: String(raw.VorteqJobNum ?? ''),
};
}
/**
* Get top 100 orders for a customer using the portal_Orders view.
* HDC exception: uses portal_OrdersHDC view and maps to CustID 'HDM'.
*/ */
export async function getTop100Orders(custId: string): Promise<OrderRow[]> { export async function getTop100Orders(custId: string): Promise<OrderRow[]> {
// HDC exception: use different customer ID for second parameter const viewName = custId === 'HDC' ? 'portal_OrdersHDC' : 'portal_Orders';
const cust2 = custId === 'HDC' ? 'HDM' : custId; const queryCustId = custId === 'HDC' ? 'HDM' : custId;
// Query Epicor OrderHed and OrderDtl tables const query = `
// This is a simplified version - the actual portal_Orders.sql may have more complex logic SELECT TOP 100 *
const sql = ` FROM dbo.${viewName}
SELECT TOP 100 WHERE CustomerID = @CustID
oh.OrderNum AS order_num, ORDER BY CompletionDate DESC
oh.PONum AS po_num,
oh.OrderDate AS order_date,
oh.NeedByDate AS need_by_date,
od.XPartNum AS customer_part,
od.PartNum AS vorteq_part,
od.OrderQty AS order_qty,
od.ShippedQty AS shipped_qty,
(od.OrderQty - od.ShippedQty) AS remaining_qty,
od.IUM AS um,
oh.OpenOrder AS open_order,
CASE
WHEN oh.OpenOrder = 1 THEN 'Open'
ELSE 'Closed'
END AS status,
st.Name AS ship_to_name
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.ShipToNum = st.ShipToNum
WHERE c.CustID = @Cust1
OR c.CustID = @Cust2
ORDER BY oh.OrderDate DESC, oh.OrderNum DESC
`; `;
const result = await execQuery<OrderRow[]>(sql, { const result = await execQuery<Record<string, unknown>[]>(query, {
Cust1: custId, CustID: queryCustId,
Cust2: cust2,
}); });
return result; return result.map(mapOrderRow);
} }
/** /**