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:
@@ -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 ###
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user