feat: add Area and InventoryScan Prisma models + migrations

Office-move inventory kiem ke feature. See docs/asset-license-management.md InventoryScan section for full business rationale (Area vs Seat, why not AssetItem, asset tag/serial nullable).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
toanhoang
2026-08-19 22:06:52 -04:00
co-authored by Claude Sonnet 5
parent e30489297c
commit 3078d9e8c5
4 changed files with 139 additions and 0 deletions
+95
View File
@@ -74,6 +74,11 @@ model Employee {
// 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")
@@ -280,6 +285,8 @@ model AssetCategory {
// 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")
}
@@ -361,6 +368,8 @@ model AssetModel {
// 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")
}
@@ -473,3 +482,89 @@ model MaintenanceRecord {
@@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)
@@map("inventory_scans")
}