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:
co-authored by
Claude Sonnet 5
parent
e30489297c
commit
3078d9e8c5
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "areas" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"team" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "areas_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "areas_name_key" ON "areas"("name");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "inventory_scans" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"category_id" UUID NOT NULL,
|
||||||
|
"model_id" UUID NOT NULL,
|
||||||
|
"area_id" UUID,
|
||||||
|
"assigned_employee_id" UUID,
|
||||||
|
"specs" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"photo_path" TEXT,
|
||||||
|
"telegram_chat_id" TEXT,
|
||||||
|
"telegram_message_id" INTEGER,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "inventory_scans_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_area_id_fkey" FOREIGN KEY ("area_id") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_assigned_employee_id_fkey" FOREIGN KEY ("assigned_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "inventory_scans" ADD COLUMN "asset_tag" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "inventory_scans" ADD COLUMN "serial_number" TEXT;
|
||||||
@@ -74,6 +74,11 @@ model Employee {
|
|||||||
// Assignments (assets/licenses) over time, including several active ones
|
// Assignments (assets/licenses) over time, including several active ones
|
||||||
// at once (e.g. a laptop + a license seat simultaneously).
|
// at once (e.g. a laptop + a license seat simultaneously).
|
||||||
assignments Assignment[]
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("employees")
|
@@map("employees")
|
||||||
@@ -280,6 +285,8 @@ model AssetCategory {
|
|||||||
// not expressible as a Prisma-level constraint), but the relation itself
|
// not expressible as a Prisma-level constraint), but the relation itself
|
||||||
// is not restricted to high_value categories at the schema level.
|
// is not restricted to high_value categories at the schema level.
|
||||||
assetModels AssetModel[]
|
assetModels AssetModel[]
|
||||||
|
// Back-relation for InventoryScan.categoryId — see InventoryScan's comment.
|
||||||
|
inventoryScans InventoryScan[]
|
||||||
|
|
||||||
@@map("asset_categories")
|
@@map("asset_categories")
|
||||||
}
|
}
|
||||||
@@ -361,6 +368,8 @@ model AssetModel {
|
|||||||
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
||||||
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
||||||
assetItems AssetItem[]
|
assetItems AssetItem[]
|
||||||
|
// Back-relation for InventoryScan.modelId — see InventoryScan's comment.
|
||||||
|
inventoryScans InventoryScan[]
|
||||||
|
|
||||||
@@map("asset_models")
|
@@map("asset_models")
|
||||||
}
|
}
|
||||||
@@ -473,3 +482,89 @@ model MaintenanceRecord {
|
|||||||
|
|
||||||
@@map("maintenance_records")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user