Replace box picker with free-text location in audit scan
"Where found" during inventory audit is now a plain text field (e.g. "каб. 305") stored on the object instead of assigning it to a box, since audit walks often use room/office labels that don't map to the box/shelf hierarchy used elsewhere. Box assignment via the regular object form is unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ###
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user