diff --git a/prisma/migrations/20260803000720_add_asset_item_purchase_order_id/migration.sql b/prisma/migrations/20260803000720_add_asset_item_purchase_order_id/migration.sql new file mode 100644 index 0000000..ea77ac3 --- /dev/null +++ b/prisma/migrations/20260803000720_add_asset_item_purchase_order_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "asset_items" ADD COLUMN "purchase_order_id" UUID; + +-- AddForeignKey +ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b6850fe..469fa83 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,11 +78,16 @@ enum AssetItemStatus { // Hardware inventory — see docs/asset-license-management.md's "Data model // (sơ bộ)" table (serial number, model, ngày mua, bảo hành, trạng thái). -// No Vendor/Assignment relation yet — those entities don't exist yet -// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now, -// same starting shape as Employee before Request grew an FK to it. -// warrantyEndDate is nullable: not every asset has known warranty terms at -// intake time (smallest sensible assumption — doesn't block creation). +// No Assignment relation yet — that entity doesn't exist yet (see "Việc còn +// dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known +// warranty terms at intake time (smallest sensible assumption — doesn't +// block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see +// docs/asset-license-management.md's "## PurchaseOrder (gộp Receipt)" +// Traceability bullet: supply-side traceability ("which PO did this asset +// item come in through"). Nullable because not every AssetItem goes through +// a formal PO (e.g. historical stock predating the system). Set at +// intake/creation time only — no update endpoint for this field yet, same +// precedent as License.vendorId being set-at-creation with no PATCH. model AssetItem { id String @id @default(uuid()) @db.Uuid serialNumber String @unique @map("serial_number") @@ -90,6 +95,8 @@ model AssetItem { purchaseDate DateTime @map("purchase_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date status AssetItemStatus @default(in_stock) + purchaseOrderId String? @map("purchase_order_id") @db.Uuid + purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("asset_items") @@ -167,18 +174,20 @@ model Vendor { // received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a // DB constraint, same approach as other cross-field invariants in this // codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). -// purchaseOrderId back-references on AssetItem/License/Request (traceability, -// same doc section) are NOT part of this change — out of scope for this task. +// `assetItems` is the back-relation from the AssetItem.purchaseOrderId +// retrofit (supply-side traceability, same doc section). License/Request +// back-references are NOT part of this change — out of scope for this task. model PurchaseOrder { - id String @id @default(uuid()) @db.Uuid - vendorId String @map("vendor_id") @db.Uuid - vendor Vendor @relation(fields: [vendorId], references: [id]) - orderDate DateTime @map("order_date") @db.Timestamptz(6) - expectedDate DateTime @map("expected_date") @db.Timestamptz(6) - receivedAt DateTime? @map("received_at") @db.Timestamptz(6) - receivedById String? @map("received_by") @db.Uuid - receivedBy Employee? @relation(fields: [receivedById], references: [id]) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.Uuid + vendorId String @map("vendor_id") @db.Uuid + vendor Vendor @relation(fields: [vendorId], references: [id]) + orderDate DateTime @map("order_date") @db.Timestamptz(6) + expectedDate DateTime @map("expected_date") @db.Timestamptz(6) + receivedAt DateTime? @map("received_at") @db.Timestamptz(6) + receivedById String? @map("received_by") @db.Uuid + receivedBy Employee? @relation(fields: [receivedById], references: [id]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + assetItems AssetItem[] @@map("purchase_orders") } diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 5c3f308..1aada88 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -22,12 +22,14 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); } - const { serialNumber, model, purchaseDate, warrantyEndDate } = body as Record; + const { serialNumber, model, purchaseDate, warrantyEndDate, purchaseOrderId } = + body as Record; const validation = validateCreateAssetItemInput({ serialNumber, model, purchaseDate, warrantyEndDate, + purchaseOrderId, }); if (!validation.ok) { diff --git a/src/lib/assets.ts b/src/lib/assets.ts index 684bb38..dcfd700 100644 --- a/src/lib/assets.ts +++ b/src/lib/assets.ts @@ -11,6 +11,7 @@ export interface AssetItemRecord { purchase_date: string; warranty_end_date: string | null; status: AssetItemStatus; + purchase_order_id: string | null; created_at: string; } @@ -19,6 +20,7 @@ export interface CreateAssetItemInput { model: string; purchaseDate: Date; warrantyEndDate?: Date; + purchaseOrderId?: string | null; } export type ValidationResult = @@ -48,12 +50,24 @@ const createAssetItemSchema = z.object({ model: nonEmptyString("model is required and must be a non-empty string"), purchaseDate: dateString("purchaseDate is required and must be a valid date string"), warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), + // FK to PurchaseOrder — nullable/optional, supply-side traceability (not + // every AssetItem goes through a formal PO). Only a UUID *format* check + // here, whether it references a real PurchaseOrder is left to Prisma's + // own FK constraint at insert time (see createAssetItem()'s P2003 + // handling below). Mirrors src/lib/purchase-orders.ts's receivedById + // optional().nullable() pattern. + purchaseOrderId: z + .string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" }) + .uuid("purchaseOrderId must be a valid UUID string") + .optional() + .nullable(), }); /** * Validates raw (untyped) asset-item-creation input. serialNumber/model * must be non-empty strings, purchaseDate must be a valid date string, * warrantyEndDate is optional but must be a valid date string if present. + * purchaseOrderId is optional/nullable but must be a valid UUID if present. * status is not accepted here — new assets always start "in_stock" (same * pattern as Request always starting "Draft"), later states are set via * other operations, not at creation. @@ -63,6 +77,7 @@ export function validateCreateAssetItemInput(input: { model?: unknown; purchaseDate?: unknown; warrantyEndDate?: unknown; + purchaseOrderId?: unknown; }): ValidationResult { const result = createAssetItemSchema.safeParse(input); if (!result.success) { @@ -79,6 +94,7 @@ function toRecord(row: { purchaseDate: Date; warrantyEndDate: Date | null; status: AssetItemStatus; + purchaseOrderId: string | null; createdAt: Date; }): AssetItemRecord { return { @@ -88,6 +104,7 @@ function toRecord(row: { purchase_date: row.purchaseDate.toISOString(), warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, status: row.status, + purchase_order_id: row.purchaseOrderId, created_at: row.createdAt.toISOString(), }; } @@ -102,7 +119,10 @@ export type CreateAssetItemResult = * caught here and translated into the same `{ok:false,error}` shape as * input validation failures, rather than letting a raw Prisma error leak * to the API layer. Mirrors createEmployee()/createRequest()'s P2003 - * handling for FK violations. + * handling for FK violations. If purchaseOrderId doesn't reference an + * existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) — + * caught the same way, mirrors src/lib/purchase-orders.ts's + * createPurchaseOrder() P2003 handling. */ export async function createAssetItem( input: CreateAssetItemInput @@ -114,6 +134,7 @@ export async function createAssetItem( model: input.model, purchaseDate: input.purchaseDate, warrantyEndDate: input.warrantyEndDate ?? null, + purchaseOrderId: input.purchaseOrderId ?? null, }, }); return { ok: true, value: toRecord(created) }; @@ -121,6 +142,9 @@ export async function createAssetItem( if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { return { ok: false, error: "serialNumber already exists" }; } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" }; + } throw error; } } diff --git a/tests/assets.test.ts b/tests/assets.test.ts index 951ebe5..51f8f9e 100644 --- a/tests/assets.test.ts +++ b/tests/assets.test.ts @@ -24,10 +24,42 @@ function postRequest(body: unknown): Request { }); } +// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see +// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real +// FK to Vendor — tests that need a valid purchaseOrderId create real rows +// first and use their ids. Mirrors tests/purchase-orders.test.ts's +// createTestVendor() helper. +function createTestVendor(overrides: { name?: string } = {}) { + return prisma.vendor.create({ + data: { + name: overrides.name ?? "Test Vendor", + contactEmail: "sales@testvendor.example", + contactPhone: "0281234567", + address: "123 Test Street", + }, + }); +} + +function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) { + return prisma.purchaseOrder.create({ + data: { + vendorId: overrides.vendorId as string, + orderDate: new Date("2026-08-01"), + expectedDate: new Date("2026-08-10"), + }, + }); +} + beforeEach(async () => { - // Each test starts from an empty table so listing/order assertions are - // deterministic. + // Each test starts from empty tables so listing/order assertions are + // deterministic. asset_items must be cleared before purchase_orders/ + // vendors since asset_items has FKs (ON DELETE SET NULL to + // purchase_orders) — deleteMany order avoids relying on that cascade + // behavior in tests. purchase_orders must be cleared before vendors + // (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts). await prisma.assetItem.deleteMany(); + await prisma.purchaseOrder.deleteMany(); + await prisma.vendor.deleteMany(); // Default to a logged-in session — the tests below exercise business // logic, not auth gating (that's covered by the "auth gating" describe // block further down). @@ -111,6 +143,69 @@ describe("POST /api/assets", () => { expect(body.error).toMatch(/warrantyEndDate/i); }); + it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => { + const vendor = await createTestVendor(); + const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id }); + + const res = await POST( + postRequest({ + serialNumber: "SN-007", + model: "Dell Latitude 5440", + purchaseDate: "2026-01-15", + purchaseOrderId: purchaseOrder.id, + }) + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.purchase_order_id).toBe(purchaseOrder.id); + }); + + it("creates an asset item with no purchaseOrderId (stays null)", async () => { + const res = await POST( + postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" }) + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.purchase_order_id).toBeNull(); + }); + + it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => { + const res = await POST( + postRequest({ + serialNumber: "SN-009", + model: "Dell Latitude 5440", + purchaseDate: "2026-01-15", + purchaseOrderId: "00000000-0000-0000-0000-000000000000", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/purchaseOrderId/i); + expect(body.error).toMatch(/does not reference an existing purchase order/i); + + // And it must not have created anything. + const remaining = await prisma.assetItem.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("rejects a purchaseOrderId that is not a valid UUID", async () => { + const res = await POST( + postRequest({ + serialNumber: "SN-012", + model: "Dell Latitude 5440", + purchaseDate: "2026-01-15", + purchaseOrderId: "not-a-uuid", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/purchaseOrderId/i); + }); + it("rejects a duplicate serialNumber", async () => { await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" })); const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));