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

@@ -21,10 +21,21 @@ export {
CategoryUpdateSchema,
SupplierCreateSchema,
SupplierUpdateSchema,
ItemCondition,
UnitStatus,
ProductCreateSchema,
ProductUpdateSchema,
ProductSearchSchema,
InventoryUnitCreateSchema,
InventoryUnitUpdateSchema,
} from './inventory.schema.js'
export type {
CategoryCreateInput,
CategoryUpdateInput,
SupplierCreateInput,
SupplierUpdateInput,
ProductCreateInput,
ProductUpdateInput,
InventoryUnitCreateInput,
InventoryUnitUpdateInput,
} from './inventory.schema.js'

View File

@@ -25,3 +25,48 @@ export type SupplierCreateInput = z.infer<typeof SupplierCreateSchema>
export const SupplierUpdateSchema = SupplierCreateSchema.partial()
export type SupplierUpdateInput = z.infer<typeof SupplierUpdateSchema>
export const ItemCondition = z.enum(['new', 'excellent', 'good', 'fair', 'poor'])
export const UnitStatus = z.enum(['available', 'sold', 'rented', 'in_repair', 'retired'])
export const ProductCreateSchema = z.object({
sku: z.string().max(100).optional(),
upc: z.string().max(100).optional(),
name: z.string().min(1).max(255),
description: z.string().optional(),
brand: z.string().max(255).optional(),
model: z.string().max(255).optional(),
categoryId: z.string().uuid().optional(),
locationId: z.string().uuid().optional(),
isSerialized: z.boolean().default(false),
isRental: z.boolean().default(false),
isDualUseRepair: z.boolean().default(false),
price: z.number().min(0).optional(),
minPrice: z.number().min(0).optional(),
rentalRateMonthly: z.number().min(0).optional(),
qtyOnHand: z.number().int().min(0).default(0),
qtyReorderPoint: z.number().int().min(0).optional(),
})
export type ProductCreateInput = z.infer<typeof ProductCreateSchema>
export const ProductUpdateSchema = ProductCreateSchema.partial()
export type ProductUpdateInput = z.infer<typeof ProductUpdateSchema>
export const ProductSearchSchema = z.object({
q: z.string().min(1).max(255),
})
export const InventoryUnitCreateSchema = z.object({
productId: z.string().uuid(),
locationId: z.string().uuid().optional(),
serialNumber: z.string().max(255).optional(),
condition: ItemCondition.default('new'),
status: UnitStatus.default('available'),
purchaseDate: z.string().date().optional(),
purchaseCost: z.number().min(0).optional(),
notes: z.string().optional(),
})
export type InventoryUnitCreateInput = z.infer<typeof InventoryUnitCreateSchema>
export const InventoryUnitUpdateSchema = InventoryUnitCreateSchema.omit({ productId: true }).partial()
export type InventoryUnitUpdateInput = z.infer<typeof InventoryUnitUpdateSchema>