Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb52f473a5 | ||
|
|
a6d7c7b302 |
@@ -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;
|
||||||
+25
-16
@@ -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,18 +174,20 @@ 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
|
||||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||||
orderDate DateTime @map("order_date") @db.Timestamptz(6)
|
orderDate DateTime @map("order_date") @db.Timestamptz(6)
|
||||||
expectedDate DateTime @map("expected_date") @db.Timestamptz(6)
|
expectedDate DateTime @map("expected_date") @db.Timestamptz(6)
|
||||||
receivedAt DateTime? @map("received_at") @db.Timestamptz(6)
|
receivedAt DateTime? @map("received_at") @db.Timestamptz(6)
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user