feat: add MaintenanceRecord Prisma model + migration

New entity tracking repair/warranty/preventive maintenance jobs for an
AssetItem. Reuses Vendor (nullable) for the repair location, no cost
field (mirrors PurchaseOrder). "At most 1 open record per AssetItem"
invariant left to the application layer, mirroring Assignment.
This commit is contained in:
toanhoang
2026-08-04 05:25:22 -04:00
parent d557694f77
commit 5089bd6dfc
2 changed files with 100 additions and 21 deletions
@@ -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;
+77 -21
View File
@@ -111,20 +111,25 @@ enum AssetItemStatus {
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId` // `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
// to avoid two sources of truth that could drift apart. // to avoid two sources of truth that could drift apart.
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")
modelId String @map("model_id") @db.Uuid modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id]) model AssetModel @relation(fields: [modelId], references: [id])
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 purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) 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)
// Back-relation for Assignment.assetItemId — an AssetItem can have // Back-relation for Assignment.assetItemId — an AssetItem can have
// multiple Assignment rows over its lifetime (historical + at most one // multiple Assignment rows over its lifetime (historical + at most one
// active, see Assignment model's comment), not built until this task. // 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") @@map("asset_items")
} }
@@ -184,16 +189,20 @@ model License {
// Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every // 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. // vendor record has a known VN tax code or extra notes at creation time.
model Vendor { model Vendor {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
contactEmail String @map("contact_email") contactEmail String @map("contact_email")
contactPhone String @map("contact_phone") contactPhone String @map("contact_phone")
address String address String
taxCode String? @map("tax_code") taxCode String? @map("tax_code")
notes String? notes String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
licenses License[] licenses License[]
purchaseOrders PurchaseOrder[] 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") @@map("vendors")
} }
@@ -417,3 +426,50 @@ model User {
@@map("users") @@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")
}