CI / test (push) Failing after 8s
AssetModel gains an optional warrantyMonths (standard vendor warranty length). createAssetItem() now defaults warrantyEndDate to purchaseDate + warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3) whenever the caller omits warrantyEndDate -- still overridable per item for batches with extended/different warranty terms. /asset-models UI gets an input for the new field; /assets needs no change since it already omits warrantyEndDate from the payload when left blank. Hand-written migration + manual smoke test (tsx script, not Vitest), same deviation as the other 2026-08-2x work in this branch -- see CLAUDE.md. tsc/lint clean.
615 lines
34 KiB
Plaintext
615 lines
34 KiB
Plaintext
// 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 4 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.
|
|
enum AssetItemStatus {
|
|
in_stock
|
|
assigned
|
|
repair
|
|
retired
|
|
}
|
|
|
|
// 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")
|
|
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[]
|
|
// 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[]
|
|
|
|
@@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")
|
|
}
|