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:
222
packages/backend/src/routes/v1/products.test.ts
Normal file
222
packages/backend/src/routes/v1/products.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
97
packages/backend/src/routes/v1/products.ts
Normal file
97
packages/backend/src/routes/v1/products.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user