diff --git a/backend/alembic/versions/e7a2c9f1b3d4_add_last_seen_location_to_objects.py b/backend/alembic/versions/e7a2c9f1b3d4_add_last_seen_location_to_objects.py new file mode 100644 index 0000000..9a4ceeb --- /dev/null +++ b/backend/alembic/versions/e7a2c9f1b3d4_add_last_seen_location_to_objects.py @@ -0,0 +1,30 @@ +"""add last_seen_location to objects + +Revision ID: e7a2c9f1b3d4 +Revises: c3bc00a214e0 +Create Date: 2026-07-22 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e7a2c9f1b3d4' +down_revision: Union[str, None] = 'c3bc00a214e0' +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_location', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('objects', 'last_seen_location') + # ### end Alembic commands ### diff --git a/backend/app/models/object.py b/backend/app/models/object.py index 04ee9d8..c15fd6a 100644 --- a/backend/app/models/object.py +++ b/backend/app/models/object.py @@ -28,6 +28,7 @@ class InventoryObject(Base): DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_seen_location: Mapped[str | None] = mapped_column(String(255), 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 2b74498..a86531e 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -59,7 +59,7 @@ async def scan_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID, data: 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) + obj = await objects_service.mark_object_found(db, obj, data.location) return objects_service.build_object_read(obj) diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py index 16c716a..4446df4 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -36,7 +36,7 @@ class ObjectUpdate(BaseModel): class ObjectScan(BaseModel): - box_id: uuid.UUID | None = None + location: str | None = None class ObjectRead(BaseModel): @@ -51,5 +51,6 @@ class ObjectRead(BaseModel): created_at: datetime updated_at: datetime last_seen_at: datetime | None + last_seen_location: str | None qr_code_url: str photos: list[PhotoRead] diff --git a/backend/app/services/objects.py b/backend/app/services/objects.py index 7cba144..2fc4e9e 100644 --- a/backend/app/services/objects.py +++ b/backend/app/services/objects.py @@ -22,6 +22,7 @@ def build_object_read(obj: InventoryObject) -> ObjectRead: created_at=obj.created_at, updated_at=obj.updated_at, last_seen_at=obj.last_seen_at, + last_seen_location=obj.last_seen_location, qr_code_url=object_public_url(obj.id), photos=[build_photo_read(p) for p in obj.photos], ) @@ -64,8 +65,8 @@ 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 +async def mark_object_found(db: AsyncSession, obj: InventoryObject, location: str | None) -> InventoryObject: + obj.last_seen_location = location obj.last_seen_at = datetime.now(timezone.utc) await db.commit() return await get_object(db, obj.id) diff --git a/backend/tests/test_objects.py b/backend/tests/test_objects.py index 13b9b4e..193d803 100644 --- a/backend/tests/test_objects.py +++ b/backend/tests/test_objects.py @@ -47,26 +47,25 @@ 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: +async def test_scan_marks_object_found_with_location(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"] + assert create_res.json()["last_seen_location"] is None scan_res = await client.post( - f"/api/v1/objects/{object_id}/scan", headers=auth_headers, json={"box_id": box_id} + f"/api/v1/objects/{object_id}/scan", headers=auth_headers, json={"location": "каб. 305"} ) assert scan_res.status_code == 200 - assert scan_res.json()["box_id"] == box_id + assert scan_res.json()["last_seen_location"] == "каб. 305" assert scan_res.json()["last_seen_at"] is not None + # Scanning only records where it was found; it does not touch the box assignment. + assert scan_res.json()["box_id"] is 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: diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7201065..7c07243 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -31,6 +31,7 @@ export interface InventoryObject { created_at: string updated_at: string last_seen_at: string | null + last_seen_location: string | null qr_code_url: string photos: Photo[] } @@ -43,7 +44,7 @@ export interface ObjectInput { } export interface ObjectScanInput { - box_id?: string | null + location?: string | null } export interface Box { diff --git a/frontend/src/hooks/useAuditSession.ts b/frontend/src/hooks/useAuditSession.ts index 1e48167..9a82b1b 100644 --- a/frontend/src/hooks/useAuditSession.ts +++ b/frontend/src/hooks/useAuditSession.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react' const SESSION_START_KEY = 'inventory_audit_session_start' -const LAST_BOX_KEY = 'inventory_audit_last_box_id' +const LAST_LOCATION_KEY = 'inventory_audit_last_location' function readSessionStart(): number { const raw = sessionStorage.getItem(SESSION_START_KEY) @@ -12,26 +12,26 @@ function readSessionStart(): number { } /** Tracks the current audit walk: when it started (for the found/not-found split) and the - * last room/box picked, so the next scanned object can default to the same location. */ + * last location typed in, so the next scanned object can default to the same location. */ export function useAuditSession() { const [sessionStart, setSessionStart] = useState(readSessionStart) - const [lastBoxId, setLastBoxIdState] = useState(() => - sessionStorage.getItem(LAST_BOX_KEY), + const [lastLocation, setLastLocationState] = useState(() => + sessionStorage.getItem(LAST_LOCATION_KEY), ) const restart = useCallback(() => { const now = Date.now() sessionStorage.setItem(SESSION_START_KEY, String(now)) - sessionStorage.removeItem(LAST_BOX_KEY) + sessionStorage.removeItem(LAST_LOCATION_KEY) setSessionStart(now) - setLastBoxIdState(null) + setLastLocationState(null) }, []) - const setLastBoxId = useCallback((boxId: string | null) => { - if (boxId) sessionStorage.setItem(LAST_BOX_KEY, boxId) - else sessionStorage.removeItem(LAST_BOX_KEY) - setLastBoxIdState(boxId) + const setLastLocation = useCallback((location: string | null) => { + if (location) sessionStorage.setItem(LAST_LOCATION_KEY, location) + else sessionStorage.removeItem(LAST_LOCATION_KEY) + setLastLocationState(location) }, []) - return { sessionStart, restart, lastBoxId, setLastBoxId } + return { sessionStart, restart, lastLocation, setLastLocation } } diff --git a/frontend/src/hooks/useObjects.ts b/frontend/src/hooks/useObjects.ts index 2004625..8f258f1 100644 --- a/frontend/src/hooks/useObjects.ts +++ b/frontend/src/hooks/useObjects.ts @@ -51,12 +51,9 @@ export function useDeleteObject() { export function useScanObject() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ id, boxId }: { id: string; boxId: string | null }) => - api.post(`/objects/${id}/scan`, { box_id: boxId } satisfies ObjectScanInput), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['objects'] }) - queryClient.invalidateQueries({ queryKey: ['boxes'] }) - }, + mutationFn: ({ id, location }: { id: string; location: string | null }) => + api.post(`/objects/${id}/scan`, { location } satisfies ObjectScanInput), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects'] }), }) } diff --git a/frontend/src/pages/admin/AuditPage.tsx b/frontend/src/pages/admin/AuditPage.tsx index 180e3aa..7e04019 100644 --- a/frontend/src/pages/admin/AuditPage.tsx +++ b/frontend/src/pages/admin/AuditPage.tsx @@ -4,20 +4,19 @@ import { Button } from '../../components/Button' import { PhotoGallery } from '../../components/PhotoGallery' import { QrScanner } from '../../components/QrScanner' import { useAuditSession } from '../../hooks/useAuditSession' -import { useBox, useBoxesList } from '../../hooks/useBoxes' +import { useBox } from '../../hooks/useBoxes' import { useObject, useObjectsList, useScanObject } from '../../hooks/useObjects' import { parseScannedTarget, type ScannedTarget } from '../../lib/qrUrl' const RESCAN_COOLDOWN_MS = 4000 export function AuditPage() { - const { sessionStart, restart, lastBoxId, setLastBoxId } = useAuditSession() + const { sessionStart, restart, lastLocation, setLastLocation } = useAuditSession() const { data: objects } = useObjectsList('') - const { data: boxes } = useBoxesList('') const scanObject = useScanObject() const [overlay, setOverlay] = useState(null) - const [selectedBoxId, setSelectedBoxId] = useState('') + const [locationInput, setLocationInput] = useState('') const [cameraError, setCameraError] = useState(null) const cooldownRef = useRef<{ key: string; at: number } | null>(null) @@ -33,10 +32,10 @@ export function AuditPage() { const cooldown = cooldownRef.current if (cooldown && cooldown.key === key && Date.now() - cooldown.at < RESCAN_COOLDOWN_MS) return - if (target.type === 'object') setSelectedBoxId(lastBoxId ?? '') + if (target.type === 'object') setLocationInput(lastLocation ?? '') setOverlay(target) }, - [lastBoxId], + [lastLocation], ) function closeOverlay() { @@ -46,9 +45,9 @@ export function AuditPage() { async function confirmFound() { if (!overlay || overlay.type !== 'object') return - const boxId = selectedBoxId || null - await scanObject.mutateAsync({ id: overlay.id, boxId }) - setLastBoxId(boxId) + const location = locationInput.trim() || null + await scanObject.mutateAsync({ id: overlay.id, location }) + setLastLocation(location) closeOverlay() } @@ -106,18 +105,14 @@ export function AuditPage() {
- setLocationInput(e.target.value)} + placeholder="например, каб. 305" + autoFocus className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-950" - > - - {boxes?.map((box) => ( - - ))} - + />
@@ -210,7 +205,9 @@ export function AuditPage() { {o.name} · {o.inventory_number} - {o.box && {o.box.name}} + {o.last_seen_location && ( + {o.last_seen_location} + )} ))} {foundObjects.length === 0 && (