Add ExpectedAsset CSV import + bot search/pick for pre-known inventory
CI / test (push) Failing after 8s
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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 "🔍 Tìm trong list" để 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user