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 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-22 14:04:00 +05:00
parent 78862296c4
commit f99949559c
16 changed files with 506 additions and 2 deletions

View File

@@ -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 ###

View File

@@ -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(

View File

@@ -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)

View File

@@ -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]

View File

@@ -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)

View File

@@ -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(