From 8ac95c974b45be72d2e2d270dc793132b342426a Mon Sep 17 00:00:00 2001 From: toanhoang Date: Fri, 21 Aug 2026 08:59:18 -0400 Subject: [PATCH] Add ExpectedAsset CSV import + bot search/pick for pre-known inventory Lets the operator upload a CSV of already-known assets (asset tag, model, serial, old area/assignee) before an office-move walk, via a new session-authed /inventory-scans/import page. The Telegram bot gains a "tim trong list" search step at the asset-tag prompt: picking a match pre-fills tag/serial and offers one-tap "giu nguyen" buttons for the old area/assignee, then marks that row matched once the scan is created. Follows the same-day decision to turn off local OCR (qwen2.5vl via Ollama) for this feature -- see CLAUDE.md and the ITAM/Ollama vault notes for the accuracy/speed numbers behind that call. Deviates from repo convention same as the InventoryScan work above: no Vitest coverage (manual smoke test only, via simulated webhook calls), hand-written migration, not through the ba->pm->feature-implementer-> test-runner->code-reviewer pipeline. tsc/lint are clean. --- CLAUDE.md | 1 + .../migration.sql | 19 ++ prisma/schema.prisma | 34 +++ src/app/api/bot/expected-assets/route.ts | 18 ++ src/app/api/bot/inventory-scans/route.ts | 2 + src/app/api/expected-assets/import/route.ts | 34 +++ src/app/inventory-scans/import/page.tsx | 84 ++++++ src/components/nav.tsx | 1 + src/lib/expected-assets.ts | 244 ++++++++++++++++++ src/lib/inventory-scans.ts | 60 +++-- 10 files changed, 480 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20260821194252_add_expected_asset/migration.sql create mode 100644 src/app/api/bot/expected-assets/route.ts create mode 100644 src/app/api/expected-assets/import/route.ts create mode 100644 src/app/inventory-scans/import/page.tsx create mode 100644 src/lib/expected-assets.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8ee9f90..4c1b37c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state - `MaintenanceRecord` xong + code-reviewer gate PASS (2026-08-04): entity mới track sửa chữa/bảo hành cho `AssetItem` (spec BA+stakeholder, xem `docs/asset-license-management.md`) — `type` (enum `repair`/`warranty`/`preventive`), `vendorId` (tái dùng `Vendor`, nullable), không track `cost`. Mutator đầu tiên ghi `AssetItem.status` ngoài `Assignment`: mở record → auto `status=repair` (chặn 400 nếu `AssetItem` đang `retired` hoặc có `Assignment` active hoặc đã có record khác đang mở); đóng record (`PATCH`, set `endDate`+`resolutionNotes`) nhận `targetStatus` bắt buộc, giới hạn chỉ `in_stock`/`retired` (không cho `assigned`/`repair` — tránh tạo `AssetItem.status=assigned` mồ côi không có `Assignment` tương ứng, fix sau code-reviewer MEDIUM finding). Code-reviewer tìm 1 gap HIGH (guard `updateMany` ban đầu dùng `status: {not:"retired"}` thay vì pin đúng `status:"in_stock"` như `Assignment`, cho phép race mở 2 record cùng lúc / đụng độ với `Assignment`) — đã fix, giờ mirror đúng pattern atomic guard của `createAssignment()`. CONFIRMED qua test-runner độc lập: 327/327 test (chạy lại 3 lần liên tiếp), tsc/lint sạch. - Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (`tests/maintenance-records.test.ts`), gây `PrismaClientKnownRequestError: Foreign key constraint violated` không ổn định ở `beforeEach` của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: `process.env.DATABASE_URL` bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của `src/lib/prisma.ts` singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: `tests/setup.ts` giờ dùng `vi.mock("@/lib/prisma", async () => {...})` — mỗi file test tự tạo DB tạm riêng + 1 `PrismaClient` riêng (qua option `datasourceUrl`, không đụng `process.env` toàn cục nữa) — không còn state nào chia sẻ giữa các file. `fileParallelism: false` đã bỏ khỏi `vitest.config.ts` (về default `true`). - **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình. +- **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: " ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd. - **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy). ## Việc còn dở diff --git a/prisma/migrations/20260821194252_add_expected_asset/migration.sql b/prisma/migrations/20260821194252_add_expected_asset/migration.sql new file mode 100644 index 0000000..2a27556 --- /dev/null +++ b/prisma/migrations/20260821194252_add_expected_asset/migration.sql @@ -0,0 +1,19 @@ +-- Hand-written (same deviation as the other 2026-08-20 migrations in this +-- feature -- see CLAUDE.md "Việc còn dở"), not `prisma migrate dev`. +CREATE TABLE "expected_assets" ( + "id" UUID NOT NULL, + "asset_tag" TEXT, + "model_text" TEXT NOT NULL, + "serial_number" TEXT, + "old_area_text" TEXT, + "old_assignee_text" TEXT, + "matched_scan_id" UUID, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "expected_assets_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "expected_assets_matched_scan_id_key" ON "expected_assets"("matched_scan_id"); + +ALTER TABLE "expected_assets" ADD CONSTRAINT "expected_assets_matched_scan_id_fkey" + FOREIGN KEY ("matched_scan_id") REFERENCES "inventory_scans"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 095618f..8eece34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -565,6 +565,40 @@ model InventoryScan { 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") +} diff --git a/src/app/api/bot/expected-assets/route.ts b/src/app/api/bot/expected-assets/route.ts new file mode 100644 index 0000000..d21b487 --- /dev/null +++ b/src/app/api/bot/expected-assets/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { requireBotAuth } from "@/lib/bot-auth"; +import { searchExpectedAssets } from "@/lib/expected-assets"; + +// Bot-facing search for the "🔍 Tìm trong list" picker (see +// src/lib/expected-assets.ts's searchExpectedAssets() for the matching +// rule). ?q= is optional -- an empty/missing query returns the most +// recently imported unmatched rows, which is what the bot shows first +// before the operator types anything to narrow it down. +export async function GET(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? undefined; + const expectedAssets = await searchExpectedAssets(q); + return NextResponse.json({ expectedAssets }); +} diff --git a/src/app/api/bot/inventory-scans/route.ts b/src/app/api/bot/inventory-scans/route.ts index ccce32b..5ec81d4 100644 --- a/src/app/api/bot/inventory-scans/route.ts +++ b/src/app/api/bot/inventory-scans/route.ts @@ -30,6 +30,7 @@ export async function POST(request: Request): Promise { photoFileId, telegramChatId, telegramMessageId, + expectedAssetId, } = body as Record; const validation = validateCreateInventoryScanInput({ @@ -43,6 +44,7 @@ export async function POST(request: Request): Promise { photoFileId, telegramChatId, telegramMessageId, + expectedAssetId, }); if (!validation.ok) { return NextResponse.json({ error: validation.error }, { status: 400 }); diff --git a/src/app/api/expected-assets/import/route.ts b/src/app/api/expected-assets/import/route.ts new file mode 100644 index 0000000..f04bd74 --- /dev/null +++ b/src/app/api/expected-assets/import/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { importExpectedAssets, parseExpectedAssetsCsv } from "@/lib/expected-assets"; + +// Session-authed CSV upload (see src/app/inventory-scans/import/page.tsx) -- +// the pre-move "here's what we already know" list, uploaded once before an +// office-move walk starts. Not bot-facing: this is prep work the operator +// does at a laptop before heading out, not something the Telegram flow +// itself triggers. Request body is the raw CSV text, not JSON/multipart -- +// the upload page reads the File client-side (File.text()) and posts the +// text directly, same as this repo's other "small file, no need for real +// multipart handling" routes. +export async function POST(request: Request): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const text = await request.text(); + if (!text || text.trim() === "") { + return NextResponse.json({ error: "Request body must be non-empty CSV text" }, { status: 400 }); + } + + const parsed = parseExpectedAssetsCsv(text); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + if (parsed.rows.length === 0) { + return NextResponse.json({ error: "No data rows found after the header" }, { status: 400 }); + } + + const result = await importExpectedAssets(parsed.rows); + return NextResponse.json({ imported: result.count }, { status: 201 }); +} diff --git a/src/app/inventory-scans/import/page.tsx b/src/app/inventory-scans/import/page.tsx new file mode 100644 index 0000000..80e9870 --- /dev/null +++ b/src/app/inventory-scans/import/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +// Pre-move CSV upload -- see src/lib/expected-assets.ts's +// parseExpectedAssetsCsv() for the accepted column names and +// prisma/schema.prisma's ExpectedAsset comment for the "why" (2026-08-21 +// decision: OCR wasn't reliable/fast enough for ~300-400 assets, but +// re-typing a list that already exists in Excel is still wasted effort -- +// this feeds the bot's "🔍 Tìm trong list" picker instead). Plain +// client-side File.text() + raw-text POST, no multipart/FormData -- these +// files are small (hundreds of rows) and this repo has no existing +// multipart-upload plumbing to reuse. +export default function ImportExpectedAssetsPage() { + const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle"); + const [message, setMessage] = useState(null); + + async function handleFileChange(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; // allow re-selecting the same file name after an error + if (!file) return; + + setStatus("uploading"); + setMessage(null); + try { + const text = await file.text(); + const res = await fetch("/api/expected-assets/import", { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: text, + }); + const body = await res.json(); + if (!res.ok) { + setStatus("error"); + setMessage(body.error ?? "Import thất bại"); + return; + } + setStatus("done"); + setMessage(`Đã import ${body.imported} dòng.`); + } catch { + setStatus("error"); + setMessage("Không đọc được file -- kiểm tra lại định dạng CSV."); + } + } + + return ( +
+

