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:
Ryan Moon
2026-03-27 18:22:39 -05:00
parent 77a3a6baa9
commit 1132e0999b
10 changed files with 2289 additions and 2 deletions

View 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
},
}