From f99949559ce32772ab8b5aac99796d958af58af5 Mon Sep 17 00:00:00 2001 From: jze9 Date: Wed, 22 Jul 2026 14:04:00 +0500 Subject: [PATCH] Add inventory audit mode with camera QR scanning Lets an employee walk the premises, scan each object's QR to confirm it's present and record where it was found (defaulting to the last picked location), and track found/not-found progress for the walk. Scanning a box's QR shows its contents read-only without affecting the found/not-found counts. Co-Authored-By: Claude Sonnet 5 --- ...3bc00a214e0_add_last_seen_at_to_objects.py | 30 +++ backend/app/models/object.py | 1 + backend/app/routers/objects.py | 12 +- backend/app/schemas/object.py | 5 + backend/app/services/objects.py | 9 + backend/tests/test_objects.py | 22 ++ frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/api/types.ts | 5 + frontend/src/components/Layout.tsx | 3 + frontend/src/components/QrScanner.tsx | 91 +++++++ frontend/src/hooks/useAuditSession.ts | 37 +++ frontend/src/hooks/useObjects.ts | 14 +- frontend/src/lib/qrUrl.ts | 23 ++ frontend/src/pages/admin/AuditPage.tsx | 246 ++++++++++++++++++ frontend/src/router.tsx | 2 + 16 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/c3bc00a214e0_add_last_seen_at_to_objects.py create mode 100644 frontend/src/components/QrScanner.tsx create mode 100644 frontend/src/hooks/useAuditSession.ts create mode 100644 frontend/src/lib/qrUrl.ts create mode 100644 frontend/src/pages/admin/AuditPage.tsx diff --git a/backend/alembic/versions/c3bc00a214e0_add_last_seen_at_to_objects.py b/backend/alembic/versions/c3bc00a214e0_add_last_seen_at_to_objects.py new file mode 100644 index 0000000..15bbef8 --- /dev/null +++ b/backend/alembic/versions/c3bc00a214e0_add_last_seen_at_to_objects.py @@ -0,0 +1,30 @@ +"""add last_seen_at to objects + +Revision ID: c3bc00a214e0 +Revises: cdcadb640107 +Create Date: 2026-07-22 08:05:44.372353 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c3bc00a214e0' +down_revision: Union[str, None] = 'cdcadb640107' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('objects', sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('objects', 'last_seen_at') + # ### end Alembic commands ### diff --git a/backend/app/models/object.py b/backend/app/models/object.py index 7977bda..04ee9d8 100644 --- a/backend/app/models/object.py +++ b/backend/app/models/object.py @@ -27,6 +27,7 @@ class InventoryObject(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) box: Mapped["Box | None"] = relationship(back_populates="objects") photos: Mapped[list["ObjectPhoto"]] = relationship( diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 3a3dbe2..2b74498 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, status from sqlalchemy.exc import IntegrityError from app.core.dependencies import CurrentUser, DbSession, get_minio_client -from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate +from app.schemas.object import ObjectCreate, ObjectRead, ObjectScan, ObjectUpdate from app.services import objects as objects_service router = APIRouter(prefix="/api/v1/objects", tags=["objects"]) @@ -53,6 +53,16 @@ async def update_object( return objects_service.build_object_read(obj) +@router.post("/{object_id}/scan", response_model=ObjectRead) +async def scan_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID, data: ObjectScan) -> ObjectRead: + """Mark an object as found during an inventory audit walk, recording where it was seen.""" + obj = await objects_service.get_object(db, object_id) + if obj is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found") + obj = await objects_service.mark_object_found(db, obj, data.box_id) + return objects_service.build_object_read(obj) + + @router.delete("/{object_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID) -> None: obj = await objects_service.get_object(db, object_id) diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py index aa3a832..16c716a 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -35,6 +35,10 @@ class ObjectUpdate(BaseModel): box_id: uuid.UUID | None = None +class ObjectScan(BaseModel): + box_id: uuid.UUID | None = None + + class ObjectRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -46,5 +50,6 @@ class ObjectRead(BaseModel): box: BoxRef | None created_at: datetime updated_at: datetime + last_seen_at: datetime | None qr_code_url: str photos: list[PhotoRead] diff --git a/backend/app/services/objects.py b/backend/app/services/objects.py index 2206fb4..7cba144 100644 --- a/backend/app/services/objects.py +++ b/backend/app/services/objects.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +21,7 @@ def build_object_read(obj: InventoryObject) -> ObjectRead: box=BoxRef.model_validate(obj.box) if obj.box else None, created_at=obj.created_at, updated_at=obj.updated_at, + last_seen_at=obj.last_seen_at, qr_code_url=object_public_url(obj.id), photos=[build_photo_read(p) for p in obj.photos], ) @@ -62,6 +64,13 @@ async def update_object(db: AsyncSession, obj: InventoryObject, data: ObjectUpda return await get_object(db, obj.id) +async def mark_object_found(db: AsyncSession, obj: InventoryObject, box_id: uuid.UUID | None) -> InventoryObject: + obj.box_id = box_id + obj.last_seen_at = datetime.now(timezone.utc) + await db.commit() + return await get_object(db, obj.id) + + async def delete_object(db: AsyncSession, minio_client, obj: InventoryObject) -> None: for photo in list(obj.photos): await delete_photo(db, minio_client, photo) diff --git a/backend/tests/test_objects.py b/backend/tests/test_objects.py index ce1064b..13b9b4e 100644 --- a/backend/tests/test_objects.py +++ b/backend/tests/test_objects.py @@ -47,6 +47,28 @@ async def test_duplicate_inventory_number_conflicts(client: AsyncClient, auth_he await client.delete(f"/api/v1/objects/{first.json()['id']}", headers=auth_headers) +async def test_scan_marks_object_found_and_moves_it(client: AsyncClient, auth_headers: dict[str, str]) -> None: + inventory_number = f"TEST-{uuid.uuid4().hex[:8]}" + create_res = await client.post( + "/api/v1/objects", headers=auth_headers, json={"inventory_number": inventory_number, "name": "Scan test"} + ) + object_id = create_res.json()["id"] + assert create_res.json()["last_seen_at"] is None + + box_res = await client.post("/api/v1/boxes", headers=auth_headers, json={"name": "Audit room"}) + box_id = box_res.json()["id"] + + scan_res = await client.post( + f"/api/v1/objects/{object_id}/scan", headers=auth_headers, json={"box_id": box_id} + ) + assert scan_res.status_code == 200 + assert scan_res.json()["box_id"] == box_id + assert scan_res.json()["last_seen_at"] is not None + + await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers) + await client.delete(f"/api/v1/boxes/{box_id}", headers=auth_headers) + + async def test_object_qrcode(client: AsyncClient, auth_headers: dict[str, str]) -> None: inventory_number = f"TEST-{uuid.uuid4().hex[:8]}" create_res = await client.post( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d92e9a5..090b225 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^5.4.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.101.3", + "jsqr": "^1.4.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.82.0", @@ -1150,6 +1151,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 85d216e..57ae722 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^5.4.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.101.3", + "jsqr": "^1.4.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.82.0", diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 42926a5..7201065 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -30,6 +30,7 @@ export interface InventoryObject { box: BoxRef | null created_at: string updated_at: string + last_seen_at: string | null qr_code_url: string photos: Photo[] } @@ -41,6 +42,10 @@ export interface ObjectInput { box_id?: string | null } +export interface ObjectScanInput { + box_id?: string | null +} + export interface Box { id: string name: string diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index eee4d8a..26f19b4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -22,6 +22,9 @@ export function AdminLayout() { Ящики + + Инвентаризация + + + +
+ {cameraError ? ( +
+

{cameraError}

+ +
+ ) : ( + + )} + + {overlay?.type === 'object' && ( +
+
+ {!scannedObject ? ( +

Загрузка объекта...

+ ) : ( + <> +
+

{scannedObject.name}

+

+ {scannedObject.inventory_number} +

+
+ {scannedObject.description && ( +

{scannedObject.description}

+ )} + {scannedObject.photos.length > 0 && } + +
+ + +
+ +
+ + +
+ + )} +
+
+ )} + + {overlay?.type === 'box' && ( +
+
+ {!viewedBox ? ( +

Загрузка ящика...

+ ) : ( + <> +
+ 📦 +

{viewedBox.name}

+
+ {viewedBox.description && ( +

{viewedBox.description}

+ )} + + {viewedBox.children.length > 0 && ( +
+

Вложенные ящики

+ {viewedBox.children.map((child) => ( + + ))} +
+ )} + +
+

+ Объекты ({viewedBox.objects.length}) +

+ {viewedBox.objects.length === 0 ? ( +

Пусто

+ ) : ( +
+ {viewedBox.objects.map((o) => ( +
+ + {o.name}{' '} + · {o.inventory_number} + + {foundIds.has(o.id) && ✓ найден} +
+ ))} +
+ )} +
+ + + + )} +
+
+ )} +
+ +
+

+ Найдено ({foundObjects.length}) +

+
+ {foundObjects.map((o) => ( + + + {o.name} · {o.inventory_number} + + {o.box && {o.box.name}} + + ))} + {foundObjects.length === 0 && ( +

Пока ничего не найдено

+ )} +
+
+ +
+

+ Не найдено ({notFoundObjects.length}) +

+
+ {notFoundObjects.map((o) => ( + + + {o.name} · {o.inventory_number} + + {o.box && {o.box.name}} + + ))} + {notFoundObjects.length === 0 && ( +

Все объекты найдены

+ )} +
+
+ + ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 1305a03..74d2f11 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -3,6 +3,7 @@ import { useAuth } from './auth/AuthContext' import { RequireAuth } from './auth/RequireAuth' import { AdminLayout } from './components/Layout' import { LoginPage } from './pages/LoginPage' +import { AuditPage } from './pages/admin/AuditPage' import { BoxBrowsePage } from './pages/admin/BoxBrowsePage' import { BoxFormPage } from './pages/admin/BoxFormPage' import { ObjectFormPage } from './pages/admin/ObjectFormPage' @@ -39,6 +40,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } />