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() { } /> } /> } /> + } /> } />