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()
|
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_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")
|
box: Mapped["Box | None"] = relationship(back_populates="objects")
|
||||||
photos: Mapped[list["ObjectPhoto"]] = relationship(
|
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)
|
obj = await objects_service.get_object(db, object_id)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
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)
|
return objects_service.build_object_read(obj)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ObjectUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ObjectScan(BaseModel):
|
class ObjectScan(BaseModel):
|
||||||
box_id: uuid.UUID | None = None
|
location: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ObjectRead(BaseModel):
|
class ObjectRead(BaseModel):
|
||||||
@@ -51,5 +51,6 @@ class ObjectRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
last_seen_at: datetime | None
|
last_seen_at: datetime | None
|
||||||
|
last_seen_location: str | None
|
||||||
qr_code_url: str
|
qr_code_url: str
|
||||||
photos: list[PhotoRead]
|
photos: list[PhotoRead]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def build_object_read(obj: InventoryObject) -> ObjectRead:
|
|||||||
created_at=obj.created_at,
|
created_at=obj.created_at,
|
||||||
updated_at=obj.updated_at,
|
updated_at=obj.updated_at,
|
||||||
last_seen_at=obj.last_seen_at,
|
last_seen_at=obj.last_seen_at,
|
||||||
|
last_seen_location=obj.last_seen_location,
|
||||||
qr_code_url=object_public_url(obj.id),
|
qr_code_url=object_public_url(obj.id),
|
||||||
photos=[build_photo_read(p) for p in obj.photos],
|
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)
|
return await get_object(db, obj.id)
|
||||||
|
|
||||||
|
|
||||||
async def mark_object_found(db: AsyncSession, obj: InventoryObject, box_id: uuid.UUID | None) -> InventoryObject:
|
async def mark_object_found(db: AsyncSession, obj: InventoryObject, location: str | None) -> InventoryObject:
|
||||||
obj.box_id = box_id
|
obj.last_seen_location = location
|
||||||
obj.last_seen_at = datetime.now(timezone.utc)
|
obj.last_seen_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return await get_object(db, obj.id)
|
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)
|
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]}"
|
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
|
||||||
create_res = await client.post(
|
create_res = await client.post(
|
||||||
"/api/v1/objects", headers=auth_headers, json={"inventory_number": inventory_number, "name": "Scan test"}
|
"/api/v1/objects", headers=auth_headers, json={"inventory_number": inventory_number, "name": "Scan test"}
|
||||||
)
|
)
|
||||||
object_id = create_res.json()["id"]
|
object_id = create_res.json()["id"]
|
||||||
assert create_res.json()["last_seen_at"] is None
|
assert create_res.json()["last_seen_at"] is None
|
||||||
|
assert create_res.json()["last_seen_location"] 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(
|
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.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
|
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/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:
|
async def test_object_qrcode(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface InventoryObject {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
last_seen_at: string | null
|
last_seen_at: string | null
|
||||||
|
last_seen_location: string | null
|
||||||
qr_code_url: string
|
qr_code_url: string
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export interface ObjectInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ObjectScanInput {
|
export interface ObjectScanInput {
|
||||||
box_id?: string | null
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Box {
|
export interface Box {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
const SESSION_START_KEY = 'inventory_audit_session_start'
|
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 {
|
function readSessionStart(): number {
|
||||||
const raw = sessionStorage.getItem(SESSION_START_KEY)
|
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
|
/** 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() {
|
export function useAuditSession() {
|
||||||
const [sessionStart, setSessionStart] = useState<number>(readSessionStart)
|
const [sessionStart, setSessionStart] = useState<number>(readSessionStart)
|
||||||
const [lastBoxId, setLastBoxIdState] = useState<string | null>(() =>
|
const [lastLocation, setLastLocationState] = useState<string | null>(() =>
|
||||||
sessionStorage.getItem(LAST_BOX_KEY),
|
sessionStorage.getItem(LAST_LOCATION_KEY),
|
||||||
)
|
)
|
||||||
|
|
||||||
const restart = useCallback(() => {
|
const restart = useCallback(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
sessionStorage.setItem(SESSION_START_KEY, String(now))
|
sessionStorage.setItem(SESSION_START_KEY, String(now))
|
||||||
sessionStorage.removeItem(LAST_BOX_KEY)
|
sessionStorage.removeItem(LAST_LOCATION_KEY)
|
||||||
setSessionStart(now)
|
setSessionStart(now)
|
||||||
setLastBoxIdState(null)
|
setLastLocationState(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setLastBoxId = useCallback((boxId: string | null) => {
|
const setLastLocation = useCallback((location: string | null) => {
|
||||||
if (boxId) sessionStorage.setItem(LAST_BOX_KEY, boxId)
|
if (location) sessionStorage.setItem(LAST_LOCATION_KEY, location)
|
||||||
else sessionStorage.removeItem(LAST_BOX_KEY)
|
else sessionStorage.removeItem(LAST_LOCATION_KEY)
|
||||||
setLastBoxIdState(boxId)
|
setLastLocationState(location)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { sessionStart, restart, lastBoxId, setLastBoxId }
|
return { sessionStart, restart, lastLocation, setLastLocation }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,9 @@ export function useDeleteObject() {
|
|||||||
export function useScanObject() {
|
export function useScanObject() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, boxId }: { id: string; boxId: string | null }) =>
|
mutationFn: ({ id, location }: { id: string; location: string | null }) =>
|
||||||
api.post<InventoryObject>(`/objects/${id}/scan`, { box_id: boxId } satisfies ObjectScanInput),
|
api.post<InventoryObject>(`/objects/${id}/scan`, { location } satisfies ObjectScanInput),
|
||||||
onSuccess: () => {
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['objects'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['boxes'] })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,19 @@ import { Button } from '../../components/Button'
|
|||||||
import { PhotoGallery } from '../../components/PhotoGallery'
|
import { PhotoGallery } from '../../components/PhotoGallery'
|
||||||
import { QrScanner } from '../../components/QrScanner'
|
import { QrScanner } from '../../components/QrScanner'
|
||||||
import { useAuditSession } from '../../hooks/useAuditSession'
|
import { useAuditSession } from '../../hooks/useAuditSession'
|
||||||
import { useBox, useBoxesList } from '../../hooks/useBoxes'
|
import { useBox } from '../../hooks/useBoxes'
|
||||||
import { useObject, useObjectsList, useScanObject } from '../../hooks/useObjects'
|
import { useObject, useObjectsList, useScanObject } from '../../hooks/useObjects'
|
||||||
import { parseScannedTarget, type ScannedTarget } from '../../lib/qrUrl'
|
import { parseScannedTarget, type ScannedTarget } from '../../lib/qrUrl'
|
||||||
|
|
||||||
const RESCAN_COOLDOWN_MS = 4000
|
const RESCAN_COOLDOWN_MS = 4000
|
||||||
|
|
||||||
export function AuditPage() {
|
export function AuditPage() {
|
||||||
const { sessionStart, restart, lastBoxId, setLastBoxId } = useAuditSession()
|
const { sessionStart, restart, lastLocation, setLastLocation } = useAuditSession()
|
||||||
const { data: objects } = useObjectsList('')
|
const { data: objects } = useObjectsList('')
|
||||||
const { data: boxes } = useBoxesList('')
|
|
||||||
const scanObject = useScanObject()
|
const scanObject = useScanObject()
|
||||||
|
|
||||||
const [overlay, setOverlay] = useState<ScannedTarget | null>(null)
|
const [overlay, setOverlay] = useState<ScannedTarget | null>(null)
|
||||||
const [selectedBoxId, setSelectedBoxId] = useState('')
|
const [locationInput, setLocationInput] = useState('')
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||||
const cooldownRef = useRef<{ key: string; at: number } | null>(null)
|
const cooldownRef = useRef<{ key: string; at: number } | null>(null)
|
||||||
|
|
||||||
@@ -33,10 +32,10 @@ export function AuditPage() {
|
|||||||
const cooldown = cooldownRef.current
|
const cooldown = cooldownRef.current
|
||||||
if (cooldown && cooldown.key === key && Date.now() - cooldown.at < RESCAN_COOLDOWN_MS) return
|
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)
|
setOverlay(target)
|
||||||
},
|
},
|
||||||
[lastBoxId],
|
[lastLocation],
|
||||||
)
|
)
|
||||||
|
|
||||||
function closeOverlay() {
|
function closeOverlay() {
|
||||||
@@ -46,9 +45,9 @@ export function AuditPage() {
|
|||||||
|
|
||||||
async function confirmFound() {
|
async function confirmFound() {
|
||||||
if (!overlay || overlay.type !== 'object') return
|
if (!overlay || overlay.type !== 'object') return
|
||||||
const boxId = selectedBoxId || null
|
const location = locationInput.trim() || null
|
||||||
await scanObject.mutateAsync({ id: overlay.id, boxId })
|
await scanObject.mutateAsync({ id: overlay.id, location })
|
||||||
setLastBoxId(boxId)
|
setLastLocation(location)
|
||||||
closeOverlay()
|
closeOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,18 +105,14 @@ export function AuditPage() {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium">Где нашли?</label>
|
<label className="text-sm font-medium">Где нашли?</label>
|
||||||
<select
|
<input
|
||||||
value={selectedBoxId}
|
type="text"
|
||||||
onChange={(e) => setSelectedBoxId(e.target.value)}
|
value={locationInput}
|
||||||
|
onChange={(e) => 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"
|
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-950"
|
||||||
>
|
/>
|
||||||
<option value="">— без ящика —</option>
|
|
||||||
{boxes?.map((box) => (
|
|
||||||
<option key={box.id} value={box.id}>
|
|
||||||
{box.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -210,7 +205,9 @@ export function AuditPage() {
|
|||||||
<span>
|
<span>
|
||||||
{o.name} <span className="text-slate-500 dark:text-slate-400">· {o.inventory_number}</span>
|
{o.name} <span className="text-slate-500 dark:text-slate-400">· {o.inventory_number}</span>
|
||||||
</span>
|
</span>
|
||||||
{o.box && <span className="shrink-0 text-slate-500 dark:text-slate-400">{o.box.name}</span>}
|
{o.last_seen_location && (
|
||||||
|
<span className="shrink-0 text-slate-500 dark:text-slate-400">{o.last_seen_location}</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{foundObjects.length === 0 && (
|
{foundObjects.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user