Import danh sách asset đã có (CSV)

+

+ Upload file CSV liệt kê asset đã biết từ trước (asset tag, model, khu vực/người giữ cũ). + Trong lúc đi kiểm kê, bot Telegram sẽ có nút "🔍 Tìm trong list" để chọn từ đây + thay vì gõ tay, và tự gợi ý khu vực/người giữ cũ. +

+

+ Cột nhận diện theo tên (không phân biệt hoa/thường, thứ tự tuỳ ý): asset tag,{" "} + model, serial, area (khu vực cũ),{" "} + assignee (người giữ cũ). Dòng đầu tiên phải là header. +

+ +
+ + +
+ + {message ? ( +

+ {message} +

+ ) : null} +
+ ); +} diff --git a/src/components/nav.tsx b/src/components/nav.tsx index 5fffc41..92377e9 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -23,6 +23,7 @@ const ENTITY_LINKS = [ { href: "/assignments", label: "Assignments" }, { href: "/maintenance-records", label: "Maintenance Records" }, { href: "/inventory-scans", label: "Inventory Scans (Move)" }, + { href: "/inventory-scans/import", label: "Import Expected Assets" }, ] as const; export function Nav() { diff --git a/src/lib/expected-assets.ts b/src/lib/expected-assets.ts new file mode 100644 index 0000000..996d2a5 --- /dev/null +++ b/src/lib/expected-assets.ts @@ -0,0 +1,244 @@ +// ExpectedAsset — a row from a pre-move Excel/CSV export of known assets, +// used as a lookup/staging list for the bot's "🔍 Tìm trong list" picker +// during the office-move walk (see prisma/schema.prisma's ExpectedAsset +// model comment for the full "why" and the 2026-08-21 decision this +// follows). Two write paths: importExpectedAssets() (CSV upload, session +// route) and markExpectedAssetMatched() (bot picks one while creating a +// scan) -- same list/search read path (searchExpectedAssets()) for both +// the bot's picker and any future review UI. +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export interface ExpectedAssetRecord { + id: string; + asset_tag: string | null; + model_text: string; + serial_number: string | null; + old_area_text: string | null; + old_assignee_text: string | null; + matched_scan_id: string | null; + created_at: string; +} + +export interface ImportExpectedAssetRow { + assetTag: string | null; + modelText: string; + serialNumber: string | null; + oldAreaText: string | null; + oldAssigneeText: string | null; +} + +function toRecord(row: { + id: string; + assetTag: string | null; + modelText: string; + serialNumber: string | null; + oldAreaText: string | null; + oldAssigneeText: string | null; + matchedScanId: string | null; + createdAt: Date; +}): ExpectedAssetRecord { + return { + id: row.id, + asset_tag: row.assetTag, + model_text: row.modelText, + serial_number: row.serialNumber, + old_area_text: row.oldAreaText, + old_assignee_text: row.oldAssigneeText, + matched_scan_id: row.matchedScanId, + created_at: row.createdAt.toISOString(), + }; +} + +// Header names accepted per logical field, matched case/space/underscore +// -insensitively (see normalizeHeader()) -- covers plausible English and +// unaccented-Vietnamese exports without requiring the user to rename +// columns to match this app's exact field names. +const HEADER_ALIASES: Record = { + assetTag: ["assettag", "asset tag", "tag", "matag", "ma tag", "ma so"], + modelText: ["model", "modeltext", "model text", "model name", "thietbi", "ten model"], + serialNumber: ["serial", "serialnumber", "serial number", "sn", "serial no", "so serial"], + oldAreaText: ["area", "oldarea", "old area", "location", "khuvuc", "khu vuc", "vitri", "vi tri"], + oldAssigneeText: [ + "assignee", + "owner", + "oldassignee", + "old assignee", + "nguoigiu", + "nguoi giu", + "employee", + "nhanvien", + "nhan vien", + ], +}; + +function normalizeHeader(header: string): string { + return header.trim().toLowerCase().replace(/[_\-]+/g, " ").replace(/\s+/g, " "); +} + +// Minimal RFC4180-ish CSV line splitter -- handles quoted fields (incl. +// embedded commas/escaped double-quotes) without pulling in a dependency +// for what is, for this app's actual import files, a simple flat table. +function splitCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (inQuotes) { + if (char === '"') { + if (line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += char; + } + } else if (char === '"' && current === "") { + // Only treat a quote as the start of a quoted field when it's the + // very first character of the field -- otherwise a stray/legitimate + // quote mid-value (e.g. a 24" monitor size) would wrongly swallow + // the rest of the line as quoted content instead of being kept as a + // literal character. + inQuotes = true; + } else if (char === ",") { + fields.push(current); + current = ""; + } else { + current += char; + } + } + fields.push(current); + return fields.map((f) => f.trim()); +} + +export type ParseCsvResult = + | { ok: true; rows: ImportExpectedAssetRow[] } + | { ok: false; error: string }; + +/** + * Parses a CSV export into rows ready for importExpectedAssets(). Header + * row is required; column order doesn't matter (matched by name via + * HEADER_ALIASES). modelText is the only field this app's schema requires + * NOT NULL, but a row missing it is not rejected outright -- defaults to + * "" rather than failing the whole import over one bad row, consistent + * with this feature's existing "don't block the walk" precedent (see + * InventoryScan.assetTag's schema comment). Fully blank rows are skipped. + */ +export function parseExpectedAssetsCsv(text: string): ParseCsvResult { + const lines = text.split(/\r\n|\r|\n/).filter((line) => line.trim() !== ""); + if (lines.length === 0) { + return { ok: false, error: "CSV file is empty" }; + } + + const headerCells = splitCsvLine(lines[0]).map(normalizeHeader); + const columnIndex: Partial> = {}; + for (const [field, aliases] of Object.entries(HEADER_ALIASES) as [ + keyof ImportExpectedAssetRow, + string[], + ][]) { + const idx = headerCells.findIndex((h) => aliases.includes(h)); + if (idx !== -1) { + columnIndex[field] = idx; + } + } + if (columnIndex.assetTag === undefined && columnIndex.modelText === undefined) { + return { + ok: false, + error: + "Could not find an asset tag or model column in the header row -- recognized names: " + + Object.values(HEADER_ALIASES).flat().join(", "), + }; + } + + const rows: ImportExpectedAssetRow[] = []; + for (const line of lines.slice(1)) { + const cells = splitCsvLine(line); + const get = (field: keyof ImportExpectedAssetRow): string | null => { + const idx = columnIndex[field]; + if (idx === undefined) return null; + const value = (cells[idx] ?? "").trim(); + return value === "" ? null : value; + }; + const row: ImportExpectedAssetRow = { + assetTag: get("assetTag"), + modelText: get("modelText") ?? "", + serialNumber: get("serialNumber"), + oldAreaText: get("oldAreaText"), + oldAssigneeText: get("oldAssigneeText"), + }; + if (!row.assetTag && !row.modelText && !row.serialNumber) { + continue; // fully blank row + } + rows.push(row); + } + + return { ok: true, rows }; +} + +export async function importExpectedAssets( + rows: ImportExpectedAssetRow[] +): Promise<{ count: number }> { + const result = await prisma.expectedAsset.createMany({ + data: rows.map((row) => ({ + assetTag: row.assetTag, + modelText: row.modelText, + serialNumber: row.serialNumber, + oldAreaText: row.oldAreaText, + oldAssigneeText: row.oldAssigneeText, + })), + }); + return { count: result.count }; +} + +/** + * Unmatched (matchedScanId IS NULL) rows, optionally filtered by a + * case-insensitive substring match against assetTag/modelText/ + * oldAssigneeText -- this is what the bot's "🔍 Tìm trong list" search + * step calls. Capped at 20 results; the bot renders these as inline + * keyboard buttons, so more than that is unusable anyway. + */ +export async function searchExpectedAssets(query?: string): Promise { + const where: Prisma.ExpectedAssetWhereInput = { matchedScanId: null }; + if (query && query.trim() !== "") { + const q = query.trim(); + where.OR = [ + { assetTag: { contains: q, mode: "insensitive" } }, + { modelText: { contains: q, mode: "insensitive" } }, + { oldAssigneeText: { contains: q, mode: "insensitive" } }, + ]; + } + const rows = await prisma.expectedAsset.findMany({ + where, + orderBy: [{ createdAt: "desc" }], + take: 20, + }); + return rows.map(toRecord); +} + +export async function getExpectedAsset(id: string): Promise { + const row = await prisma.expectedAsset.findUnique({ where: { id } }); + return row ? toRecord(row) : null; +} + +export type MarkMatchedResult = { ok: true } | { ok: false; error: string }; + +export async function markExpectedAssetMatched( + id: string, + scanId: string +): Promise { + try { + await prisma.expectedAsset.update({ + where: { id }, + data: { matchedScanId: scanId }, + }); + return { ok: true }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + return { ok: false, error: "ExpectedAsset not found" }; + } + throw error; + } +} diff --git a/src/lib/inventory-scans.ts b/src/lib/inventory-scans.ts index 3d391ec..a290033 100644 --- a/src/lib/inventory-scans.ts +++ b/src/lib/inventory-scans.ts @@ -37,6 +37,11 @@ export interface CreateInventoryScanInput { photoFileId?: string; telegramChatId?: string; telegramMessageId?: number; + // Set when the bot's "🔍 Tìm trong list" picker was used -- see + // src/lib/expected-assets.ts. Marks that ExpectedAsset row matched to + // this scan in the same transaction as the create, so it stops showing + // up in future searches. + expectedAssetId?: string; } export type ValidationResult = @@ -60,6 +65,7 @@ const createInventoryScanSchema = z.object({ .number({ invalid_type_error: "telegramMessageId must be a number" }) .int() .optional(), + expectedAssetId: uuidField("expectedAssetId must be a valid UUID string").optional(), }); export function validateCreateInventoryScanInput(input: { @@ -73,6 +79,7 @@ export function validateCreateInventoryScanInput(input: { photoFileId?: unknown; telegramChatId?: unknown; telegramMessageId?: unknown; + expectedAssetId?: unknown; }): ValidationResult { const result = createInventoryScanSchema.safeParse(input); if (!result.success) { @@ -164,25 +171,44 @@ export async function createInventoryScan( input: CreateInventoryScanInput ): Promise { try { - const created = await prisma.inventoryScan.create({ - data: { - categoryId: input.categoryId, - modelId: input.modelId, - areaId: input.areaId ?? null, - assignedEmployeeId: input.assignedEmployeeId ?? null, - assetTag: input.assetTag ?? null, - serialNumber: input.serialNumber ?? null, - specs: (input.specs ?? {}) as Prisma.InputJsonValue, - photoFileId: input.photoFileId ?? null, - telegramChatId: input.telegramChatId ?? null, - telegramMessageId: input.telegramMessageId ?? null, - }, - include: includeForRecord, - }); + const data = { + categoryId: input.categoryId, + modelId: input.modelId, + areaId: input.areaId ?? null, + assignedEmployeeId: input.assignedEmployeeId ?? null, + assetTag: input.assetTag ?? null, + serialNumber: input.serialNumber ?? null, + specs: (input.specs ?? {}) as Prisma.InputJsonValue, + photoFileId: input.photoFileId ?? null, + telegramChatId: input.telegramChatId ?? null, + telegramMessageId: input.telegramMessageId ?? null, + }; + + // When the bot's "🔍 Tìm trong list" picker was used, marking the + // ExpectedAsset row matched has to land in the same transaction as the + // create -- otherwise a crash between the two calls would silently + // leave that row unmatched and re-offer it in a later search, letting + // the same physical item get logged twice. + const created = input.expectedAssetId + ? await prisma.$transaction(async (tx) => { + const scan = await tx.inventoryScan.create({ data, include: includeForRecord }); + await tx.expectedAsset.update({ + where: { id: input.expectedAssetId }, + data: { matchedScanId: scan.id }, + }); + return scan; + }) + : await prisma.inventoryScan.create({ data, include: includeForRecord }); + return { ok: true, value: toRecord(created) }; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { - return { ok: false, error: "categoryId/modelId/areaId/assignedEmployeeId must reference existing rows" }; + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2003") { + return { ok: false, error: "categoryId/modelId/areaId/assignedEmployeeId must reference existing rows" }; + } + if (error.code === "P2025") { + return { ok: false, error: "expectedAssetId does not reference an existing row" }; + } } throw error; }