// Prisma schema — source of truth for the DB schema going forward, // replacing the hand-written `CREATE TABLE IF NOT EXISTS` migration that // used to live in src/lib/db.ts. Only the Request entity (Draft state only) // exists so far — see ../processes/asset-license-management.md for the full // data model / state machine this will grow into. generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // Fixed, closed set of 2 values tied to the domain (asset vs license // request) — a native Postgres enum is the idiomatic Prisma mapping here. // This replaces the old `type TEXT NOT NULL CHECK (type IN ('asset', // 'license'))` column with `type "RequestType" NOT NULL` backed by a // `CREATE TYPE "RequestType" AS ENUM ('asset', 'license')`. Same validation // guarantee (only these two values are ever storable), different mechanism. // See feature-implementer report for the enum-vs-TEXT+CHECK trade-off. enum RequestType { asset license } model Request { id String @id @default(uuid()) @db.Uuid // Was a free-text String with no referential integrity — retrofitted to a // real FK once Employee records existed to point to (see Employee model // above). No production data existed at the time of this change (`requests` // table was empty), so this was a plain column-type migration, not a // backfill. employeeId String @map("employee_id") @db.Uuid employee Employee @relation(fields: [employeeId], references: [id]) type RequestType reason String // Only the 'Draft' state is implemented so far. Deliberately kept as a // plain string (not an enum) until more states exist — see // src/lib/requests.ts's RequestStatus type. status String @default("Draft") // Nullable FK to PurchaseOrder — see docs/asset-license-management.md's // "## PurchaseOrder (gộp Receipt)" Traceability bullet. Semantically the // DEMAND side, unlike AssetItem.purchaseOrderId/License.purchaseOrderId // (supply-side intake): this records "which approved Request got grouped // into this PO", not "which PO did this item come in through". Shape is // identical to those two though — nullable FK, set at creation time only, // no update endpoint for this field yet, same P2003-on-insert handling. 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("requests") } // Employee directory — see docs/asset-license-management.md's "Data model // (sơ bộ)" table (id, tên, phòng ban, manager). `department` is deliberately // a plain String for now, not a separate Department entity — that's a // possible future normalization, not built yet. `managerId` is a nullable // self-relation: top-level employees (no manager) have `managerId = null`. model Employee { id String @id @default(uuid()) @db.Uuid name String department String managerId String? @map("manager_id") @db.Uuid manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) reports Employee[] @relation("EmployeeManager") requests Request[] // Back-relation for PurchaseOrder.receivedById — an Employee can be the // receiver on multiple POs over time. purchaseOrdersReceived PurchaseOrder[] // Back-relation for Assignment.employeeId — an Employee can hold multiple // Assignments (assets/licenses) over time, including several active ones // at once (e.g. a laptop + a license seat simultaneously). assignments Assignment[] // Back-relation for InventoryScan.assignedEmployeeId — see InventoryScan's // comment. An Employee can have multiple scanned items (workstation + // monitor + controller, etc.) attributed to them from the office-move // inventory walk. inventoryScans InventoryScan[] createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("employees") } // Fixed, closed set of values tied to the domain (see docs/ // asset-license-management.md's "Data model (sơ bộ)" table) — native // Postgres enum, same rationale as RequestType above. `lost` added // 2026-08-25 (ALTER TYPE ... ADD VALUE, not a table -- see the // 2026-08-24/25 discussion in CLAUDE.md on why this stays an enum: the // values are read directly by business logic, e.g. createAssignment()'s // `status !== "in_stock"` gate, not just display labels). `assigned` // renamed to `in_use` same day (ALTER TYPE ... RENAME VALUE) -- "assigned" // implied a specific person always holds it, but createAssignment() only // requires in_stock -> the enum value itself, and ~158 real-imported items // have this status with no Assignment row at all (shared/unowned // equipment) -- "in_use" doesn't carry that false implication. enum AssetItemStatus { in_stock in_use repair retired lost } // 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 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. // `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02 // (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu" // bullet) — same precedent as Request.employeeId/License.vendorId (string // -> FK once the referenced entity existed). No production `AssetItem` data // existed at the time of this change, so this was a plain column-type // migration, not a backfill. Required (not nullable) — every AssetItem must // belong to a real AssetModel. AssetItem deliberately does NOT carry its own // `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") // Company-assigned physical tag (e.g. "DANLAP001", printed on a sticker // on the device) -- 2026-08-24, added when bulk-importing the real // ServiceNow ITAM export (see docs/asset-license-management.md). Was // only on InventoryScan before (the office-move walk feature); this is // the same concept but on the actual asset record, not a move-day scan. // Nullable (a manually-entered new purchase may not have one yet) but // unique when present -- the real export had 613/613 unique, non-null // tags, same integrity guarantee as serialNumber. assetTag String? @unique @map("asset_tag") modelId String @map("model_id") @db.Uuid model AssetModel @relation(fields: [modelId], references: [id]) // Nullable as of 2026-08-24 (was NOT NULL) -- bulk-importing the real // ITAM export, some legacy rows have no known purchase date at all (no // "Order received" value in the source). Manual /assets create form // still requires it (a NEW purchase should always have a known date); // this only relaxes the DB/import path for historical data. Also means // AssetModel.warrantyMonths auto-fill (see createAssetItem()) can't // compute anything for these rows -- warrantyEndDate stays null too. purchaseDate DateTime? @map("purchase_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date status AssetItemStatus @default(in_stock) // Purchase cost, in `costCurrency`'s units -- added 2026-08-24 alongside // externalPoNumber for the real ITAM export (very sparse there: 8/613 // rows had a nonzero cost). Decimal, not Float, for exact money math (no // binary-float rounding drift) -- Decimal(14,2) comfortably covers VND // amounts in the millions with room to spare. cost Decimal? @db.Decimal(14, 2) costCurrency String? @map("cost_currency") // Reuses the Area entity built for InventoryScan/the office-move feature // (2026-08-25) rather than a free-text location string -- same "share // the lookup entity across tables" precedent floated for Department. // Nullable: real-world coverage is thin (the ITAM export's own location // data was nearly useless -- "Physical location" was the literal string // "DAN" for all 613 rows, no per-item detail; only "Stockroom" carried // any signal, and only for the ~71 in_stock items -- an in_use item is // with a person, not sitting in a stockroom bin, so it has no area here // by design, not by data gap). areaId String? @map("area_id") @db.Uuid area Area? @relation(fields: [areaId], references: [id], onDelete: SetNull) purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) // Free-text PO number from an external system (e.g. the real ITAM // export's "PO number" column) -- deliberately NOT the same thing as // purchaseOrderId above, which is a real FK into this app's own // PurchaseOrder table. External exports' PO numbers are messy free text // ("Not available", "Pending", or an actual number) with no matching // PurchaseOrder row to link to here, so this just preserves the raw // value for reference instead of forcing a fake relation. externalPoNumber String? @map("external_po_number") 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[] // 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") } // Fixed, closed set of 2 values tied to the domain (see docs/ // asset-license-management.md's "Data model (sơ bộ)" table) — native // Postgres enum, same rationale as RequestType/AssetItemStatus above. enum LicenseType { perpetual subscription } // Software license inventory — see docs/asset-license-management.md's "Data // model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, // loại). `vendorId` was a free-text `vendor` String until the "## Vendor // (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId // (string -> FK once Employee existed). No production `License` data // existed at the time of this change, so this was a plain column-type // migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0 // at creation), not a computed value — it will only ever be set to 0 here // since Assignment (the thing that would consume seats) doesn't exist yet. // `expiryDate` is nullable: perpetual licenses never expire, only // subscription licenses usually have one. `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 license come in through"). Nullable because not every License // goes through a formal PO. Mirrors AssetItem.purchaseOrderId's just-merged // pattern exactly — same nullable-FK shape, set at creation time only, no // update endpoint for this field yet. model License { id String @id @default(uuid()) @db.Uuid name String vendorId String @map("vendor_id") @db.Uuid vendor Vendor @relation(fields: [vendorId], references: [id]) seatsTotal Int @map("seats_total") seatsUsed Int @default(0) @map("seats_used") expiryDate DateTime? @map("expiry_date") @db.Date licenseType LicenseType @map("license_type") 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.licenseId — a License can have multiple // Assignment rows (one per seat handed out, plus historical returns), not // built until this task. assignments Assignment[] @@map("licenses") } // Purchasing counterparty — see docs/asset-license-management.md's "## Vendor // (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer // (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought // from (e.g. "FPT Trading"), a different concept that isn't always the same // company. `licenses` is the back-relation from the License.vendor -> // License.vendorId retrofit (same doc section). `purchaseOrders` is the // back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp // 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[] // 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") } // Purchasing document — see docs/asset-license-management.md's "## // PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what // would otherwise be a separate `Receipt` entity: receiving is just part of // this row's own state (`receivedAt`/`receivedBy`), not a separate table to // reconcile against. No `status` field — status is inferred from linked // `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid // two sources of truth. No cost/price fields — stakeholder said explicitly // PurchaseOrder doesn't need to track money, only "which asset/license // belongs to which PO". Receiving is all-or-nothing per PO (no partial // receipt tracking) — a deliberate simplification, see doc for the // stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both // nullable and must be co-null/co-set together (both-null = not yet // 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). // `assetItems` is the back-relation from the AssetItem.purchaseOrderId // retrofit (supply-side traceability, same doc section). `licenses` is the // back-relation from the License.purchaseOrderId retrofit (same doc // section, same rationale). `requests` is the back-relation from the // Request.purchaseOrderId retrofit — the DEMAND side ("which approved // Request got grouped into this PO"), as opposed to assetItems/licenses // which are the SUPPLY side (intake traceability) — see Request model's // comment above for the distinction. 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) assetItems AssetItem[] licenses License[] requests Request[] @@map("purchase_orders") } // Fixed, closed set of 2 values tied to the domain (see docs/ // asset-license-management.md's "## Phân loại tài sản: High-value vs // Low-value/Consumable" section) — native Postgres enum, same rationale as // RequestType/AssetItemStatus/LicenseType above. Determines downstream // rules for entities that will attach to a category later (not built in // this task): AssetModel.categoryId must point to a `high_value` category, // ConsumableItem.categoryId must point to a `low_value` category. enum AssetCategoryValueGroup { high_value low_value } // Asset classification catalog — see docs/asset-license-management.md's // "## Phân loại tài sản" section and the `AssetCategory` row in the "Data // model (sơ bộ)" table. Standalone entity, no FKs (same starting shape as // Vendor before it grew relations) — `AssetModel` and // `AssetCategoryAttributeDef` will attach to this via `categoryId` in // later tasks (not this one). IT admin defines the concrete categories // (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value // set. model AssetCategory { id String @id @default(uuid()) @db.Uuid name String valueGroup AssetCategoryValueGroup @map("value_group") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) attributeDefs AssetCategoryAttributeDef[] // Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)" // in docs/asset-license-management.md. Only categories with // valueGroup=high_value are ever actually referenced by an AssetModel (a // business rule enforced in src/lib/asset-models.ts's createAssetModel(), // not expressible as a Prisma-level constraint), but the relation itself // is not restricted to high_value categories at the schema level. assetModels AssetModel[] // Back-relation for InventoryScan.categoryId — see InventoryScan's comment. inventoryScans InventoryScan[] @@map("asset_categories") } // Fixed, closed set of 4 values — a DEV-defined technical set (which Zod // validator to build dynamically for AssetModel.specs at write time: string // -> z.string(), number -> z.number(), boolean -> z.boolean(), date -> // z.coerce.date()). Deliberately a different kind of "fixed set" than // AssetCategoryValueGroup above: that one is a closed business classification // IT admins never extend; this one is a closed set of *primitive types* the // app's dynamic-attribute machinery knows how to handle. Still modeled as a // native Postgres enum per the same codebase convention (RequestType / // AssetItemStatus / LicenseType / AssetCategoryValueGroup), not a String + // hand-rolled check. enum AssetAttributeDataType { string number boolean date } // Per-category dynamic attribute definitions — see docs/ // asset-license-management.md's "Cập nhật 2026-08-02 (3): thêm // AssetCategoryAttributeDef" section and the `AssetCategoryAttributeDef` row // in the "Data model (sơ bộ)" table. Defines the *shape* of attributes a // category cares about (e.g. category "Laptop" -> key `ram_gb`, label // "RAM (GB)", dataType number); the actual *values* live in // AssetModel.specs (JSONB), not built in this task. Each AssetCategory owns // its own independent set of attribute defs — deliberately NOT a shared // attribute library across categories (stakeholder explicitly rejected that // design to keep things simple at current scale), so the same `key` string // existing under two different categoryId rows is normal, not a collision — // only unique *within* a category, hence the composite unique below (not a // plain @unique on key alone). `sortOrder` is the display order on the // dynamic form, no uniqueness constraint of its own (ties/gaps are fine, // it's just a sort key). model AssetCategoryAttributeDef { id String @id @default(uuid()) @db.Uuid categoryId String @map("category_id") @db.Uuid category AssetCategory @relation(fields: [categoryId], references: [id]) key String label String dataType AssetAttributeDataType @map("data_type") required Boolean sortOrder Int @map("sort_order") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@unique([categoryId, key]) @@map("asset_category_attribute_defs") } // Sits between AssetCategory and AssetItem — see docs/ // asset-license-management.md's "Cập nhật 2026-08-02 (2)" section and the // `AssetModel` row in the "Data model (sơ bộ)" table. 1 AssetModel has many // AssetItem serials (AssetItem.modelId FK, not built in this task). // `manufacturer`/`name` are deliberately two separate String fields (not // merged into one), per the doc, to allow filtering/reporting by // manufacturer alone (e.g. "Dell") without string-parsing a combined field. // `categoryId` is a required FK to AssetCategory — the DB layer doesn't // enforce that the referenced category is `high_value` (Prisma/Postgres FK // constraints can't express that), that business rule is checked in // src/lib/asset-models.ts's createAssetModel() via an explicit lookup // before the create, per the doc's "Ràng buộc dữ liệu" section. `specs` is // JSONB, defaulting to an empty object — the real per-category dynamic // attribute *values* (whose *shape* is defined by // AssetCategoryAttributeDef rows for this model's category). This task // deliberately does NOT validate specs against the category's attribute // defs (loose z.record(z.unknown()) validation only, see // src/lib/asset-models.ts) — that dynamic cross-validation is a separate // follow-up task per the doc's "Cập nhật 2026-08-02 (3)" section. model AssetModel { id String @id @default(uuid()) @db.Uuid categoryId String @map("category_id") @db.Uuid category AssetCategory @relation(fields: [categoryId], references: [id]) manufacturer String name String specs Json @default("{}") // Standard manufacturer/vendor warranty length for this model, in months // (e.g. Dell Latitude 5420 -> 36). Nullable -- not every model has a known // standard term. Used only as a DEFAULT when creating an AssetItem: see // src/lib/assets.ts's createAssetItem() -- warrantyEndDate = purchaseDate // + warrantyMonths, computed server-side only when the caller doesn't // supply warrantyEndDate explicitly (2026-08-21 decision: a specific // purchase batch's actual warranty can differ from the model's standard // term -- e.g. an extended-warranty deal -- so this must stay an // overridable suggestion, not a hard-locked computed column on AssetItem). warrantyMonths Int? @map("warranty_months") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Back-relation for the AssetItem.model -> AssetItem.modelId retrofit — // see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md. assetItems AssetItem[] // Back-relation for InventoryScan.modelId — see InventoryScan's comment. inventoryScans InventoryScan[] @@map("asset_models") } // Assigns an AssetItem (high-value hardware) or a License seat to a specific // Employee — see docs/asset-license-management.md's "## Assignment (chốt // 2026-08-02)" section and the `Assignment` row in the "Data model (sơ bộ)" // table. Exactly one of assetItemId/licenseId must be set (XOR) — enforced // at the Zod layer (src/lib/assignments.ts's `.refine()`), NOT a DB // constraint, per the same convention as PurchaseOrder's // receivedAt/receivedById co-nullability check: Prisma schema can't express // an XOR between two nullable FKs natively. Both FKs stay plain nullable // columns here for that reason. `assignedAt` defaults to now() but is a // real stored column (not derived from createdAt) because a future // "transfer" operation (out of scope for this task, see doc) will create a // new Assignment row whose assignedAt should reflect the transfer moment, // not necessarily row-insert time. `returnedAt` null means still held by // the employee — this is also what "active assignment" means throughout // the codebase (see the uniqueness rule below). `signedAt` is deliberately // just a nullable timestamp marking "handover paperwork signed" — NO file // upload, stakeholder explicitly decided against storing a scanned file at // this stage (see doc). No `@@unique` on assetItemId here for "only one // active assignment at a time" — a plain unique constraint would also // forbid a *second, already-returned* historical Assignment for the same // AssetItem (e.g. item reassigned after being returned), which is valid and // expected. The "at most one ACTIVE (returnedAt IS NULL) assignment per // AssetItem" invariant is instead enforced in application code // (src/lib/assignments.ts's createAssignment(), inside the same transaction // that mutates AssetItem.status/License.seatsUsed) — a partial unique index // (`WHERE returned_at IS NULL`) would express this at the DB level too, but // that's raw SQL outside a Prisma-expressible `@@unique`, so deliberately // not added here; see feature-implementer report for this trade-off. model Assignment { id String @id @default(uuid()) @db.Uuid employeeId String @map("employee_id") @db.Uuid employee Employee @relation(fields: [employeeId], references: [id]) assetItemId String? @map("asset_item_id") @db.Uuid assetItem AssetItem? @relation(fields: [assetItemId], references: [id], onDelete: Restrict) licenseId String? @map("license_id") @db.Uuid license License? @relation(fields: [licenseId], references: [id], onDelete: Restrict) assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6) returnedAt DateTime? @map("returned_at") @db.Timestamptz(6) signedAt DateTime? @map("signed_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("assignments") } // Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration. // Auth.js v5 Credentials provider + JWT session strategy is used, so this is // intentionally just a plain user table — no `Account`/`Session`/ // `VerificationToken` models, those exist for OAuth account linking / DB // session strategy, neither of which applies here. No `role` field either: // permission model is flat for now (any logged-in user can do anything). model User { id String @id @default(uuid()) @db.Uuid email String @unique passwordHash String @map("password_hash") name String createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@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") } // Physical location tag for the office-move inventory walk — see the // "chia theo area" decision in the inventory-scans project chat. Deliberately // NOT the pre-existing high-value AssetItem's target ("Seat" tied 1:1 to a // Employee/team) because that data (the new office seating chart) doesn't // exist yet at the time this was built — `team` is nullable and gets filled // in once the seating chart is finalized, no schema change needed then. // Covers both per-seat locations ("Seat 12") and shared-room locations // ("Phòng họp A", "Kho tầng 2") with the same flat entity — splitting seat // vs room into separate types was considered and rejected as premature; add // a `type` column later if filtering by kind actually becomes necessary. // `name` is globally unique so the bot's "reuse existing or create new" flow // (grows the list as the walk progresses, avoids duplicate near-identical // area names from free-typing) has something to match against. model Area { id String @id @default(uuid()) @db.Uuid name String @unique team String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) inventoryScans InventoryScan[] // Back-relation for AssetItem.areaId -- see that field's comment. assetItems AssetItem[] @@map("areas") } // One physical item logged during the office-move inventory walk (bot flow: // Category -> Model -> [Workstation only: cpu/ram/gpu] -> Area -> assignee -> // optional photo). Deliberately NOT a row in AssetItem: AssetItem requires a // unique serialNumber and a purchaseDate, neither of which is known/at-hand // during a fast walk-and-tap pass — forcing either would either block the // walk or invent fake placeholder data, which this codebase avoids elsewhere // (see AssetItem/License/PurchaseOrder comments' "no fake data" precedent). // InventoryScan is the lightweight capture; promoting a scan to a real // AssetItem (with a real serial once read off the physical tag) is a manual // follow-up step done later in the review UI, not built in this task. // `categoryId`/`modelId` reuse the existing AssetCategory/AssetModel catalog // (seeded with Controller/Màn hình/Workstation for this task's scope) rather // than inventing a parallel classification. `areaId` and `assignedEmployeeId` // are both nullable — plenty of scanned items are shared equipment in a room // (no single assignee) or not yet placed in a known Area. `specs` is JSONB, // same shape/rationale as AssetModel.specs — holds the Workstation-only // cpu/ram/gpu answers; empty object for categories that don't need them. // `telegramChatId`/`telegramMessageId` are kept for traceability back to the // original bot conversation (debugging a bad scan, or re-fetching the photo // from Telegram if `photoPath` is ever lost) — nullable since a scan created // directly from the review UI later won't have either. model InventoryScan { id String @id @default(uuid()) @db.Uuid categoryId String @map("category_id") @db.Uuid category AssetCategory @relation(fields: [categoryId], references: [id]) modelId String @map("model_id") @db.Uuid model AssetModel @relation(fields: [modelId], references: [id]) areaId String? @map("area_id") @db.Uuid area Area? @relation(fields: [areaId], references: [id]) assignedEmployeeId String? @map("assigned_employee_id") @db.Uuid assignedEmployee Employee? @relation(fields: [assignedEmployeeId], references: [id]) // Physical asset tag/barcode label already stuck on the device (see the // "Scan QR/barcode đã có sẵn trên thiết bị" scope decision). Free text, // typed by the bot operator for now — not yet an actual barcode-image // decode. Deliberately NOT unique/required at this table's level (unlike // AssetItem.serialNumber): plenty of scanned items have no tag at all // (Controllers, Monitors bought without one), and enforcing uniqueness // here would block the walk on a typo'd re-entry instead of just letting // review-time cleanup catch it, per this model's "don't block the walk" // precedent (see AssetItem-vs-InventoryScan comment above). assetTag String? @map("asset_tag") // Manufacturer serial number, read off the same physical tag as // assetTag — same "don't block the walk" rationale (nullable, not // unique at this table's level; AssetItem.serialNumber is the unique // one, enforced only once a scan is promoted). Filled in either by // typing, or later by whoever reviews the scan's photo (bot operator or // Claude) reading it off the image — no OCR automation built yet, this // column is just where that value lands once read. serialNumber String? @map("serial_number") specs Json @default("{}") // Stores the Telegram file_id (not a local disk path, despite the DB // column name) — photos live on Telegram's servers, re-fetched on demand // via GET /api/inventory-scans/[id]/photo rather than duplicated onto // this app's own disk. Avoids a shared volume mount between this // container and the separate inventory-capture bot service. photoFileId String? @map("photo_path") telegramChatId String? @map("telegram_chat_id") telegramMessageId Int? @map("telegram_message_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Back-reference for ExpectedAsset.matchedScanId -- set once someone // picks this scan's pre-uploaded row via the bot's "🔍 Tìm trong list" // step. No @relation attribute needed here; the FK lives on the other // side. expectedAsset ExpectedAsset? @@map("inventory_scans") } // A row from a pre-move Excel/CSV export of known assets (asset tag, // model, old area/owner), uploaded via POST /api/expected-assets/import // before an office-move walk starts (2026-08-21 decision -- team decided // plain manual entry was faster/more reliable than local OCR for ~300-400 // assets, see docs/asset-license-management.md, but typing in a KNOWN // asset tag from scratch during the walk is still wasted effort when it's // sitting right here). Purely a lookup/staging table for the bot's "🔍 Tìm // trong list" picker -- modelText/oldAreaText/oldAssigneeText are free // text straight from the import file, deliberately NOT FK'd into // AssetModel/Area/Employee: import-file text won't reliably match this // app's catalog IDs/spelling, and forcing that match at import time would // block the whole import on one typo instead of just being a soft // "here's the old value" suggestion during the walk. Marked matched (via // matchedScanId) once someone actually picks it while creating a scan, so // it stops showing up in future searches. model ExpectedAsset { id String @id @default(uuid()) @db.Uuid assetTag String? @map("asset_tag") modelText String @map("model_text") serialNumber String? @map("serial_number") oldAreaText String? @map("old_area_text") oldAssigneeText String? @map("old_assignee_text") matchedScanId String? @unique @map("matched_scan_id") @db.Uuid matchedScan InventoryScan? @relation(fields: [matchedScanId], references: [id]) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("expected_assets") }