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,222 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import {
createTestApp,
cleanDb,
seedTestCompany,
registerAndLogin,
} from '../../test/helpers.js'
describe('Product routes', () => {
let app: FastifyInstance
let token: string
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const auth = await registerAndLogin(app)
token = auth.token
})
afterAll(async () => {
await app.close()
})
it('creates a non-serialized product', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/products',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Guitar Strings - D\'Addario EJ16',
sku: 'STR-DAD-EJ16',
upc: '019954121266',
brand: "D'Addario",
price: 6.99,
qtyOnHand: 24,
qtyReorderPoint: 10,
},
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.name).toBe('Guitar Strings - D\'Addario EJ16')
expect(body.isSerialized).toBe(false)
expect(body.qtyOnHand).toBe(24)
})
it('creates a serialized product', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/products',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Yamaha YTR-2330 Trumpet',
sku: 'BRASS-YAM-2330',
brand: 'Yamaha',
model: 'YTR-2330',
isSerialized: true,
price: 1299.99,
minPrice: 1100.00,
},
})
expect(response.statusCode).toBe(201)
expect(response.json().isSerialized).toBe(true)
})
it('searches by name, sku, and brand', async () => {
await app.inject({
method: 'POST',
url: '/v1/products',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Fender Stratocaster', brand: 'Fender', sku: 'GTR-FND-STRAT' },
})
await app.inject({
method: 'POST',
url: '/v1/products',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Gibson Les Paul', brand: 'Gibson', sku: 'GTR-GIB-LP' },
})
const byName = await app.inject({
method: 'GET',
url: '/v1/products/search?q=stratocaster',
headers: { authorization: `Bearer ${token}` },
})
expect(byName.json().length).toBe(1)
const bySku = await app.inject({
method: 'GET',
url: '/v1/products/search?q=GTR-GIB',
headers: { authorization: `Bearer ${token}` },
})
expect(bySku.json().length).toBe(1)
const byBrand = await app.inject({
method: 'GET',
url: '/v1/products/search?q=fender',
headers: { authorization: `Bearer ${token}` },
})
expect(byBrand.json().length).toBe(1)
})
it('logs price change on update', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/v1/products',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Test Item', price: 10.00 },
})
const id = createRes.json().id
const updateRes = await app.inject({
method: 'PATCH',
url: `/v1/products/${id}`,
headers: { authorization: `Bearer ${token}` },
payload: { price: 12.50 },
})
expect(updateRes.statusCode).toBe(200)
expect(updateRes.json().price).toBe('12.50')
})
})
describe('Inventory unit routes', () => {
let app: FastifyInstance
let token: string
let productId: string
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const auth = await registerAndLogin(app, { email: `unit-${Date.now()}@test.com` })
token = auth.token
const productRes = await app.inject({
method: 'POST',
url: '/v1/products',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Yamaha Trumpet', isSerialized: true, price: 1200 },
})
productId = productRes.json().id
})
afterAll(async () => {
await app.close()
})
it('creates an inventory unit with serial number', async () => {
const response = await app.inject({
method: 'POST',
url: `/v1/products/${productId}/units`,
headers: { authorization: `Bearer ${token}` },
payload: {
serialNumber: 'YAM-2024-001234',
condition: 'new',
purchaseDate: '2024-09-15',
purchaseCost: 650.00,
},
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.serialNumber).toBe('YAM-2024-001234')
expect(body.condition).toBe('new')
expect(body.status).toBe('available')
expect(body.purchaseCost).toBe('650.00')
})
it('lists units for a product', async () => {
await app.inject({
method: 'POST',
url: `/v1/products/${productId}/units`,
headers: { authorization: `Bearer ${token}` },
payload: { serialNumber: 'SN-001' },
})
await app.inject({
method: 'POST',
url: `/v1/products/${productId}/units`,
headers: { authorization: `Bearer ${token}` },
payload: { serialNumber: 'SN-002' },
})
const response = await app.inject({
method: 'GET',
url: `/v1/products/${productId}/units`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().length).toBe(2)
})
it('updates unit status and condition', async () => {
const createRes = await app.inject({
method: 'POST',
url: `/v1/products/${productId}/units`,
headers: { authorization: `Bearer ${token}` },
payload: { serialNumber: 'SN-UPDATE' },
})
const unitId = createRes.json().id
const updateRes = await app.inject({
method: 'PATCH',
url: `/v1/units/${unitId}`,
headers: { authorization: `Bearer ${token}` },
payload: { status: 'rented', condition: 'good' },
})
expect(updateRes.statusCode).toBe(200)
expect(updateRes.json().status).toBe('rented')
expect(updateRes.json().condition).toBe('good')
})
})

View File

@@ -0,0 +1,97 @@
import type { FastifyPluginAsync } from 'fastify'
import {
ProductCreateSchema,
ProductUpdateSchema,
ProductSearchSchema,
InventoryUnitCreateSchema,
InventoryUnitUpdateSchema,
} from '@forte/shared/schemas'
import { ProductService, InventoryUnitService } from '../../services/product.service.js'
export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Products ---
app.post('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
const parsed = ProductCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const product = await ProductService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(product)
})
app.get('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
const list = await ProductService.list(app.db, request.companyId)
return reply.send(list)
})
app.get('/products/search', { preHandler: [app.authenticate] }, async (request, reply) => {
const parsed = ProductSearchSchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Query parameter q is required', statusCode: 400 } })
}
const results = await ProductService.search(app.db, request.companyId, parsed.data.q)
return reply.send(results)
})
app.get('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const product = await ProductService.getById(app.db, request.companyId, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
app.patch('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ProductUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const product = await ProductService.update(app.db, request.companyId, id, parsed.data)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
app.delete('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const product = await ProductService.softDelete(app.db, request.companyId, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
// --- Inventory Units ---
app.post('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = InventoryUnitCreateSchema.safeParse({ ...(request.body as object), productId })
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(unit)
})
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const units = await InventoryUnitService.listByProduct(app.db, request.companyId, productId)
return reply.send(units)
})
app.get('/units/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const unit = await InventoryUnitService.getById(app.db, request.companyId, id)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
app.patch('/units/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = InventoryUnitUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
}