diff --git a/prisma/migrations/20260820150000_add_area_and_inventory_scan/migration.sql b/prisma/migrations/20260820150000_add_area_and_inventory_scan/migration.sql new file mode 100644 index 0000000..5269953 --- /dev/null +++ b/prisma/migrations/20260820150000_add_area_and_inventory_scan/migration.sql @@ -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; diff --git a/prisma/migrations/20260820180000_add_asset_tag/migration.sql b/prisma/migrations/20260820180000_add_asset_tag/migration.sql new file mode 100644 index 0000000..f265775 --- /dev/null +++ b/prisma/migrations/20260820180000_add_asset_tag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "inventory_scans" ADD COLUMN "asset_tag" TEXT; diff --git a/prisma/migrations/20260820190000_add_serial_number/migration.sql b/prisma/migrations/20260820190000_add_serial_number/migration.sql new file mode 100644 index 0000000..d0bd557 --- /dev/null +++ b/prisma/migrations/20260820190000_add_serial_number/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "inventory_scans" ADD COLUMN "serial_number" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94aa239..095618f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +}