From 1132e0999b48f8796d189d6719d5b9c3ff5fcba5 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Fri, 27 Mar 2026 18:22:39 -0500 Subject: [PATCH] Add products, inventory units, stock receipts, and price history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../0005_add_products_units_receipts.sql | 96 ++ .../src/db/migrations/meta/0005_snapshot.json | 1516 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/inventory.ts | 138 +- packages/backend/src/main.ts | 2 + .../backend/src/routes/v1/products.test.ts | 222 +++ packages/backend/src/routes/v1/products.ts | 97 ++ .../backend/src/services/product.service.ts | 157 ++ packages/shared/src/schemas/index.ts | 11 + .../shared/src/schemas/inventory.schema.ts | 45 + 10 files changed, 2289 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/db/migrations/0005_add_products_units_receipts.sql create mode 100644 packages/backend/src/db/migrations/meta/0005_snapshot.json create mode 100644 packages/backend/src/routes/v1/products.test.ts create mode 100644 packages/backend/src/routes/v1/products.ts create mode 100644 packages/backend/src/services/product.service.ts diff --git a/packages/backend/src/db/migrations/0005_add_products_units_receipts.sql b/packages/backend/src/db/migrations/0005_add_products_units_receipts.sql new file mode 100644 index 0000000..f6fb2a6 --- /dev/null +++ b/packages/backend/src/db/migrations/0005_add_products_units_receipts.sql @@ -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; \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/0005_snapshot.json b/packages/backend/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..ad42a5c --- /dev/null +++ b/packages/backend/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1516 @@ +{ + "id": "c7d24a4e-be5c-47ab-b743-ceafcfead0e3", + "prevId": "eb655a87-6e46-4ea1-a129-c10f522fb093", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "category_company_id_company_id_fk": { + "name": "category_company_id_company_id_fk", + "tableFrom": "category", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_unit": { + "name": "inventory_unit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "item_condition", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "status": { + "name": "status", + "type": "unit_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "purchase_date": { + "name": "purchase_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "purchase_cost": { + "name": "purchase_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inventory_unit_product_id_product_id_fk": { + "name": "inventory_unit_product_id_product_id_fk", + "tableFrom": "inventory_unit", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inventory_unit_company_id_company_id_fk": { + "name": "inventory_unit_company_id_company_id_fk", + "tableFrom": "inventory_unit", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inventory_unit_location_id_location_id_fk": { + "name": "inventory_unit_location_id_location_id_fk", + "tableFrom": "inventory_unit", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.price_history": { + "name": "price_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "previous_price": { + "name": "previous_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "new_price": { + "name": "new_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "previous_min_price": { + "name": "previous_min_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "new_min_price": { + "name": "new_min_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "price_history_product_id_product_id_fk": { + "name": "price_history_product_id_product_id_fk", + "tableFrom": "price_history", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "price_history_company_id_company_id_fk": { + "name": "price_history_company_id_company_id_fk", + "tableFrom": "price_history", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_supplier": { + "name": "product_supplier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "supplier_id": { + "name": "supplier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "supplier_sku": { + "name": "supplier_sku", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_preferred": { + "name": "is_preferred", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_supplier_product_id_product_id_fk": { + "name": "product_supplier_product_id_product_id_fk", + "tableFrom": "product_supplier", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_supplier_supplier_id_supplier_id_fk": { + "name": "product_supplier_supplier_id_supplier_id_fk", + "tableFrom": "product_supplier", + "tableTo": "supplier", + "columnsFrom": [ + "supplier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sku": { + "name": "sku", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "upc": { + "name": "upc", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_serialized": { + "name": "is_serialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_rental": { + "name": "is_rental", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_dual_use_repair": { + "name": "is_dual_use_repair", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "min_price": { + "name": "min_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "rental_rate_monthly": { + "name": "rental_rate_monthly", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "qty_on_hand": { + "name": "qty_on_hand", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qty_reorder_point": { + "name": "qty_reorder_point", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "legacy_id": { + "name": "legacy_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_company_id_company_id_fk": { + "name": "product_company_id_company_id_fk", + "tableFrom": "product", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_location_id_location_id_fk": { + "name": "product_location_id_location_id_fk", + "tableFrom": "product", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "product_category_id_category_id_fk": { + "name": "product_category_id_category_id_fk", + "tableFrom": "product", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stock_receipt": { + "name": "stock_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "supplier_id": { + "name": "supplier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inventory_unit_id": { + "name": "inventory_unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "qty": { + "name": "qty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "cost_per_unit": { + "name": "cost_per_unit", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "total_cost": { + "name": "total_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "received_date": { + "name": "received_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "received_by": { + "name": "received_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stock_receipt_company_id_company_id_fk": { + "name": "stock_receipt_company_id_company_id_fk", + "tableFrom": "stock_receipt", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_receipt_location_id_location_id_fk": { + "name": "stock_receipt_location_id_location_id_fk", + "tableFrom": "stock_receipt", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_receipt_product_id_product_id_fk": { + "name": "stock_receipt_product_id_product_id_fk", + "tableFrom": "stock_receipt", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_receipt_supplier_id_supplier_id_fk": { + "name": "stock_receipt_supplier_id_supplier_id_fk", + "tableFrom": "stock_receipt", + "tableTo": "supplier", + "columnsFrom": [ + "supplier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "stock_receipt_inventory_unit_id_inventory_unit_id_fk": { + "name": "stock_receipt_inventory_unit_id_inventory_unit_id_fk", + "tableFrom": "stock_receipt", + "tableTo": "inventory_unit", + "columnsFrom": [ + "inventory_unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supplier": { + "name": "supplier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contact_name": { + "name": "contact_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "account_number": { + "name": "account_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "payment_terms": { + "name": "payment_terms", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "supplier_company_id_company_id_fk": { + "name": "supplier_company_id_company_id_fk", + "tableFrom": "supplier", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'staff'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_company_id_company_id_fk": { + "name": "user_company_id_company_id_fk", + "tableFrom": "user", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company": { + "name": "company", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'America/Chicago'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "location_company_id_company_id_fk": { + "name": "location_company_id_company_id_fk", + "tableFrom": "location", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_processor_link": { + "name": "account_processor_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "payment_processor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "processor_customer_id": { + "name": "processor_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_processor_link_account_id_account_id_fk": { + "name": "account_processor_link_account_id_account_id_fk", + "tableFrom": "account_processor_link", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "account_processor_link_company_id_company_id_fk": { + "name": "account_processor_link_company_id_company_id_fk", + "tableFrom": "account_processor_link", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_number": { + "name": "account_number", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "billing_mode": { + "name": "billing_mode", + "type": "billing_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'consolidated'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "legacy_id": { + "name": "legacy_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "legacy_source": { + "name": "legacy_source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_company_id_company_id_fk": { + "name": "account_company_id_company_id_fk", + "tableFrom": "account", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "is_minor": { + "name": "is_minor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "member_account_id_account_id_fk": { + "name": "member_account_id_account_id_fk", + "tableFrom": "member", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "member_company_id_company_id_fk": { + "name": "member_company_id_company_id_fk", + "tableFrom": "member", + "tableTo": "company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.item_condition": { + "name": "item_condition", + "schema": "public", + "values": [ + "new", + "excellent", + "good", + "fair", + "poor" + ] + }, + "public.unit_status": { + "name": "unit_status", + "schema": "public", + "values": [ + "available", + "sold", + "rented", + "in_repair", + "retired" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "manager", + "staff", + "technician", + "instructor" + ] + }, + "public.billing_mode": { + "name": "billing_mode", + "schema": "public", + "values": [ + "consolidated", + "split" + ] + }, + "public.payment_processor": { + "name": "payment_processor", + "schema": "public", + "values": [ + "stripe", + "global_payments" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index d472338..60686b1 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1774652800605, "tag": "0004_peaceful_wendell_rand", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1774653515690, + "tag": "0005_add_products_units_receipts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/inventory.ts b/packages/backend/src/db/schema/inventory.ts index 16a5385..086ebee 100644 --- a/packages/backend/src/db/schema/inventory.ts +++ b/packages/backend/src/db/schema/inventory.ts @@ -1,5 +1,16 @@ -import { pgTable, uuid, varchar, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' -import { companies } from './stores.js' +import { + 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', { id: uuid('id').primaryKey().defaultRandom(), @@ -33,7 +44,130 @@ export const suppliers = pgTable('supplier', { 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 CategoryInsert = typeof categories.$inferInsert export type Supplier = typeof suppliers.$inferSelect 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 diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 7a70ca2..308e25a 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -9,6 +9,7 @@ import { healthRoutes } from './routes/v1/health.js' import { authRoutes } from './routes/v1/auth.js' import { accountRoutes } from './routes/v1/accounts.js' import { inventoryRoutes } from './routes/v1/inventory.js' +import { productRoutes } from './routes/v1/products.js' export async function buildApp() { const app = Fastify({ @@ -37,6 +38,7 @@ export async function buildApp() { await app.register(authRoutes, { prefix: '/v1' }) await app.register(accountRoutes, { prefix: '/v1' }) await app.register(inventoryRoutes, { prefix: '/v1' }) + await app.register(productRoutes, { prefix: '/v1' }) return app } diff --git a/packages/backend/src/routes/v1/products.test.ts b/packages/backend/src/routes/v1/products.test.ts new file mode 100644 index 0000000..1a997fe --- /dev/null +++ b/packages/backend/src/routes/v1/products.test.ts @@ -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') + }) +}) \ No newline at end of file diff --git a/packages/backend/src/routes/v1/products.ts b/packages/backend/src/routes/v1/products.ts new file mode 100644 index 0000000..e41f8a3 --- /dev/null +++ b/packages/backend/src/routes/v1/products.ts @@ -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) + }) +} diff --git a/packages/backend/src/services/product.service.ts b/packages/backend/src/services/product.service.ts new file mode 100644 index 0000000..02a780b --- /dev/null +++ b/packages/backend/src/services/product.service.ts @@ -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 = { ...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 = { ...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 + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 5fe4a0a..ee5bb64 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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' diff --git a/packages/shared/src/schemas/inventory.schema.ts b/packages/shared/src/schemas/inventory.schema.ts index a634ed5..ddffd16 100644 --- a/packages/shared/src/schemas/inventory.schema.ts +++ b/packages/shared/src/schemas/inventory.schema.ts @@ -25,3 +25,48 @@ export type SupplierCreateInput = z.infer export const SupplierUpdateSchema = SupplierCreateSchema.partial() export type SupplierUpdateInput = z.infer + +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 + +export const ProductUpdateSchema = ProductCreateSchema.partial() +export type ProductUpdateInput = z.infer + +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 + +export const InventoryUnitUpdateSchema = InventoryUnitCreateSchema.omit({ productId: true }).partial() +export type InventoryUnitUpdateInput = z.infer