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:
364
packages/backend/api-tests/suites/inventory.ts
Normal file
364
packages/backend/api-tests/suites/inventory.ts
Normal 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
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user