feat: Phase 5 — part counter update, replacement, replacement history
All checks were successful
Deploy to Production / deploy (push) Successful in 1m5s

This commit is contained in:
Johngreen
2026-02-10 13:55:49 +09:00
parent 581c845f54
commit 035d62f0e0
6 changed files with 698 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.database.config import get_db
from src.database.models import Machine, EquipmentPart, PartCounter
from src.database.models import Machine, EquipmentPart, PartCounter, PartReplacementLog
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
@@ -37,6 +37,15 @@ class PartUpdate(BaseModel):
counter_source: Optional[str] = None
class CounterUpdate(BaseModel):
value: float
class ReplaceRequest(BaseModel):
reason: Optional[str] = None
notes: Optional[str] = None
def _format_ts(val) -> Optional[str]:
if val is None:
return None
@@ -297,3 +306,154 @@ async def delete_part(
await db.commit()
return {"status": "success", "message": "부품이 비활성화되었습니다."}
@router.put("/api/{tenant_id}/parts/{part_id}/counter")
async def update_counter(
body: CounterUpdate,
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(EquipmentPart)
.options(selectinload(EquipmentPart.counter))
.where(
EquipmentPart.id == part_id,
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.is_active == True,
)
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
counter = part.counter
if not counter:
raise HTTPException(status_code=404, detail="카운터를 찾을 수 없습니다.")
if body.value < 0:
raise HTTPException(status_code=400, detail="카운터 값은 0 이상이어야 합니다.")
now = datetime.now(timezone.utc)
counter.current_value = body.value
counter.lifecycle_pct = (
(body.value / float(part.lifecycle_limit)) * 100
if float(part.lifecycle_limit) > 0
else 0
)
counter.last_updated_at = now
counter.version = (counter.version or 0) + 1
await db.commit()
await db.refresh(counter)
return _part_to_dict(part, counter)
@router.post("/api/{tenant_id}/parts/{part_id}/replace")
async def replace_part(
tenant_id: str = Path(...),
part_id: UUID = Path(...),
body: Optional[ReplaceRequest] = None,
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(EquipmentPart)
.options(selectinload(EquipmentPart.counter))
.where(
EquipmentPart.id == part_id,
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.is_active == True,
)
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
counter = part.counter
if not counter:
raise HTTPException(status_code=404, detail="카운터를 찾을 수 없습니다.")
now = datetime.now(timezone.utc)
log = PartReplacementLog(
tenant_id=tenant_id,
equipment_part_id=part.id,
replaced_by=UUID(current_user.user_id),
replaced_at=now,
counter_at_replacement=float(counter.current_value or 0),
lifecycle_pct_at_replacement=float(counter.lifecycle_pct or 0),
reason=body.reason if body else None,
notes=body.notes if body else None,
)
db.add(log)
counter.current_value = 0
counter.lifecycle_pct = 0
counter.last_reset_at = now
counter.last_updated_at = now
counter.version = (counter.version or 0) + 1
part.installed_at = now
await db.commit()
await db.refresh(part)
await db.refresh(counter)
return {
"status": "success",
"message": "부품이 교체되었습니다.",
"part": _part_to_dict(part, counter),
}
def _replacement_to_dict(log: PartReplacementLog) -> dict:
return {
"id": str(log.id),
"equipment_part_id": str(log.equipment_part_id),
"replaced_by": str(log.replaced_by) if log.replaced_by else None,
"replaced_at": _format_ts(log.replaced_at),
"counter_at_replacement": float(log.counter_at_replacement),
"lifecycle_pct_at_replacement": float(log.lifecycle_pct_at_replacement),
"reason": str(log.reason) if log.reason else None,
"notes": str(log.notes) if log.notes else None,
}
@router.get("/api/{tenant_id}/parts/{part_id}/replacements")
async def list_replacements(
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
part_stmt = select(EquipmentPart).where(
EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id
)
part = (await db.execute(part_stmt)).scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
stmt = (
select(PartReplacementLog)
.where(
PartReplacementLog.equipment_part_id == part_id,
PartReplacementLog.tenant_id == tenant_id,
)
.order_by(PartReplacementLog.replaced_at.desc())
)
result = await db.execute(stmt)
logs = result.scalars().all()
return {"replacements": [_replacement_to_dict(log) for log in logs]}