Add ExpectedAsset CSV import + bot search/pick for pre-known inventory
CI / test (push) Failing after 8s

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.
This commit is contained in:
toanhoang
2026-08-21 08:59:18 -04:00
parent b4e2f6c83e
commit 8ac95c974b
10 changed files with 480 additions and 17 deletions
+1
View File
@@ -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: <khu vực/người giữ cũ>" ở 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ở
@@ -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;
+34
View File
@@ -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")
}
+18
View File
@@ -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<NextResponse> {
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 });
}
+2
View File
@@ -30,6 +30,7 @@ export async function POST(request: Request): Promise<NextResponse> {
photoFileId,
telegramChatId,
telegramMessageId,
expectedAssetId,
} = body as Record<string, unknown>;
const validation = validateCreateInventoryScanInput({
@@ -43,6 +44,7 @@ export async function POST(request: Request): Promise<NextResponse> {
photoFileId,
telegramChatId,
telegramMessageId,
expectedAssetId,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
@@ -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<NextResponse> {
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 });
}
+84
View File
@@ -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<string | null>(null);
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
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 (
<main className="mx-auto max-w-2xl space-y-4 p-6">
<h1 className="text-xl font-semibold">Import danh sách asset đã có (CSV)</h1>
<p className="text-sm text-muted-foreground">
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 &quot;🔍 Tìm trong list&quot; để chọn từ đây
thay vì gõ tay, và tự gợi ý khu vực/người giữ cũ.
</p>
<p className="text-sm text-muted-foreground">
Cột nhận diện theo tên (không phân biệt hoa/thường, thứ tự tuỳ ý): <code>asset tag</code>,{" "}
<code>model</code>, <code>serial</code>, <code>area</code> (khu vực cũ),{" "}
<code>assignee</code> (người giữ cũ). Dòng đầu tiên phải là header.
</p>
<div>
<input
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
disabled={status === "uploading"}
className="hidden"
id="csv-input"
/>
<Button asChild>
<label htmlFor="csv-input" className="cursor-pointer">
{status === "uploading" ? "Đang import..." : "Chọn file CSV"}
</label>
</Button>
</div>
{message ? (
<p className={status === "error" ? "text-sm text-destructive" : "text-sm text-foreground"}>
{message}
</p>
) : null}
</main>
);
}
+1
View File
@@ -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() {
+244
View File
@@ -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<keyof ImportExpectedAssetRow, string[]> = {
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<Record<keyof ImportExpectedAssetRow, number>> = {};
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<ExpectedAssetRecord[]> {
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<ExpectedAssetRecord | null> {
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<MarkMatchedResult> {
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;
}
}
+43 -17
View File
@@ -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<CreateInventoryScanResult> {
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;
}