Add products, inventory units, stock receipts, and price history
- product table (catalog definition, no cost column — cost tracked per receipt/unit) - inventory_unit table (serialized items with serial number, condition, status) - stock_receipt table (FIFO cost tracking — records every stock receive event with cost_per_unit, supplier, date) - price_history table (logs every retail price change for margin analysis over time) - product_supplier join table (many-to-many, tracks supplier SKU and preferred supplier) - Full CRUD routes + search (name, SKU, UPC, brand) - Inventory unit routes nested under products - Price changes auto-logged on product update - 33 tests passing
This commit is contained in:
157
packages/backend/src/services/product.service.ts
Normal file
157
packages/backend/src/services/product.service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { eq, and, or, ilike } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
|
||||
import type {
|
||||
ProductCreateInput,
|
||||
ProductUpdateInput,
|
||||
InventoryUnitCreateInput,
|
||||
InventoryUnitUpdateInput,
|
||||
} from '@forte/shared/schemas'
|
||||
|
||||
export const ProductService = {
|
||||
async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) {
|
||||
const [product] = await db
|
||||
.insert(products)
|
||||
.values({
|
||||
companyId,
|
||||
...input,
|
||||
price: input.price?.toString(),
|
||||
minPrice: input.minPrice?.toString(),
|
||||
rentalRateMonthly: input.rentalRateMonthly?.toString(),
|
||||
})
|
||||
.returning()
|
||||
return product
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
|
||||
const [product] = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.id, id), eq(products.companyId, companyId)))
|
||||
.limit(1)
|
||||
return product ?? null
|
||||
},
|
||||
|
||||
async list(db: PostgresJsDatabase, companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.companyId, companyId), eq(products.isActive, true)))
|
||||
.limit(100)
|
||||
},
|
||||
|
||||
async search(db: PostgresJsDatabase, companyId: string, query: string) {
|
||||
const pattern = `%${query}%`
|
||||
return db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(
|
||||
and(
|
||||
eq(products.companyId, companyId),
|
||||
eq(products.isActive, true),
|
||||
or(
|
||||
ilike(products.name, pattern),
|
||||
ilike(products.sku, pattern),
|
||||
ilike(products.upc, pattern),
|
||||
ilike(products.brand, pattern),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(50)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProductUpdateInput, changedBy?: string) {
|
||||
// Log price changes before updating
|
||||
if (input.price !== undefined || input.minPrice !== undefined) {
|
||||
const existing = await this.getById(db, companyId, id)
|
||||
if (existing) {
|
||||
await db.insert(priceHistory).values({
|
||||
productId: id,
|
||||
companyId,
|
||||
previousPrice: existing.price,
|
||||
newPrice: input.price?.toString() ?? existing.price ?? '0',
|
||||
previousMinPrice: existing.minPrice,
|
||||
newMinPrice: input.minPrice?.toString() ?? existing.minPrice,
|
||||
changedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { ...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(and(eq(products.id, id), eq(products.companyId, companyId)))
|
||||
.returning()
|
||||
return product ?? null
|
||||
},
|
||||
|
||||
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
|
||||
const [product] = await db
|
||||
.update(products)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(products.id, id), eq(products.companyId, companyId)))
|
||||
.returning()
|
||||
return product ?? null
|
||||
},
|
||||
}
|
||||
|
||||
export const InventoryUnitService = {
|
||||
async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) {
|
||||
const [unit] = await db
|
||||
.insert(inventoryUnits)
|
||||
.values({
|
||||
companyId,
|
||||
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, companyId: string, id: string) {
|
||||
const [unit] = await db
|
||||
.select()
|
||||
.from(inventoryUnits)
|
||||
.where(and(eq(inventoryUnits.id, id), eq(inventoryUnits.companyId, companyId)))
|
||||
.limit(1)
|
||||
return unit ?? null
|
||||
},
|
||||
|
||||
async listByProduct(db: PostgresJsDatabase, companyId: string, productId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(inventoryUnits)
|
||||
.where(
|
||||
and(eq(inventoryUnits.companyId, companyId), eq(inventoryUnits.productId, productId)),
|
||||
)
|
||||
},
|
||||
|
||||
async update(
|
||||
db: PostgresJsDatabase,
|
||||
companyId: string,
|
||||
id: string,
|
||||
input: InventoryUnitUpdateInput,
|
||||
) {
|
||||
const updates: Record<string, unknown> = { ...input }
|
||||
if (input.purchaseCost !== undefined) updates.purchaseCost = input.purchaseCost.toString()
|
||||
|
||||
const [unit] = await db
|
||||
.update(inventoryUnits)
|
||||
.set(updates)
|
||||
.where(and(eq(inventoryUnits.id, id), eq(inventoryUnits.companyId, companyId)))
|
||||
.returning()
|
||||
return unit ?? null
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user