feat: add shipment request (C-013) and allocation request (C-014) cart workflows
Multi-step cart workflows for both shipment and allocation requests: - Ship-to address selection from Epicor portal_CustomerShipToAddresses - Inventory browser dialog (shipment) / coil browser dialog (allocation) - Cart persistence in DB with duplicate detection - Details form (order#, PO#, pickup date, instructions, email recipients) - Review & submit with confirmation dialog - Cancel with permission gate and confirmation - Shared components: stepper, ship-to selector, cart table, header form, review - 16 API routes, 4 services, 13 components, 6 pages - Phase 2 complete: 16/16 tasks done Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b32553668d
commit
e60e07c9a5
41 changed files with 5106 additions and 20 deletions
439
src/services/ship-requests.ts
Normal file
439
src/services/ship-requests.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
/**
|
||||
* Ship Request Service
|
||||
*
|
||||
* Handles CRUD operations for shipment request carts persisted in PostgreSQL.
|
||||
* Carts are per-user, per-company. Only one active (non-submitted, non-cancelled) cart
|
||||
* is allowed at a time.
|
||||
*/
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import type {
|
||||
ShipRequestHeader,
|
||||
ShipRequestListItem,
|
||||
ShipRequestDetailItem,
|
||||
AddShipCartItemPayload,
|
||||
UpdateCartHeaderPayload,
|
||||
UpdateCartItemPayload,
|
||||
} from '@/types/requests';
|
||||
|
||||
// =============================================================================
|
||||
// Serialization helpers
|
||||
// =============================================================================
|
||||
|
||||
function serializeDetail(
|
||||
d: {
|
||||
id: string;
|
||||
part_num: string;
|
||||
lot_num: string | null;
|
||||
plant: string;
|
||||
warehouse: string | null;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
): ShipRequestDetailItem {
|
||||
return {
|
||||
id: d.id,
|
||||
part_num: d.part_num,
|
||||
lot_num: d.lot_num,
|
||||
plant: d.plant,
|
||||
warehouse: d.warehouse,
|
||||
quantity: d.quantity,
|
||||
notes: d.notes,
|
||||
created_at: d.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeHeader(
|
||||
r: {
|
||||
id: string;
|
||||
auth_user_id: string;
|
||||
quest_company_id: string;
|
||||
ship_to_address: string;
|
||||
order_number: string | null;
|
||||
po_number: string | null;
|
||||
release_number: string | null;
|
||||
pickup_date: Date | null;
|
||||
instructions: string | null;
|
||||
email_recipients: string[];
|
||||
is_submitted: boolean;
|
||||
submitted_at: Date | null;
|
||||
is_cancelled: boolean;
|
||||
cancelled_at: Date | null;
|
||||
cancelled_by: string | null;
|
||||
last_cart_activity: Date;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
details: {
|
||||
id: string;
|
||||
part_num: string;
|
||||
lot_num: string | null;
|
||||
plant: string;
|
||||
warehouse: string | null;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
created_at: Date;
|
||||
}[];
|
||||
}
|
||||
): ShipRequestHeader {
|
||||
return {
|
||||
id: r.id,
|
||||
auth_user_id: r.auth_user_id,
|
||||
quest_company_id: r.quest_company_id,
|
||||
ship_to_address: r.ship_to_address,
|
||||
order_number: r.order_number,
|
||||
po_number: r.po_number,
|
||||
release_number: r.release_number,
|
||||
pickup_date: r.pickup_date?.toISOString() ?? null,
|
||||
instructions: r.instructions,
|
||||
email_recipients: r.email_recipients,
|
||||
is_submitted: r.is_submitted,
|
||||
submitted_at: r.submitted_at?.toISOString() ?? null,
|
||||
is_cancelled: r.is_cancelled,
|
||||
cancelled_at: r.cancelled_at?.toISOString() ?? null,
|
||||
cancelled_by: r.cancelled_by,
|
||||
last_cart_activity: r.last_cart_activity.toISOString(),
|
||||
created_at: r.created_at.toISOString(),
|
||||
updated_at: r.updated_at.toISOString(),
|
||||
details: r.details.map(serializeDetail),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cart Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get or create an active cart (unsubmitted, uncancelled) for this user+company
|
||||
*/
|
||||
export async function getOrCreateCart(
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<ShipRequestHeader> {
|
||||
// Find existing active cart
|
||||
const existing = await db.ship_request.findFirst({
|
||||
where: {
|
||||
auth_user_id: userId,
|
||||
quest_company_id: companyId,
|
||||
is_submitted: false,
|
||||
is_cancelled: false,
|
||||
},
|
||||
include: { details: { orderBy: { created_at: 'asc' } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return serializeHeader(existing);
|
||||
}
|
||||
|
||||
// Create new cart
|
||||
const newCart = await db.ship_request.create({
|
||||
data: {
|
||||
auth_user_id: userId,
|
||||
quest_company_id: companyId,
|
||||
ship_to_address: '',
|
||||
},
|
||||
include: { details: true },
|
||||
});
|
||||
|
||||
return serializeHeader(newCart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific request by ID, scoped to a company
|
||||
*/
|
||||
export async function getRequestById(
|
||||
id: string,
|
||||
companyId: string
|
||||
): Promise<ShipRequestHeader | null> {
|
||||
const request = await db.ship_request.findFirst({
|
||||
where: { id, quest_company_id: companyId },
|
||||
include: { details: { orderBy: { created_at: 'asc' } } },
|
||||
});
|
||||
|
||||
return request ? serializeHeader(request) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all requests for a company (list view)
|
||||
*/
|
||||
export async function getRequests(
|
||||
companyId: string
|
||||
): Promise<ShipRequestListItem[]> {
|
||||
const requests = await db.ship_request.findMany({
|
||||
where: { quest_company_id: companyId },
|
||||
include: { _count: { select: { details: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
|
||||
return requests.map((r) => ({
|
||||
id: r.id,
|
||||
ship_to_address: r.ship_to_address,
|
||||
order_number: r.order_number,
|
||||
po_number: r.po_number,
|
||||
is_submitted: r.is_submitted,
|
||||
submitted_at: r.submitted_at?.toISOString() ?? null,
|
||||
is_cancelled: r.is_cancelled,
|
||||
cancelled_at: r.cancelled_at?.toISOString() ?? null,
|
||||
created_at: r.created_at.toISOString(),
|
||||
detail_count: r._count.details,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cart header fields (ship-to, order#, PO#, etc.)
|
||||
*/
|
||||
export async function updateCartHeader(
|
||||
id: string,
|
||||
companyId: string,
|
||||
data: UpdateCartHeaderPayload
|
||||
): Promise<ShipRequestHeader> {
|
||||
// Verify cart belongs to company and is still active
|
||||
const cart = await db.ship_request.findFirst({
|
||||
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
||||
});
|
||||
|
||||
if (!cart) {
|
||||
throw new Error('Cart not found or already submitted/cancelled');
|
||||
}
|
||||
|
||||
const updated = await db.ship_request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.ship_to_address !== undefined && { ship_to_address: data.ship_to_address }),
|
||||
...(data.order_number !== undefined && { order_number: data.order_number }),
|
||||
...(data.po_number !== undefined && { po_number: data.po_number }),
|
||||
...(data.release_number !== undefined && { release_number: data.release_number }),
|
||||
...(data.pickup_date !== undefined && {
|
||||
pickup_date: data.pickup_date ? new Date(data.pickup_date) : null,
|
||||
}),
|
||||
...(data.instructions !== undefined && { instructions: data.instructions }),
|
||||
...(data.email_recipients !== undefined && { email_recipients: data.email_recipients }),
|
||||
last_cart_activity: new Date(),
|
||||
},
|
||||
include: { details: { orderBy: { created_at: 'asc' } } },
|
||||
});
|
||||
|
||||
return serializeHeader(updated);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cart Item Operations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Add an item to the cart. Checks for duplicates (same part+lot+plant+warehouse).
|
||||
*/
|
||||
export async function addCartItem(
|
||||
requestId: string,
|
||||
companyId: string,
|
||||
item: AddShipCartItemPayload
|
||||
): Promise<ShipRequestDetailItem> {
|
||||
// Verify cart is active
|
||||
const cart = await db.ship_request.findFirst({
|
||||
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
||||
});
|
||||
|
||||
if (!cart) {
|
||||
throw new Error('Cart not found or already submitted/cancelled');
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const duplicate = await db.ship_request_detail.findFirst({
|
||||
where: {
|
||||
ship_request_id: requestId,
|
||||
part_num: item.part_num,
|
||||
lot_num: item.lot_num || null,
|
||||
plant: item.plant,
|
||||
warehouse: item.warehouse || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
throw new Error('This item already exists in the cart. Update the quantity instead.');
|
||||
}
|
||||
|
||||
const detail = await db.ship_request_detail.create({
|
||||
data: {
|
||||
ship_request_id: requestId,
|
||||
part_num: item.part_num,
|
||||
lot_num: item.lot_num || null,
|
||||
plant: item.plant,
|
||||
warehouse: item.warehouse || null,
|
||||
quantity: item.quantity,
|
||||
notes: item.notes || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Touch cart activity
|
||||
await db.ship_request.update({
|
||||
where: { id: requestId },
|
||||
data: { last_cart_activity: new Date() },
|
||||
});
|
||||
|
||||
return serializeDetail(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the cart
|
||||
*/
|
||||
export async function removeCartItem(
|
||||
detailId: string,
|
||||
requestId: string,
|
||||
companyId: string
|
||||
): Promise<void> {
|
||||
// Verify cart is active and detail belongs to it
|
||||
const cart = await db.ship_request.findFirst({
|
||||
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
||||
});
|
||||
|
||||
if (!cart) {
|
||||
throw new Error('Cart not found or already submitted/cancelled');
|
||||
}
|
||||
|
||||
const detail = await db.ship_request_detail.findFirst({
|
||||
where: { id: detailId, ship_request_id: requestId },
|
||||
});
|
||||
|
||||
if (!detail) {
|
||||
throw new Error('Item not found in cart');
|
||||
}
|
||||
|
||||
await db.ship_request_detail.delete({ where: { id: detailId } });
|
||||
|
||||
// Touch cart activity
|
||||
await db.ship_request.update({
|
||||
where: { id: requestId },
|
||||
data: { last_cart_activity: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a cart item (quantity, notes)
|
||||
*/
|
||||
export async function updateCartItem(
|
||||
detailId: string,
|
||||
requestId: string,
|
||||
companyId: string,
|
||||
data: UpdateCartItemPayload
|
||||
): Promise<ShipRequestDetailItem> {
|
||||
// Verify cart is active
|
||||
const cart = await db.ship_request.findFirst({
|
||||
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
||||
});
|
||||
|
||||
if (!cart) {
|
||||
throw new Error('Cart not found or already submitted/cancelled');
|
||||
}
|
||||
|
||||
const detail = await db.ship_request_detail.findFirst({
|
||||
where: { id: detailId, ship_request_id: requestId },
|
||||
});
|
||||
|
||||
if (!detail) {
|
||||
throw new Error('Item not found in cart');
|
||||
}
|
||||
|
||||
const updated = await db.ship_request_detail.update({
|
||||
where: { id: detailId },
|
||||
data: {
|
||||
...(data.quantity !== undefined && { quantity: data.quantity }),
|
||||
...(data.notes !== undefined && { notes: data.notes }),
|
||||
},
|
||||
});
|
||||
|
||||
// Touch cart activity
|
||||
await db.ship_request.update({
|
||||
where: { id: requestId },
|
||||
data: { last_cart_activity: new Date() },
|
||||
});
|
||||
|
||||
return serializeDetail(updated);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Submit & Cancel
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Submit a cart (mark as submitted, lock for editing)
|
||||
*/
|
||||
export async function submitCart(
|
||||
id: string,
|
||||
companyId: string,
|
||||
userId: string
|
||||
): Promise<ShipRequestHeader> {
|
||||
const cart = await db.ship_request.findFirst({
|
||||
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
||||
include: { details: true },
|
||||
});
|
||||
|
||||
if (!cart) {
|
||||
throw new Error('Cart not found or already submitted/cancelled');
|
||||
}
|
||||
|
||||
if (cart.details.length === 0) {
|
||||
throw new Error('Cannot submit an empty cart');
|
||||
}
|
||||
|
||||
if (!cart.ship_to_address) {
|
||||
throw new Error('Ship-to address is required');
|
||||
}
|
||||
|
||||
const submitted = await db.ship_request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
is_submitted: true,
|
||||
submitted_at: new Date(),
|
||||
},
|
||||
include: { details: { orderBy: { created_at: 'asc' } } },
|
||||
});
|
||||
|
||||
// Email notification stub (J-007 not built yet)
|
||||
console.log('[EMAIL STUB] Shipment request submitted:', {
|
||||
requestId: id,
|
||||
userId,
|
||||
companyId,
|
||||
recipients: submitted.email_recipients,
|
||||
itemCount: submitted.details.length,
|
||||
});
|
||||
|
||||
return serializeHeader(submitted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a submitted request
|
||||
*/
|
||||
export async function cancelRequest(
|
||||
id: string,
|
||||
companyId: string,
|
||||
userId: string
|
||||
): Promise<ShipRequestHeader> {
|
||||
const request = await db.ship_request.findFirst({
|
||||
where: { id, quest_company_id: companyId, is_cancelled: false },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error('Request not found');
|
||||
}
|
||||
|
||||
const cancelled = await db.ship_request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
is_cancelled: true,
|
||||
cancelled_at: new Date(),
|
||||
cancelled_by: userId,
|
||||
},
|
||||
include: { details: { orderBy: { created_at: 'asc' } } },
|
||||
});
|
||||
|
||||
// Email notification stub
|
||||
console.log('[EMAIL STUB] Shipment request cancelled:', {
|
||||
requestId: id,
|
||||
userId,
|
||||
companyId,
|
||||
recipients: cancelled.email_recipients,
|
||||
});
|
||||
|
||||
return serializeHeader(cancelled);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue