Merge branch 'feat/asset-item-po-id'

Add nullable AssetItem.purchaseOrderId FK (supply-side PO
traceability). Verified green by test-runner (131/131 tests,
tsc/lint/build clean) before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
toanhoang
2026-08-02 20:12:50 -04:00
co-authored by Claude Sonnet 5
5 changed files with 155 additions and 20 deletions
@@ -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;
+16 -7
View File
@@ -78,11 +78,16 @@ enum AssetItemStatus {
// Hardware inventory — see docs/asset-license-management.md's "Data model // 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). // (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 // No Assignment relation yet — that entity doesn't exist yet (see "Việc còn
// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now, // dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known
// same starting shape as Employee before Request grew an FK to it. // warranty terms at intake time (smallest sensible assumption — doesn't
// warrantyEndDate is nullable: not every asset has known warranty terms at // block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see
// intake time (smallest sensible assumption — doesn't block creation). // 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 { model AssetItem {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
serialNumber String @unique @map("serial_number") serialNumber String @unique @map("serial_number")
@@ -90,6 +95,8 @@ model AssetItem {
purchaseDate DateTime @map("purchase_date") @db.Date purchaseDate DateTime @map("purchase_date") @db.Date
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
status AssetItemStatus @default(in_stock) 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) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("asset_items") @@map("asset_items")
@@ -167,8 +174,9 @@ model Vendor {
// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a // received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a
// DB constraint, same approach as other cross-field invariants in this // DB constraint, same approach as other cross-field invariants in this
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). // codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
// purchaseOrderId back-references on AssetItem/License/Request (traceability, // `assetItems` is the back-relation from the AssetItem.purchaseOrderId
// same doc section) are NOT part of this change — out of scope for this task. // 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 { model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
vendorId String @map("vendor_id") @db.Uuid vendorId String @map("vendor_id") @db.Uuid
@@ -179,6 +187,7 @@ model PurchaseOrder {
receivedById String? @map("received_by") @db.Uuid receivedById String? @map("received_by") @db.Uuid
receivedBy Employee? @relation(fields: [receivedById], references: [id]) receivedBy Employee? @relation(fields: [receivedById], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
assetItems AssetItem[]
@@map("purchase_orders") @@map("purchase_orders")
} }
+3 -1
View File
@@ -22,12 +22,14 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { serialNumber, model, purchaseDate, warrantyEndDate } = body as Record<string, unknown>; const { serialNumber, model, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({ const validation = validateCreateAssetItemInput({
serialNumber, serialNumber,
model, model,
purchaseDate, purchaseDate,
warrantyEndDate, warrantyEndDate,
purchaseOrderId,
}); });
if (!validation.ok) { if (!validation.ok) {
+25 -1
View File
@@ -11,6 +11,7 @@ export interface AssetItemRecord {
purchase_date: string; purchase_date: string;
warranty_end_date: string | null; warranty_end_date: string | null;
status: AssetItemStatus; status: AssetItemStatus;
purchase_order_id: string | null;
created_at: string; created_at: string;
} }
@@ -19,6 +20,7 @@ export interface CreateAssetItemInput {
model: string; model: string;
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate?: Date; warrantyEndDate?: Date;
purchaseOrderId?: string | null;
} }
export type ValidationResult = export type ValidationResult =
@@ -48,12 +50,24 @@ const createAssetItemSchema = z.object({
model: nonEmptyString("model is required and must be a non-empty string"), model: nonEmptyString("model is required and must be a non-empty string"),
purchaseDate: dateString("purchaseDate is required and must be a valid date string"), purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), 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 * Validates raw (untyped) asset-item-creation input. serialNumber/model
* must be non-empty strings, purchaseDate must be a valid date string, * must be non-empty strings, purchaseDate must be a valid date string,
* warrantyEndDate is optional but must be a valid date string if present. * 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 * status is not accepted here — new assets always start "in_stock" (same
* pattern as Request always starting "Draft"), later states are set via * pattern as Request always starting "Draft"), later states are set via
* other operations, not at creation. * other operations, not at creation.
@@ -63,6 +77,7 @@ export function validateCreateAssetItemInput(input: {
model?: unknown; model?: unknown;
purchaseDate?: unknown; purchaseDate?: unknown;
warrantyEndDate?: unknown; warrantyEndDate?: unknown;
purchaseOrderId?: unknown;
}): ValidationResult { }): ValidationResult {
const result = createAssetItemSchema.safeParse(input); const result = createAssetItemSchema.safeParse(input);
if (!result.success) { if (!result.success) {
@@ -79,6 +94,7 @@ function toRecord(row: {
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate: Date | null; warrantyEndDate: Date | null;
status: AssetItemStatus; status: AssetItemStatus;
purchaseOrderId: string | null;
createdAt: Date; createdAt: Date;
}): AssetItemRecord { }): AssetItemRecord {
return { return {
@@ -88,6 +104,7 @@ function toRecord(row: {
purchase_date: row.purchaseDate.toISOString(), purchase_date: row.purchaseDate.toISOString(),
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status, status: row.status,
purchase_order_id: row.purchaseOrderId,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
}; };
} }
@@ -102,7 +119,10 @@ export type CreateAssetItemResult =
* caught here and translated into the same `{ok:false,error}` shape as * caught here and translated into the same `{ok:false,error}` shape as
* input validation failures, rather than letting a raw Prisma error leak * input validation failures, rather than letting a raw Prisma error leak
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003 * 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( export async function createAssetItem(
input: CreateAssetItemInput input: CreateAssetItemInput
@@ -114,6 +134,7 @@ export async function createAssetItem(
model: input.model, model: input.model,
purchaseDate: input.purchaseDate, purchaseDate: input.purchaseDate,
warrantyEndDate: input.warrantyEndDate ?? null, warrantyEndDate: input.warrantyEndDate ?? null,
purchaseOrderId: input.purchaseOrderId ?? null,
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
@@ -121,6 +142,9 @@ export async function createAssetItem(
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { ok: false, error: "serialNumber already exists" }; 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; throw error;
} }
} }
+97 -2
View File
@@ -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: "[email protected]",
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 () => { beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. // 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.assetItem.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
// Default to a logged-in session — the tests below exercise business // Default to a logged-in session — the tests below exercise business
// logic, not auth gating (that's covered by the "auth gating" describe // logic, not auth gating (that's covered by the "auth gating" describe
// block further down). // block further down).
@@ -111,6 +143,69 @@ describe("POST /api/assets", () => {
expect(body.error).toMatch(/warrantyEndDate/i); 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 () => { it("rejects a duplicate serialNumber", async () => {
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" })); 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" })); const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));