diff --git a/prisma/migrations/20260804092355_add_maintenance_record/migration.sql b/prisma/migrations/20260804092355_add_maintenance_record/migration.sql new file mode 100644 index 0000000..49b5197 --- /dev/null +++ b/prisma/migrations/20260804092355_add_maintenance_record/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "MaintenanceType" AS ENUM ('repair', 'warranty', 'preventive'); + +-- CreateTable +CREATE TABLE "maintenance_records" ( + "id" UUID NOT NULL, + "asset_item_id" UUID NOT NULL, + "type" "MaintenanceType" NOT NULL, + "vendor_id" UUID, + "issue_description" TEXT NOT NULL, + "resolution_notes" TEXT, + "start_date" DATE NOT NULL, + "end_date" DATE, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "maintenance_records_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "maintenance_records" ADD CONSTRAINT "maintenance_records_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "maintenance_records" ADD CONSTRAINT "maintenance_records_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85f75d2..d5b63b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -111,20 +111,25 @@ enum AssetItemStatus { // `categoryId` — category is derived via `modelId -> AssetModel.categoryId` // to avoid two sources of truth that could drift apart. model AssetItem { - id String @id @default(uuid()) @db.Uuid - serialNumber String @unique @map("serial_number") - modelId String @map("model_id") @db.Uuid - model AssetModel @relation(fields: [modelId], references: [id]) - 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) + id String @id @default(uuid()) @db.Uuid + serialNumber String @unique @map("serial_number") + modelId String @map("model_id") @db.Uuid + model AssetModel @relation(fields: [modelId], references: [id]) + 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) // Back-relation for Assignment.assetItemId — an AssetItem can have // multiple Assignment rows over its lifetime (historical + at most one // active, see Assignment model's comment), not built until this task. - assignments Assignment[] + assignments Assignment[] + // Back-relation for MaintenanceRecord.assetItemId — an AssetItem can have + // multiple MaintenanceRecord rows over its lifetime (historical + at most + // one open, see MaintenanceRecord model's comment), not built until this + // task. + maintenanceRecords MaintenanceRecord[] @@map("asset_items") } @@ -184,16 +189,20 @@ model License { // Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every // vendor record has a known VN tax code or extra notes at creation time. model Vendor { - id String @id @default(uuid()) @db.Uuid - name String - contactEmail String @map("contact_email") - contactPhone String @map("contact_phone") - address String - taxCode String? @map("tax_code") - notes String? - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - licenses License[] - purchaseOrders PurchaseOrder[] + id String @id @default(uuid()) @db.Uuid + name String + contactEmail String @map("contact_email") + contactPhone String @map("contact_phone") + address String + taxCode String? @map("tax_code") + notes String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + licenses License[] + purchaseOrders PurchaseOrder[] + // Back-relation for MaintenanceRecord.vendorId — a Vendor can perform + // multiple repairs/warranty jobs over time, same reuse rationale as + // PurchaseOrder.vendorId (see MaintenanceRecord model's comment). + maintenanceRecords MaintenanceRecord[] @@map("vendors") } @@ -417,3 +426,50 @@ model User { @@map("users") } + +// Fixed, closed set of 3 values tied to the domain (see docs/ +// asset-license-management.md's "## MaintenanceRecord (spec BA + stakeholder, +// chốt 2026-08-04)" section) — native Postgres enum, same rationale as +// RequestType/AssetItemStatus/LicenseType/AssetCategoryValueGroup/ +// AssetAttributeDataType above. +enum MaintenanceType { + repair + warranty + preventive +} + +// Records a repair/warranty/preventive-maintenance job for a specific +// AssetItem — see docs/asset-license-management.md's "## MaintenanceRecord +// (spec BA + stakeholder, chốt 2026-08-04)" section. Only applies to +// AssetItem (hardware) — License has no physical-repair concept, so unlike +// Assignment there's no XOR here: assetItemId is the single, always-required +// target. `vendorId` reuses the existing Vendor entity (same "external +// counterparty" concept as PurchaseOrder.vendorId) and is nullable — null +// means an internal repair with no outside vendor involved. No `cost` field, +// per the same precedent as PurchaseOrder (stakeholder explicitly said no +// money tracking there). No separate `status` field either — "open" vs +// "done" is derived from `endDate` (null = still being worked on, set = done), +// same precedent as PurchaseOrder.receivedAt not duplicating state. `endDate +// >= startDate` and "at most one OPEN (endDate IS NULL) MaintenanceRecord per +// AssetItem" are both business invariants enforced at the application layer +// (src/lib/maintenance-records.ts, not built in this task) — NOT DB +// constraints, per the same convention as Assignment's XOR / "at most one +// active" invariant (see Assignment model's comment above for the mirrored +// trade-off). No FK to User for "who logged this" — no existing precedent in +// this codebase ties a business entity to User (User is login-only), see +// doc for this explicit call. +model MaintenanceRecord { + id String @id @default(uuid()) @db.Uuid + assetItemId String @map("asset_item_id") @db.Uuid + assetItem AssetItem @relation(fields: [assetItemId], references: [id]) + type MaintenanceType + vendorId String? @map("vendor_id") @db.Uuid + vendor Vendor? @relation(fields: [vendorId], references: [id]) + issueDescription String @map("issue_description") + resolutionNotes String? @map("resolution_notes") + startDate DateTime @map("start_date") @db.Date + endDate DateTime? @map("end_date") @db.Date + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("maintenance_records") +}