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:
@@ -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