/** * 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 { // 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 { 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 { 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 { // 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 { // 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 { // 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 { // 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 { 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 { 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); } // ============================================================================= // Lightweight Cart Check (no auto-create) // ============================================================================= /** * Check if an active cart exists for this user+company WITHOUT creating one. * Returns null if no active cart exists. */ export async function getActiveCart( userId: string, companyId: string ): Promise { 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' }, }); return existing ? serializeHeader(existing) : null; } // ============================================================================= // Batch Cart Item Operations // ============================================================================= export type BatchAddResult = { added: ShipRequestDetailItem[]; skipped: { item: AddShipCartItemPayload; reason: string }[]; errors: { item: AddShipCartItemPayload; error: string }[]; }; /** * Add multiple items to the cart in one operation. * Skips duplicates and plant mismatches rather than throwing. */ export async function addBatchCartItems( requestId: string, companyId: string, items: AddShipCartItemPayload[] ): Promise { // Verify cart is active const cart = await db.ship_request.findFirst({ where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false }, include: { details: { orderBy: { created_at: 'asc' } } }, }); if (!cart) { throw new Error('Cart not found or already submitted/cancelled'); } // Determine locked plant from existing items const lockedPlant = cart.details.length > 0 ? cart.details[0]!.plant : null; const added: ShipRequestDetailItem[] = []; const skipped: { item: AddShipCartItemPayload; reason: string }[] = []; const errors: { item: AddShipCartItemPayload; error: string }[] = []; for (const item of items) { try { // Plant constraint check if (lockedPlant && item.plant !== lockedPlant) { skipped.push({ item, reason: `Plant mismatch: cart is locked to ${lockedPlant}` }); continue; } // Duplicate check 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) { skipped.push({ item, reason: 'Already in cart' }); continue; } // If this is the first item, it sets the plant 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, }, }); added.push(serializeDetail(detail)); } catch (err) { errors.push({ item, error: err instanceof Error ? err.message : 'Unknown error' }); } } // Touch cart activity if (added.length > 0) { await db.ship_request.update({ where: { id: requestId }, data: { last_cart_activity: new Date() }, }); } return { added, skipped, errors }; }