Build inventory frontend and stock management features

- Full inventory UI: product list with search/filter, product detail with
  tabs (details, units, suppliers, stock receipts, price history)
- Product filters: category, type (serialized/rental/repair), low stock,
  active/inactive — all server-side with URL-synced state
- Product-supplier junction: link products to multiple suppliers with
  preferred flag, joined supplier details in UI
- Stock receipts: record incoming stock with supplier, qty, cost per unit,
  invoice number; auto-increments qty_on_hand for non-serialized products
- Price history tab on product detail page
- categories/all endpoint to avoid pagination limit on dropdown fetches
- categoryId filter on product list endpoint
- Repair parts and additional inventory items in music store seed data
- isDualUseRepair corrected: instruments set to false, strings/parts true
- Product-supplier links and stock receipts in seed data
- Price history seed data simulating cost increases over past year
- 37 API tests covering categories, suppliers, products, units,
  product-suppliers, and stock receipts
- alert-dialog and checkbox UI components
- sync-and-deploy.sh script for rsync + remote deploy
This commit is contained in:
Ryan Moon
2026-03-30 20:12:07 -05:00
parent ec09e319ed
commit 5f5ba9e4a2
24 changed files with 4023 additions and 187 deletions

View File

@@ -0,0 +1,364 @@
import { suite } from '../lib/context.js'
suite('Inventory', { tags: ['inventory'] }, (t) => {
// ─── Categories ────────────────────────────────────────────────────────────
t.test('creates a category', { tags: ['categories', 'create'] }, async () => {
const res = await t.api.post('/v1/categories', { name: 'Violins', sortOrder: 1 })
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Violins')
t.assert.equal(res.data.sortOrder, 1)
t.assert.equal(res.data.isActive, true)
t.assert.ok(res.data.id)
})
t.test('rejects category without name', { tags: ['categories', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/categories', {})
t.assert.status(res, 400)
})
t.test('lists categories with pagination', { tags: ['categories', 'list'] }, async () => {
await t.api.post('/v1/categories', { name: 'Cat List A' })
await t.api.post('/v1/categories', { name: 'Cat List B' })
const res = await t.api.get('/v1/categories', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('gets category by id', { tags: ['categories', 'read'] }, async () => {
const created = await t.api.post('/v1/categories', { name: 'Get By ID Cat' })
const res = await t.api.get(`/v1/categories/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get By ID Cat')
})
t.test('returns 404 for missing category', { tags: ['categories', 'read'] }, async () => {
const res = await t.api.get('/v1/categories/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('updates a category', { tags: ['categories', 'update'] }, async () => {
const created = await t.api.post('/v1/categories', { name: 'Before Update' })
const res = await t.api.patch(`/v1/categories/${created.data.id}`, { name: 'After Update', sortOrder: 5 })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'After Update')
t.assert.equal(res.data.sortOrder, 5)
})
t.test('soft-deletes a category', { tags: ['categories', 'delete'] }, async () => {
const created = await t.api.post('/v1/categories', { name: 'To Delete Cat' })
const res = await t.api.del(`/v1/categories/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Suppliers ─────────────────────────────────────────────────────────────
t.test('creates a supplier', { tags: ['suppliers', 'create'] }, async () => {
const res = await t.api.post('/v1/suppliers', {
name: 'Shar Music',
email: 'orders@sharmusic.com',
phone: '800-248-7427',
paymentTerms: 'Net 30',
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Shar Music')
t.assert.equal(res.data.paymentTerms, 'Net 30')
t.assert.ok(res.data.id)
})
t.test('rejects supplier without name', { tags: ['suppliers', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/suppliers', { email: 'x@x.com' })
t.assert.status(res, 400)
})
t.test('lists suppliers with pagination', { tags: ['suppliers', 'list'] }, async () => {
await t.api.post('/v1/suppliers', { name: 'Sup List A' })
await t.api.post('/v1/suppliers', { name: 'Sup List B' })
const res = await t.api.get('/v1/suppliers', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination.total >= 2)
})
t.test('searches suppliers by name', { tags: ['suppliers', 'list', 'search'] }, async () => {
await t.api.post('/v1/suppliers', { name: 'SearchableSupplierXYZ' })
const res = await t.api.get('/v1/suppliers', { q: 'SearchableSupplierXYZ' })
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 1)
t.assert.equal(res.data.data[0].name, 'SearchableSupplierXYZ')
})
t.test('updates a supplier', { tags: ['suppliers', 'update'] }, async () => {
const created = await t.api.post('/v1/suppliers', { name: 'Old Supplier Name' })
const res = await t.api.patch(`/v1/suppliers/${created.data.id}`, { name: 'New Supplier Name', accountNumber: 'ACC-001' })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'New Supplier Name')
t.assert.equal(res.data.accountNumber, 'ACC-001')
})
t.test('soft-deletes a supplier', { tags: ['suppliers', 'delete'] }, async () => {
const created = await t.api.post('/v1/suppliers', { name: 'To Delete Sup' })
const res = await t.api.del(`/v1/suppliers/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Products ──────────────────────────────────────────────────────────────
t.test('creates a product', { tags: ['products', 'create'] }, async () => {
const res = await t.api.post('/v1/products', {
name: 'Violin 4/4 Student',
sku: 'VLN-44-TST',
brand: 'Eastman',
price: 399,
isSerialized: true,
isRental: true,
isActive: true,
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Violin 4/4 Student')
t.assert.equal(res.data.sku, 'VLN-44-TST')
t.assert.equal(res.data.price, '399.00')
t.assert.equal(res.data.isSerialized, true)
t.assert.ok(res.data.id)
})
t.test('rejects product without name', { tags: ['products', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/products', { sku: 'NO-NAME' })
t.assert.status(res, 400)
})
t.test('lists products with pagination', { tags: ['products', 'list'] }, async () => {
await t.api.post('/v1/products', { name: 'Prod List A', price: 100 })
await t.api.post('/v1/products', { name: 'Prod List B', price: 200 })
const res = await t.api.get('/v1/products', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('searches products by name', { tags: ['products', 'list', 'search'] }, async () => {
await t.api.post('/v1/products', { name: 'SearchableViolinXYZ' })
const res = await t.api.get('/v1/products', { q: 'SearchableViolinXYZ' })
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 1)
t.assert.equal(res.data.data[0].name, 'SearchableViolinXYZ')
})
t.test('filters products by categoryId', { tags: ['products', 'list', 'filter'] }, async () => {
const cat = await t.api.post('/v1/categories', { name: 'FilterCat' })
await t.api.post('/v1/products', { name: 'In Cat Prod', categoryId: cat.data.id })
await t.api.post('/v1/products', { name: 'No Cat Prod' })
const res = await t.api.get('/v1/products', { categoryId: cat.data.id })
t.assert.status(res, 200)
t.assert.ok(res.data.data.every((p: any) => p.categoryId === cat.data.id))
})
t.test('gets product by id', { tags: ['products', 'read'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'Get By ID Prod' })
const res = await t.api.get(`/v1/products/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get By ID Prod')
})
t.test('returns 404 for missing product', { tags: ['products', 'read'] }, async () => {
const res = await t.api.get('/v1/products/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('updates a product and records price history', { tags: ['products', 'update', 'price-history'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'Price Test Prod', price: 100 })
const res = await t.api.patch(`/v1/products/${created.data.id}`, { price: 150, name: 'Price Test Prod Updated' })
t.assert.status(res, 200)
t.assert.equal(res.data.price, '150.00')
const history = await t.api.get(`/v1/products/${created.data.id}/price-history`)
t.assert.status(history, 200)
t.assert.equal(history.data.data.length, 1)
t.assert.equal(history.data.data[0].previousPrice, '100.00')
t.assert.equal(history.data.data[0].newPrice, '150.00')
})
t.test('price history is empty for new product', { tags: ['products', 'price-history'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'No History Prod', price: 50 })
const res = await t.api.get(`/v1/products/${created.data.id}/price-history`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 0)
})
t.test('soft-deletes a product', { tags: ['products', 'delete'] }, async () => {
const created = await t.api.post('/v1/products', { name: 'To Delete Prod' })
const res = await t.api.del(`/v1/products/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Inventory Units ───────────────────────────────────────────────────────
t.test('creates a serialized unit', { tags: ['units', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Serial Prod', isSerialized: true })
const res = await t.api.post(`/v1/products/${prod.data.id}/units`, {
serialNumber: 'SN-001',
condition: 'new',
status: 'available',
purchaseCost: 185,
})
t.assert.status(res, 201)
t.assert.equal(res.data.serialNumber, 'SN-001')
t.assert.equal(res.data.condition, 'new')
t.assert.equal(res.data.status, 'available')
t.assert.equal(res.data.purchaseCost, '185.00')
})
t.test('lists units for a product', { tags: ['units', 'list'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Multi Unit Prod', isSerialized: true })
await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-A', condition: 'new', status: 'available' })
await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-B', condition: 'good', status: 'rented' })
const res = await t.api.get(`/v1/products/${prod.data.id}/units`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
})
t.test('updates a unit status', { tags: ['units', 'update'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Update Unit Prod', isSerialized: true })
const unit = await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-UPD', condition: 'good', status: 'available' })
const res = await t.api.patch(`/v1/units/${unit.data.id}`, { status: 'rented', notes: 'Rented to customer A' })
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'rented')
t.assert.equal(res.data.notes, 'Rented to customer A')
})
// ─── Product Suppliers ─────────────────────────────────────────────────────
t.test('links a supplier to a product', { tags: ['product-suppliers', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Linkable Prod' })
const sup = await t.api.post('/v1/suppliers', { name: 'Link Supplier' })
const res = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, {
supplierId: sup.data.id,
supplierSku: 'SUP-SKU-001',
isPreferred: true,
})
t.assert.status(res, 201)
t.assert.equal(res.data.supplierId, sup.data.id)
t.assert.equal(res.data.supplierSku, 'SUP-SKU-001')
t.assert.equal(res.data.isPreferred, true)
})
t.test('lists suppliers for a product with joined supplier details', { tags: ['product-suppliers', 'list'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Supplier List Prod' })
const sup1 = await t.api.post('/v1/suppliers', { name: 'Supplier Alpha', paymentTerms: 'Net 30' })
const sup2 = await t.api.post('/v1/suppliers', { name: 'Supplier Beta' })
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup1.data.id, isPreferred: true })
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup2.data.id, isPreferred: false })
const res = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
// preferred comes first
t.assert.equal(res.data.data[0].supplierName, 'Supplier Alpha')
t.assert.equal(res.data.data[0].isPreferred, true)
t.assert.equal(res.data.data[0].supplierPaymentTerms, 'Net 30')
t.assert.equal(res.data.data[1].supplierName, 'Supplier Beta')
})
t.test('preferred flag moves to new supplier when set', { tags: ['product-suppliers', 'preferred'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Preferred Test Prod' })
const sup1 = await t.api.post('/v1/suppliers', { name: 'Pref Sup A' })
const sup2 = await t.api.post('/v1/suppliers', { name: 'Pref Sup B' })
const link1 = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup1.data.id, isPreferred: true })
t.assert.equal(link1.data.isPreferred, true)
// Link second supplier as preferred — first should lose preferred status
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup2.data.id, isPreferred: true })
const list = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
const a = list.data.data.find((s: any) => s.supplierId === sup1.data.id)
const b = list.data.data.find((s: any) => s.supplierId === sup2.data.id)
t.assert.equal(a.isPreferred, false)
t.assert.equal(b.isPreferred, true)
})
t.test('updates a product-supplier link', { tags: ['product-suppliers', 'update'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Update Link Prod' })
const sup = await t.api.post('/v1/suppliers', { name: 'Update Link Sup' })
const link = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup.data.id, supplierSku: 'OLD-SKU' })
const res = await t.api.patch(`/v1/products/${prod.data.id}/suppliers/${link.data.id}`, { supplierSku: 'NEW-SKU' })
t.assert.status(res, 200)
t.assert.equal(res.data.supplierSku, 'NEW-SKU')
})
t.test('removes a supplier link', { tags: ['product-suppliers', 'delete'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Remove Link Prod' })
const sup = await t.api.post('/v1/suppliers', { name: 'Remove Link Sup' })
const link = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup.data.id })
const res = await t.api.del(`/v1/products/${prod.data.id}/suppliers/${link.data.id}`)
t.assert.status(res, 200)
const list = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
t.assert.equal(list.data.data.length, 0)
})
t.test('returns 404 for missing product-supplier link update', { tags: ['product-suppliers', 'update'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Missing Link Prod' })
const res = await t.api.patch(`/v1/products/${prod.data.id}/suppliers/a0000000-0000-0000-0000-999999999999`, { supplierSku: 'X' })
t.assert.status(res, 404)
})
// ─── Stock Receipts ────────────────────────────────────────────────────────
t.test('records a stock receipt and increments qty_on_hand', { tags: ['stock-receipts', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Rosin Stock', qtyOnHand: 10 })
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
qty: 5,
costPerUnit: 6.50,
receivedDate: '2026-03-15',
invoiceNumber: 'INV-2026-001',
})
t.assert.status(res, 201)
t.assert.equal(res.data.qty, 5)
t.assert.equal(res.data.costPerUnit, '6.50')
t.assert.equal(res.data.totalCost, '32.50')
t.assert.equal(res.data.invoiceNumber, 'INV-2026-001')
// qty_on_hand should have increased
const updated = await t.api.get(`/v1/products/${prod.data.id}`)
t.assert.equal(updated.data.qtyOnHand, 15)
})
t.test('records receipt with supplier link', { tags: ['stock-receipts', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Strings Stock' })
const sup = await t.api.post('/v1/suppliers', { name: 'Receipt Supplier' })
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
supplierId: sup.data.id,
qty: 12,
costPerUnit: 19.50,
receivedDate: '2026-03-20',
})
t.assert.status(res, 201)
t.assert.equal(res.data.supplierId, sup.data.id)
})
t.test('does not increment qty for serialized products', { tags: ['stock-receipts', 'create'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Serial Stock', isSerialized: true, qtyOnHand: 0 })
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
qty: 1,
costPerUnit: 185,
receivedDate: '2026-03-01',
})
const updated = await t.api.get(`/v1/products/${prod.data.id}`)
t.assert.equal(updated.data.qtyOnHand, 0)
})
t.test('lists stock receipts for a product', { tags: ['stock-receipts', 'list'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'List Receipts Prod' })
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 10, costPerUnit: 5, receivedDate: '2026-01-10' })
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 20, costPerUnit: 4.75, receivedDate: '2026-02-15' })
const res = await t.api.get(`/v1/products/${prod.data.id}/stock-receipts`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
// most recent date first
t.assert.equal(res.data.data[0].receivedDate, '2026-02-15')
})
t.test('rejects receipt with missing required fields', { tags: ['stock-receipts', 'validation'] }, async () => {
const prod = await t.api.post('/v1/products', { name: 'Invalid Receipt Prod' })
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 5 })
t.assert.status(res, 400)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,11 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(result)
})
app.get('/categories/all', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const rows = await CategoryService.listAll(app.db)
return reply.send({ data: rows })
})
app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const category = await CategoryService.getById(app.db, id)

View File

@@ -4,9 +4,12 @@ import {
ProductUpdateSchema,
InventoryUnitCreateSchema,
InventoryUnitUpdateSchema,
ProductSupplierCreateSchema,
ProductSupplierUpdateSchema,
StockReceiptCreateSchema,
PaginationSchema,
} from '@lunarfront/shared/schemas'
import { ProductService, InventoryUnitService } from '../../services/product.service.js'
import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js'
export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Products ---
@@ -22,7 +25,16 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await ProductService.list(app.db, params)
const q = request.query as Record<string, string>
const filters = {
categoryId: q.categoryId,
isActive: q.isActive === 'true' ? true : q.isActive === 'false' ? false : undefined,
isSerialized: q.isSerialized === 'true' ? true : q.isSerialized === 'false' ? false : undefined,
isRental: q.isRental === 'true' ? true : q.isRental === 'false' ? false : undefined,
isDualUseRepair: q.isDualUseRepair === 'true' ? true : q.isDualUseRepair === 'false' ? false : undefined,
lowStock: q.lowStock === 'true',
}
const result = await ProductService.list(app.db, params, filters)
return reply.send(result)
})
@@ -87,4 +99,66 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
// --- Product Suppliers ---
app.get('/products/:productId/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await ProductSupplierService.listByProduct(app.db, productId)
return reply.send({ data: rows })
})
app.post('/products/:productId/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = ProductSupplierCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const row = await ProductSupplierService.create(app.db, productId, parsed.data)
return reply.status(201).send(row)
})
app.patch('/products/:productId/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId, id } = request.params as { productId: string; id: string }
const parsed = ProductSupplierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const row = await ProductSupplierService.update(app.db, id, productId, parsed.data)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
app.delete('/products/:productId/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const row = await ProductSupplierService.delete(app.db, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })
return reply.send(row)
})
// --- Price History ---
app.get('/products/:productId/price-history', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await ProductService.listPriceHistory(app.db, productId)
return reply.send({ data: rows })
})
// --- Stock Receipts ---
app.post('/products/:productId/stock-receipts', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = StockReceiptCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const receipt = await StockReceiptService.create(app.db, productId, parsed.data)
return reply.status(201).send(receipt)
})
app.get('/products/:productId/stock-receipts', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const rows = await StockReceiptService.listByProduct(app.db, productId)
return reply.send({ data: rows })
})
}

View File

@@ -60,6 +60,14 @@ export const CategoryService = {
return paginatedResponse(data, total, params.page, params.limit)
},
async listAll(db: PostgresJsDatabase<any>) {
return db
.select()
.from(categories)
.where(eq(categories.isActive, true))
.orderBy(categories.sortOrder, categories.name)
},
async update(db: PostgresJsDatabase<any>, id: string, input: CategoryUpdateInput) {
const [category] = await db
.update(categories)

View File

@@ -1,12 +1,15 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import { eq, and, count, desc, lte, sql, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.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 {
@@ -40,14 +43,39 @@ export const ProductService = {
return product ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(products.isActive, true)
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
categoryId?: string
isActive?: boolean
isSerialized?: boolean
isRental?: boolean
isDualUseRepair?: boolean
lowStock?: boolean
}) {
const conditions = [eq(products.isActive, filters?.isActive ?? true)]
const searchCondition = params.q
? buildSearchCondition(params.q, [products.name, products.sku, products.upc, products.brand])
: undefined
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?.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 = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
const sortableColumns: Record<string, Column> = {
name: products.name,
@@ -78,7 +106,7 @@ export const ProductService = {
if (input.price !== undefined || input.minPrice !== undefined) {
const existing = await this.getById(db, id)
if (existing) {
await db.insert(priceHistory).values({
await db.insert(priceHistoryTable).values({
productId: id,
previousPrice: existing.price,
newPrice: input.price?.toString() ?? existing.price ?? '0',
@@ -111,6 +139,14 @@ export const ProductService = {
.returning()
return product ?? null
},
async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) {
return db
.select()
.from(priceHistoryTable)
.where(eq(priceHistoryTable.productId, productId))
.orderBy(desc(priceHistoryTable.createdAt))
},
}
export const InventoryUnitService = {
@@ -200,3 +236,118 @@ export const InventoryUnitService = {
return unit ?? null
},
}
export const ProductSupplierService = {
async listByProduct(db: PostgresJsDatabase<any>, 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<any>, 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<any>, 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<any>, id: string) {
const [row] = await db
.delete(productSuppliers)
.where(eq(productSuppliers.id, id))
.returning()
return row ?? null
},
}
export const StockReceiptService = {
async create(db: PostgresJsDatabase<any>, 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
const [product] = await db.select().from(products).where(eq(products.id, productId)).limit(1)
if (product && !product.isSerialized) {
await db
.update(products)
.set({ qtyOnHand: product.qtyOnHand + input.qty, updatedAt: new Date() })
.where(eq(products.id, productId))
}
return receipt
},
async listByProduct(db: PostgresJsDatabase<any>, 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))
},
}