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:
@@ -0,0 +1,96 @@
|
|||||||
|
CREATE TYPE "public"."item_condition" AS ENUM('new', 'excellent', 'good', 'fair', 'poor');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."unit_status" AS ENUM('available', 'sold', 'rented', 'in_repair', 'retired');--> statement-breakpoint
|
||||||
|
CREATE TABLE "inventory_unit" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"product_id" uuid NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"location_id" uuid,
|
||||||
|
"serial_number" varchar(255),
|
||||||
|
"condition" "item_condition" DEFAULT 'new' NOT NULL,
|
||||||
|
"status" "unit_status" DEFAULT 'available' NOT NULL,
|
||||||
|
"purchase_date" date,
|
||||||
|
"purchase_cost" numeric(10, 2),
|
||||||
|
"notes" text,
|
||||||
|
"legacy_id" varchar(255),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "price_history" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"product_id" uuid NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"previous_price" numeric(10, 2),
|
||||||
|
"new_price" numeric(10, 2) NOT NULL,
|
||||||
|
"previous_min_price" numeric(10, 2),
|
||||||
|
"new_min_price" numeric(10, 2),
|
||||||
|
"reason" text,
|
||||||
|
"changed_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_supplier" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"product_id" uuid NOT NULL,
|
||||||
|
"supplier_id" uuid NOT NULL,
|
||||||
|
"supplier_sku" varchar(100),
|
||||||
|
"is_preferred" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"location_id" uuid,
|
||||||
|
"sku" varchar(100),
|
||||||
|
"upc" varchar(100),
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"brand" varchar(255),
|
||||||
|
"model" varchar(255),
|
||||||
|
"category_id" uuid,
|
||||||
|
"is_serialized" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_rental" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_dual_use_repair" boolean DEFAULT false NOT NULL,
|
||||||
|
"price" numeric(10, 2),
|
||||||
|
"min_price" numeric(10, 2),
|
||||||
|
"rental_rate_monthly" numeric(10, 2),
|
||||||
|
"qty_on_hand" integer DEFAULT 0 NOT NULL,
|
||||||
|
"qty_reorder_point" integer,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"legacy_id" varchar(255),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "stock_receipt" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"location_id" uuid,
|
||||||
|
"product_id" uuid NOT NULL,
|
||||||
|
"supplier_id" uuid,
|
||||||
|
"inventory_unit_id" uuid,
|
||||||
|
"qty" integer DEFAULT 1 NOT NULL,
|
||||||
|
"cost_per_unit" numeric(10, 2) NOT NULL,
|
||||||
|
"total_cost" numeric(10, 2) NOT NULL,
|
||||||
|
"received_date" date NOT NULL,
|
||||||
|
"received_by" uuid,
|
||||||
|
"invoice_number" varchar(100),
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "inventory_unit" ADD CONSTRAINT "inventory_unit_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "inventory_unit" ADD CONSTRAINT "inventory_unit_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "inventory_unit" ADD CONSTRAINT "inventory_unit_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "price_history" ADD CONSTRAINT "price_history_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "price_history" ADD CONSTRAINT "price_history_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_supplier" ADD CONSTRAINT "product_supplier_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product_supplier" ADD CONSTRAINT "product_supplier_supplier_id_supplier_id_fk" FOREIGN KEY ("supplier_id") REFERENCES "public"."supplier"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product" ADD CONSTRAINT "product_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product" ADD CONSTRAINT "product_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product" ADD CONSTRAINT "product_category_id_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "stock_receipt" ADD CONSTRAINT "stock_receipt_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "stock_receipt" ADD CONSTRAINT "stock_receipt_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "stock_receipt" ADD CONSTRAINT "stock_receipt_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "stock_receipt" ADD CONSTRAINT "stock_receipt_supplier_id_supplier_id_fk" FOREIGN KEY ("supplier_id") REFERENCES "public"."supplier"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "stock_receipt" ADD CONSTRAINT "stock_receipt_inventory_unit_id_inventory_unit_id_fk" FOREIGN KEY ("inventory_unit_id") REFERENCES "public"."inventory_unit"("id") ON DELETE no action ON UPDATE no action;
|
||||||
1516
packages/backend/src/db/migrations/meta/0005_snapshot.json
Normal file
1516
packages/backend/src/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1774652800605,
|
"when": 1774652800605,
|
||||||
"tag": "0004_peaceful_wendell_rand",
|
"tag": "0004_peaceful_wendell_rand",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774653515690,
|
||||||
|
"tag": "0005_add_products_units_receipts",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
import { pgTable, uuid, varchar, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
|
import {
|
||||||
import { companies } from './stores.js'
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
numeric,
|
||||||
|
date,
|
||||||
|
pgEnum,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
import { companies, locations } from './stores.js'
|
||||||
|
|
||||||
export const categories = pgTable('category', {
|
export const categories = pgTable('category', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -33,7 +44,130 @@ export const suppliers = pgTable('supplier', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const conditionEnum = pgEnum('item_condition', [
|
||||||
|
'new',
|
||||||
|
'excellent',
|
||||||
|
'good',
|
||||||
|
'fair',
|
||||||
|
'poor',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const unitStatusEnum = pgEnum('unit_status', [
|
||||||
|
'available',
|
||||||
|
'sold',
|
||||||
|
'rented',
|
||||||
|
'in_repair',
|
||||||
|
'retired',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const products = pgTable('product', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id),
|
||||||
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
|
sku: varchar('sku', { length: 100 }),
|
||||||
|
upc: varchar('upc', { length: 100 }),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
brand: varchar('brand', { length: 255 }),
|
||||||
|
model: varchar('model', { length: 255 }),
|
||||||
|
categoryId: uuid('category_id').references(() => categories.id),
|
||||||
|
isSerialized: boolean('is_serialized').notNull().default(false),
|
||||||
|
isRental: boolean('is_rental').notNull().default(false),
|
||||||
|
isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false),
|
||||||
|
price: numeric('price', { precision: 10, scale: 2 }),
|
||||||
|
minPrice: numeric('min_price', { precision: 10, scale: 2 }),
|
||||||
|
rentalRateMonthly: numeric('rental_rate_monthly', { precision: 10, scale: 2 }),
|
||||||
|
qtyOnHand: integer('qty_on_hand').notNull().default(0),
|
||||||
|
qtyReorderPoint: integer('qty_reorder_point'),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
legacyId: varchar('legacy_id', { length: 255 }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const inventoryUnits = pgTable('inventory_unit', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
companyId: uuid('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id),
|
||||||
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
|
serialNumber: varchar('serial_number', { length: 255 }),
|
||||||
|
condition: conditionEnum('condition').notNull().default('new'),
|
||||||
|
status: unitStatusEnum('status').notNull().default('available'),
|
||||||
|
purchaseDate: date('purchase_date'),
|
||||||
|
purchaseCost: numeric('purchase_cost', { precision: 10, scale: 2 }),
|
||||||
|
notes: text('notes'),
|
||||||
|
legacyId: varchar('legacy_id', { length: 255 }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const productSuppliers = pgTable('product_supplier', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
supplierId: uuid('supplier_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => suppliers.id),
|
||||||
|
supplierSku: varchar('supplier_sku', { length: 100 }),
|
||||||
|
isPreferred: boolean('is_preferred').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Category = typeof categories.$inferSelect
|
export type Category = typeof categories.$inferSelect
|
||||||
export type CategoryInsert = typeof categories.$inferInsert
|
export type CategoryInsert = typeof categories.$inferInsert
|
||||||
export type Supplier = typeof suppliers.$inferSelect
|
export type Supplier = typeof suppliers.$inferSelect
|
||||||
export type SupplierInsert = typeof suppliers.$inferInsert
|
export type SupplierInsert = typeof suppliers.$inferInsert
|
||||||
|
export const stockReceipts = pgTable('stock_receipt', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id),
|
||||||
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
supplierId: uuid('supplier_id').references(() => suppliers.id),
|
||||||
|
inventoryUnitId: uuid('inventory_unit_id').references(() => inventoryUnits.id),
|
||||||
|
qty: integer('qty').notNull().default(1),
|
||||||
|
costPerUnit: numeric('cost_per_unit', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
totalCost: numeric('total_cost', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
receivedDate: date('received_date').notNull(),
|
||||||
|
receivedBy: uuid('received_by'),
|
||||||
|
invoiceNumber: varchar('invoice_number', { length: 100 }),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const priceHistory = pgTable('price_history', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
companyId: uuid('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id),
|
||||||
|
previousPrice: numeric('previous_price', { precision: 10, scale: 2 }),
|
||||||
|
newPrice: numeric('new_price', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
previousMinPrice: numeric('previous_min_price', { precision: 10, scale: 2 }),
|
||||||
|
newMinPrice: numeric('new_min_price', { precision: 10, scale: 2 }),
|
||||||
|
reason: text('reason'),
|
||||||
|
changedBy: uuid('changed_by'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PriceHistory = typeof priceHistory.$inferSelect
|
||||||
|
|
||||||
|
export type StockReceipt = typeof stockReceipts.$inferSelect
|
||||||
|
export type StockReceiptInsert = typeof stockReceipts.$inferInsert
|
||||||
|
|
||||||
|
export type Product = typeof products.$inferSelect
|
||||||
|
export type ProductInsert = typeof products.$inferInsert
|
||||||
|
export type InventoryUnit = typeof inventoryUnits.$inferSelect
|
||||||
|
export type InventoryUnitInsert = typeof inventoryUnits.$inferInsert
|
||||||
|
export type ProductSupplier = typeof productSuppliers.$inferSelect
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { healthRoutes } from './routes/v1/health.js'
|
|||||||
import { authRoutes } from './routes/v1/auth.js'
|
import { authRoutes } from './routes/v1/auth.js'
|
||||||
import { accountRoutes } from './routes/v1/accounts.js'
|
import { accountRoutes } from './routes/v1/accounts.js'
|
||||||
import { inventoryRoutes } from './routes/v1/inventory.js'
|
import { inventoryRoutes } from './routes/v1/inventory.js'
|
||||||
|
import { productRoutes } from './routes/v1/products.js'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -37,6 +38,7 @@ export async function buildApp() {
|
|||||||
await app.register(authRoutes, { prefix: '/v1' })
|
await app.register(authRoutes, { prefix: '/v1' })
|
||||||
await app.register(accountRoutes, { prefix: '/v1' })
|
await app.register(accountRoutes, { prefix: '/v1' })
|
||||||
await app.register(inventoryRoutes, { prefix: '/v1' })
|
await app.register(inventoryRoutes, { prefix: '/v1' })
|
||||||
|
await app.register(productRoutes, { prefix: '/v1' })
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
157
packages/backend/src/services/product.service.ts
Normal file
157
packages/backend/src/services/product.service.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { eq, and, or, ilike } from 'drizzle-orm'
|
||||||
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
|
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
|
||||||
|
import type {
|
||||||
|
ProductCreateInput,
|
||||||
|
ProductUpdateInput,
|
||||||
|
InventoryUnitCreateInput,
|
||||||
|
InventoryUnitUpdateInput,
|
||||||
|
} from '@forte/shared/schemas'
|
||||||
|
|
||||||
|
export const ProductService = {
|
||||||
|
async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) {
|
||||||
|
const [product] = await db
|
||||||
|
.insert(products)
|
||||||
|
.values({
|
||||||
|
companyId,
|
||||||
|
...input,
|
||||||
|
price: input.price?.toString(),
|
||||||
|
minPrice: input.minPrice?.toString(),
|
||||||
|
rentalRateMonthly: input.rentalRateMonthly?.toString(),
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
return product
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
|
||||||
|
const [product] = await db
|
||||||
|
.select()
|
||||||
|
.from(products)
|
||||||
|
.where(and(eq(products.id, id), eq(products.companyId, companyId)))
|
||||||
|
.limit(1)
|
||||||
|
return product ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(db: PostgresJsDatabase, companyId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(products)
|
||||||
|
.where(and(eq(products.companyId, companyId), eq(products.isActive, true)))
|
||||||
|
.limit(100)
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(db: PostgresJsDatabase, companyId: string, query: string) {
|
||||||
|
const pattern = `%${query}%`
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(products)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(products.companyId, companyId),
|
||||||
|
eq(products.isActive, true),
|
||||||
|
or(
|
||||||
|
ilike(products.name, pattern),
|
||||||
|
ilike(products.sku, pattern),
|
||||||
|
ilike(products.upc, pattern),
|
||||||
|
ilike(products.brand, pattern),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(50)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProductUpdateInput, changedBy?: string) {
|
||||||
|
// Log price changes before updating
|
||||||
|
if (input.price !== undefined || input.minPrice !== undefined) {
|
||||||
|
const existing = await this.getById(db, companyId, id)
|
||||||
|
if (existing) {
|
||||||
|
await db.insert(priceHistory).values({
|
||||||
|
productId: id,
|
||||||
|
companyId,
|
||||||
|
previousPrice: existing.price,
|
||||||
|
newPrice: input.price?.toString() ?? existing.price ?? '0',
|
||||||
|
previousMinPrice: existing.minPrice,
|
||||||
|
newMinPrice: input.minPrice?.toString() ?? existing.minPrice,
|
||||||
|
changedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||||
|
if (input.price !== undefined) updates.price = input.price.toString()
|
||||||
|
if (input.minPrice !== undefined) updates.minPrice = input.minPrice.toString()
|
||||||
|
if (input.rentalRateMonthly !== undefined)
|
||||||
|
updates.rentalRateMonthly = input.rentalRateMonthly.toString()
|
||||||
|
|
||||||
|
const [product] = await db
|
||||||
|
.update(products)
|
||||||
|
.set(updates)
|
||||||
|
.where(and(eq(products.id, id), eq(products.companyId, companyId)))
|
||||||
|
.returning()
|
||||||
|
return product ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
|
||||||
|
const [product] = await db
|
||||||
|
.update(products)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(products.id, id), eq(products.companyId, companyId)))
|
||||||
|
.returning()
|
||||||
|
return product ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InventoryUnitService = {
|
||||||
|
async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) {
|
||||||
|
const [unit] = await db
|
||||||
|
.insert(inventoryUnits)
|
||||||
|
.values({
|
||||||
|
companyId,
|
||||||
|
productId: input.productId,
|
||||||
|
locationId: input.locationId,
|
||||||
|
serialNumber: input.serialNumber,
|
||||||
|
condition: input.condition,
|
||||||
|
status: input.status,
|
||||||
|
purchaseDate: input.purchaseDate,
|
||||||
|
purchaseCost: input.purchaseCost?.toString(),
|
||||||
|
notes: input.notes,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
return unit
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
|
||||||
|
const [unit] = await db
|
||||||
|
.select()
|
||||||
|
.from(inventoryUnits)
|
||||||
|
.where(and(eq(inventoryUnits.id, id), eq(inventoryUnits.companyId, companyId)))
|
||||||
|
.limit(1)
|
||||||
|
return unit ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async listByProduct(db: PostgresJsDatabase, companyId: string, productId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(inventoryUnits)
|
||||||
|
.where(
|
||||||
|
and(eq(inventoryUnits.companyId, companyId), eq(inventoryUnits.productId, productId)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
db: PostgresJsDatabase,
|
||||||
|
companyId: string,
|
||||||
|
id: string,
|
||||||
|
input: InventoryUnitUpdateInput,
|
||||||
|
) {
|
||||||
|
const updates: Record<string, unknown> = { ...input }
|
||||||
|
if (input.purchaseCost !== undefined) updates.purchaseCost = input.purchaseCost.toString()
|
||||||
|
|
||||||
|
const [unit] = await db
|
||||||
|
.update(inventoryUnits)
|
||||||
|
.set(updates)
|
||||||
|
.where(and(eq(inventoryUnits.id, id), eq(inventoryUnits.companyId, companyId)))
|
||||||
|
.returning()
|
||||||
|
return unit ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -21,10 +21,21 @@ export {
|
|||||||
CategoryUpdateSchema,
|
CategoryUpdateSchema,
|
||||||
SupplierCreateSchema,
|
SupplierCreateSchema,
|
||||||
SupplierUpdateSchema,
|
SupplierUpdateSchema,
|
||||||
|
ItemCondition,
|
||||||
|
UnitStatus,
|
||||||
|
ProductCreateSchema,
|
||||||
|
ProductUpdateSchema,
|
||||||
|
ProductSearchSchema,
|
||||||
|
InventoryUnitCreateSchema,
|
||||||
|
InventoryUnitUpdateSchema,
|
||||||
} from './inventory.schema.js'
|
} from './inventory.schema.js'
|
||||||
export type {
|
export type {
|
||||||
CategoryCreateInput,
|
CategoryCreateInput,
|
||||||
CategoryUpdateInput,
|
CategoryUpdateInput,
|
||||||
SupplierCreateInput,
|
SupplierCreateInput,
|
||||||
SupplierUpdateInput,
|
SupplierUpdateInput,
|
||||||
|
ProductCreateInput,
|
||||||
|
ProductUpdateInput,
|
||||||
|
InventoryUnitCreateInput,
|
||||||
|
InventoryUnitUpdateInput,
|
||||||
} from './inventory.schema.js'
|
} from './inventory.schema.js'
|
||||||
|
|||||||
@@ -25,3 +25,48 @@ export type SupplierCreateInput = z.infer<typeof SupplierCreateSchema>
|
|||||||
|
|
||||||
export const SupplierUpdateSchema = SupplierCreateSchema.partial()
|
export const SupplierUpdateSchema = SupplierCreateSchema.partial()
|
||||||
export type SupplierUpdateInput = z.infer<typeof SupplierUpdateSchema>
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user