import { eq, and, count, desc, sql, type Column } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { products, inventoryUnits, priceHistory as priceHistoryTable, productSuppliers, suppliers, stockReceipts } from '../db/schema/inventory.js' import { ValidationError } from '../lib/errors.js' import type { ProductCreateInput, ProductUpdateInput, InventoryUnitCreateInput, InventoryUnitUpdateInput, ProductSupplierCreateInput, ProductSupplierUpdateInput, StockReceiptCreateInput, PaginationInput, } from '@lunarfront/shared/schemas' import { withPagination, withSort, buildSearchCondition, paginatedResponse, } from '../utils/pagination.js' import { UnitStatusService, ItemConditionService } from './lookup.service.js' export const ProductService = { async create(db: PostgresJsDatabase, input: ProductCreateInput) { const [product] = await db .insert(products) .values({ ...input, price: input.price?.toString(), minPrice: input.minPrice?.toString(), rentalRateMonthly: input.rentalRateMonthly?.toString(), }) .returning() return product }, async getById(db: PostgresJsDatabase, id: string) { const [product] = await db .select() .from(products) .where(eq(products.id, id)) .limit(1) return product ?? null }, async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { categoryId?: string isActive?: boolean isSerialized?: boolean isRental?: boolean isDualUseRepair?: boolean isConsumable?: boolean lowStock?: boolean }) { const conditions = [eq(products.isActive, filters?.isActive ?? true)] if (params.q) { conditions.push(buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])!) } if (filters?.categoryId) { conditions.push(eq(products.categoryId, filters.categoryId)) } if (filters?.isSerialized !== undefined) { conditions.push(eq(products.isSerialized, filters.isSerialized)) } if (filters?.isRental !== undefined) { conditions.push(eq(products.isRental, filters.isRental)) } if (filters?.isDualUseRepair !== undefined) { conditions.push(eq(products.isDualUseRepair, filters.isDualUseRepair)) } if (filters?.isConsumable !== undefined) { conditions.push(eq(products.isConsumable, filters.isConsumable)) } if (filters?.lowStock) { // qty_on_hand <= qty_reorder_point (and reorder point is set) OR qty_on_hand = 0 conditions.push( sql`(${products.qtyOnHand} = 0 OR (${products.qtyReorderPoint} IS NOT NULL AND ${products.qtyOnHand} <= ${products.qtyReorderPoint}))` ) } const where = conditions.length === 1 ? conditions[0] : and(...conditions) const sortableColumns: Record = { name: products.name, sku: products.sku, brand: products.brand, price: products.price, created_at: products.createdAt, } let query = db.select().from(products).where(where).$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, products.name) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(products).where(where), ]) return paginatedResponse(data, total, params.page, params.limit) }, async update( db: PostgresJsDatabase, id: string, input: ProductUpdateInput, changedBy?: string, ) { if (input.price !== undefined || input.minPrice !== undefined) { const existing = await this.getById(db, id) if (existing) { await db.insert(priceHistoryTable).values({ productId: id, previousPrice: existing.price, newPrice: input.price?.toString() ?? existing.price ?? '0', previousMinPrice: existing.minPrice, newMinPrice: input.minPrice?.toString() ?? existing.minPrice, changedBy, }) } } const updates: Record = { ...input, updatedAt: new Date() } if (input.price !== undefined) updates.price = input.price.toString() if (input.minPrice !== undefined) updates.minPrice = input.minPrice.toString() if (input.rentalRateMonthly !== undefined) updates.rentalRateMonthly = input.rentalRateMonthly.toString() const [product] = await db .update(products) .set(updates) .where(eq(products.id, id)) .returning() return product ?? null }, async softDelete(db: PostgresJsDatabase, id: string) { const [product] = await db .update(products) .set({ isActive: false, updatedAt: new Date() }) .where(eq(products.id, id)) .returning() return product ?? null }, async getByUpc(db: PostgresJsDatabase, upc: string) { const [product] = await db .select() .from(products) .where(and(eq(products.upc, upc), eq(products.isActive, true))) .limit(1) return product ?? null }, async listPriceHistory(db: PostgresJsDatabase, productId: string) { return db .select() .from(priceHistoryTable) .where(eq(priceHistoryTable.productId, productId)) .orderBy(desc(priceHistoryTable.createdAt)) }, } export const InventoryUnitService = { async create(db: PostgresJsDatabase, input: InventoryUnitCreateInput) { if (input.condition) { const valid = await ItemConditionService.validateSlug(db, input.condition) if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) } if (input.status) { const valid = await UnitStatusService.validateSlug(db, input.status) if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`) } const [unit] = await db .insert(inventoryUnits) .values({ productId: input.productId, locationId: input.locationId, serialNumber: input.serialNumber, condition: input.condition, status: input.status, purchaseDate: input.purchaseDate, purchaseCost: input.purchaseCost?.toString(), notes: input.notes, }) .returning() return unit }, async getById(db: PostgresJsDatabase, id: string) { const [unit] = await db .select() .from(inventoryUnits) .where(eq(inventoryUnits.id, id)) .limit(1) return unit ?? null }, async listByProduct( db: PostgresJsDatabase, productId: string, params: PaginationInput, ) { const where = eq(inventoryUnits.productId, productId) const sortableColumns: Record = { serial_number: inventoryUnits.serialNumber, status: inventoryUnits.status, condition: inventoryUnits.condition, created_at: inventoryUnits.createdAt, } let query = db.select().from(inventoryUnits).where(where).$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, inventoryUnits.createdAt) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(inventoryUnits).where(where), ]) return paginatedResponse(data, total, params.page, params.limit) }, async update( db: PostgresJsDatabase, id: string, input: InventoryUnitUpdateInput, ) { if (input.condition) { const valid = await ItemConditionService.validateSlug(db, input.condition) if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) } if (input.status) { const valid = await UnitStatusService.validateSlug(db, input.status) if (!valid) throw new ValidationError(`Invalid status: "${input.status}"`) } const updates: Record = { ...input, updatedAt: new Date() } if (input.purchaseCost !== undefined) updates.purchaseCost = input.purchaseCost.toString() const [unit] = await db .update(inventoryUnits) .set(updates) .where(eq(inventoryUnits.id, id)) .returning() return unit ?? null }, } export const ProductSupplierService = { async listByProduct(db: PostgresJsDatabase, productId: string) { return db .select({ id: productSuppliers.id, productId: productSuppliers.productId, supplierId: productSuppliers.supplierId, supplierSku: productSuppliers.supplierSku, isPreferred: productSuppliers.isPreferred, createdAt: productSuppliers.createdAt, supplierName: suppliers.name, supplierContactName: suppliers.contactName, supplierEmail: suppliers.email, supplierPhone: suppliers.phone, supplierAccountNumber: suppliers.accountNumber, supplierPaymentTerms: suppliers.paymentTerms, }) .from(productSuppliers) .innerJoin(suppliers, eq(productSuppliers.supplierId, suppliers.id)) .where(eq(productSuppliers.productId, productId)) .orderBy(desc(productSuppliers.isPreferred), suppliers.name) }, async create(db: PostgresJsDatabase, productId: string, input: ProductSupplierCreateInput) { if (input.isPreferred) { await db .update(productSuppliers) .set({ isPreferred: false }) .where(eq(productSuppliers.productId, productId)) } const [row] = await db .insert(productSuppliers) .values({ productId, ...input }) .returning() return row }, async update(db: PostgresJsDatabase, id: string, productId: string, input: ProductSupplierUpdateInput) { if (input.isPreferred) { await db .update(productSuppliers) .set({ isPreferred: false }) .where(eq(productSuppliers.productId, productId)) } const [row] = await db .update(productSuppliers) .set(input) .where(eq(productSuppliers.id, id)) .returning() return row ?? null }, async delete(db: PostgresJsDatabase, id: string) { const [row] = await db .delete(productSuppliers) .where(eq(productSuppliers.id, id)) .returning() return row ?? null }, } export const StockReceiptService = { async create(db: PostgresJsDatabase, productId: string, input: StockReceiptCreateInput) { const totalCost = (input.costPerUnit * input.qty).toFixed(2) const [receipt] = await db .insert(stockReceipts) .values({ productId, supplierId: input.supplierId, inventoryUnitId: input.inventoryUnitId, qty: input.qty, costPerUnit: input.costPerUnit.toString(), totalCost, receivedDate: input.receivedDate, invoiceNumber: input.invoiceNumber, notes: input.notes, }) .returning() // For non-serialized products, increment qty_on_hand atomically await db .update(products) .set({ qtyOnHand: sql`${products.qtyOnHand} + ${input.qty}`, updatedAt: new Date() }) .where(and(eq(products.id, productId), eq(products.isSerialized, false))) return receipt }, async listByProduct(db: PostgresJsDatabase, productId: string) { return db .select({ id: stockReceipts.id, productId: stockReceipts.productId, supplierId: stockReceipts.supplierId, inventoryUnitId: stockReceipts.inventoryUnitId, qty: stockReceipts.qty, costPerUnit: stockReceipts.costPerUnit, totalCost: stockReceipts.totalCost, receivedDate: stockReceipts.receivedDate, invoiceNumber: stockReceipts.invoiceNumber, notes: stockReceipts.notes, createdAt: stockReceipts.createdAt, supplierName: suppliers.name, }) .from(stockReceipts) .leftJoin(suppliers, eq(stockReceipts.supplierId, suppliers.id)) .where(eq(stockReceipts.productId, productId)) .orderBy(desc(stockReceipts.receivedDate), desc(stockReceipts.createdAt)) }, }