quest-vorteq/src/services/ship-requests.ts

556 lines
15 KiB
TypeScript
Raw Normal View History

/**
* 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);
}
// =============================================================================
// 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<ShipRequestHeader | null> {
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<BatchAddResult> {
// 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 };
}