feat: complete 5 GAP requirements — auto counters, inspection alarms, machine specs, responsive CSS
All checks were successful
Deploy to Production / deploy (push) Successful in 1m8s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m8s
- GAP 2+4: auto_time/date counter auto-computation with manual update blocking - GAP 3: auto-generate alarms on inspection completion for failed items - GAP 1: machine spec fields (manufacturer, location, capacity, power, description) - GAP 5: enhanced mobile responsive CSS (768px/480px breakpoints) - Alembic migration for new columns, seed data enriched with specs and date-type parts
This commit is contained in:
@@ -8,7 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.database.config import get_db
|
||||
from src.database.models import Alarm, EquipmentPart, PartCounter, Machine
|
||||
from src.database.models import (
|
||||
Alarm,
|
||||
EquipmentPart,
|
||||
PartCounter,
|
||||
Machine,
|
||||
InspectionSession,
|
||||
InspectionRecord,
|
||||
InspectionTemplate,
|
||||
InspectionTemplateItem,
|
||||
)
|
||||
from src.auth.models import TokenData
|
||||
from src.auth.dependencies import require_auth, verify_tenant_access
|
||||
|
||||
@@ -27,12 +36,19 @@ def _alarm_to_dict(alarm: Alarm) -> dict:
|
||||
return {
|
||||
"id": str(alarm.id),
|
||||
"tenant_id": str(alarm.tenant_id),
|
||||
"equipment_part_id": str(alarm.equipment_part_id),
|
||||
"machine_id": str(alarm.machine_id),
|
||||
"equipment_part_id": str(alarm.equipment_part_id)
|
||||
if alarm.equipment_part_id
|
||||
else None,
|
||||
"machine_id": str(alarm.machine_id) if alarm.machine_id else None,
|
||||
"inspection_session_id": str(alarm.inspection_session_id)
|
||||
if alarm.inspection_session_id
|
||||
else None,
|
||||
"alarm_type": str(alarm.alarm_type),
|
||||
"severity": str(alarm.severity),
|
||||
"message": str(alarm.message),
|
||||
"lifecycle_pct_at_trigger": float(alarm.lifecycle_pct_at_trigger),
|
||||
"lifecycle_pct_at_trigger": float(alarm.lifecycle_pct_at_trigger)
|
||||
if alarm.lifecycle_pct_at_trigger is not None
|
||||
else None,
|
||||
"is_acknowledged": bool(alarm.is_acknowledged),
|
||||
"acknowledged_by": str(alarm.acknowledged_by)
|
||||
if alarm.acknowledged_by
|
||||
@@ -182,3 +198,33 @@ async def generate_alarms_for_part(
|
||||
lifecycle_pct_at_trigger=pct,
|
||||
)
|
||||
db.add(alarm)
|
||||
|
||||
|
||||
async def generate_alarms_for_inspection(
|
||||
db: AsyncSession,
|
||||
tenant_id: str,
|
||||
session: InspectionSession,
|
||||
fail_records: list,
|
||||
template_name: str,
|
||||
):
|
||||
if not fail_records:
|
||||
return
|
||||
|
||||
fail_count = len(fail_records)
|
||||
severity = "critical" if fail_count >= 3 else "warning"
|
||||
|
||||
for record, item_name, spec_info in fail_records:
|
||||
message = (
|
||||
f"검사 '{template_name}'의 '{item_name}' 항목이 불합격입니다 ({spec_info})"
|
||||
)
|
||||
if len(message) > 500:
|
||||
message = message[:497] + "..."
|
||||
|
||||
alarm = Alarm(
|
||||
tenant_id=tenant_id,
|
||||
inspection_session_id=session.id,
|
||||
alarm_type="inspection_fail",
|
||||
severity=severity,
|
||||
message=message,
|
||||
)
|
||||
db.add(alarm)
|
||||
|
||||
@@ -53,6 +53,38 @@ def _format_ts(val) -> Optional[str]:
|
||||
return val.isoformat() if hasattr(val, "isoformat") else str(val)
|
||||
|
||||
|
||||
def _compute_auto_counter(part: EquipmentPart, counter: PartCounter) -> tuple:
|
||||
source = str(part.counter_source or "manual")
|
||||
lt = str(part.lifecycle_type)
|
||||
limit = float(part.lifecycle_limit) if part.lifecycle_limit else 0
|
||||
|
||||
if source == "auto_time" and lt == "hours" and limit > 0:
|
||||
ref = counter.last_reset_at or part.installed_at
|
||||
if ref:
|
||||
now = datetime.now(timezone.utc)
|
||||
if ref.tzinfo is None:
|
||||
from datetime import timezone as tz
|
||||
|
||||
ref = ref.replace(tzinfo=tz.utc)
|
||||
elapsed_hours = (now - ref).total_seconds() / 3600
|
||||
pct = round((elapsed_hours / limit) * 100, 1)
|
||||
return (round(elapsed_hours, 1), pct)
|
||||
|
||||
if lt == "date" and limit > 0:
|
||||
ref = counter.last_reset_at or part.installed_at
|
||||
if ref:
|
||||
now = datetime.now(timezone.utc)
|
||||
if ref.tzinfo is None:
|
||||
from datetime import timezone as tz
|
||||
|
||||
ref = ref.replace(tzinfo=tz.utc)
|
||||
elapsed_days = (now - ref).total_seconds() / 86400
|
||||
pct = round((elapsed_days / limit) * 100, 1)
|
||||
return (round(elapsed_days, 1), pct)
|
||||
|
||||
return (float(counter.current_value or 0), float(counter.lifecycle_pct or 0))
|
||||
|
||||
|
||||
def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> dict:
|
||||
result = {
|
||||
"id": str(p.id),
|
||||
@@ -71,9 +103,10 @@ def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> di
|
||||
"updated_at": _format_ts(p.updated_at),
|
||||
}
|
||||
if counter:
|
||||
cv, pct = _compute_auto_counter(p, counter)
|
||||
result["counter"] = {
|
||||
"current_value": float(counter.current_value or 0),
|
||||
"lifecycle_pct": float(counter.lifecycle_pct or 0),
|
||||
"current_value": cv,
|
||||
"lifecycle_pct": pct,
|
||||
"last_reset_at": _format_ts(counter.last_reset_at),
|
||||
"last_updated_at": _format_ts(counter.last_updated_at),
|
||||
"version": int(counter.version or 0),
|
||||
@@ -337,6 +370,14 @@ async def update_counter(
|
||||
if not counter:
|
||||
raise HTTPException(status_code=404, detail="카운터를 찾을 수 없습니다.")
|
||||
|
||||
source = str(part.counter_source or "manual")
|
||||
lt = str(part.lifecycle_type)
|
||||
if source == "auto_time" or lt == "date":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="자동 계산 부품(auto_time/date)은 수동으로 카운터를 업데이트할 수 없습니다.",
|
||||
)
|
||||
|
||||
if body.value < 0:
|
||||
raise HTTPException(status_code=400, detail="카운터 값은 0 이상이어야 합니다.")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.database.models import (
|
||||
)
|
||||
from src.auth.models import TokenData
|
||||
from src.auth.dependencies import require_auth, verify_tenant_access
|
||||
from src.api.alarms import generate_alarms_for_inspection
|
||||
|
||||
router = APIRouter(prefix="/api/{tenant_id}/inspections", tags=["inspections"])
|
||||
|
||||
@@ -471,11 +472,50 @@ async def complete_inspection(
|
||||
if body and body.notes is not None:
|
||||
session.notes = body.notes
|
||||
|
||||
fail_records_data = []
|
||||
records_with_items_stmt = (
|
||||
select(InspectionRecord)
|
||||
.options(selectinload(InspectionRecord.template_item))
|
||||
.where(
|
||||
InspectionRecord.session_id == inspection_id,
|
||||
InspectionRecord.is_pass == False,
|
||||
)
|
||||
)
|
||||
fail_result = await db.execute(records_with_items_stmt)
|
||||
fail_records = fail_result.scalars().all()
|
||||
|
||||
template_stmt = select(InspectionTemplate).where(
|
||||
InspectionTemplate.id == session.template_id
|
||||
)
|
||||
tmpl = (await db.execute(template_stmt)).scalar_one_or_none()
|
||||
template_name = str(tmpl.name) if tmpl else "알 수 없는 템플릿"
|
||||
|
||||
for r in fail_records:
|
||||
ti = r.template_item
|
||||
item_name = str(ti.name) if ti else "?"
|
||||
spec_parts = []
|
||||
if ti and ti.spec_min is not None:
|
||||
spec_parts.append(f"최소: {ti.spec_min}")
|
||||
if ti and ti.spec_max is not None:
|
||||
spec_parts.append(f"최대: {ti.spec_max}")
|
||||
measured = ""
|
||||
if r.value_numeric is not None:
|
||||
measured = f"측정값: {r.value_numeric}"
|
||||
elif r.value_boolean is not None:
|
||||
measured = f"측정값: {'합격' if r.value_boolean else '불합격'}"
|
||||
spec_info = ", ".join(filter(None, [measured] + spec_parts))
|
||||
fail_records_data.append((r, item_name, spec_info))
|
||||
|
||||
await db.flush()
|
||||
await generate_alarms_for_inspection(
|
||||
db, tenant_id, session, fail_records_data, template_name
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
pass_count = sum(1 for r in session.records if r.is_pass is True)
|
||||
fail_count = sum(1 for r in session.records if r.is_pass is False)
|
||||
fail_count_val = sum(1 for r in session.records if r.is_pass is False)
|
||||
total = len(session.records)
|
||||
|
||||
return {
|
||||
@@ -484,8 +524,8 @@ async def complete_inspection(
|
||||
"summary": {
|
||||
"total": total,
|
||||
"pass_count": pass_count,
|
||||
"fail_count": fail_count,
|
||||
"no_judgment": total - pass_count - fail_count,
|
||||
"fail_count": fail_count_val,
|
||||
"no_judgment": total - pass_count - fail_count_val,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,24 @@ class MachineCreate(BaseModel):
|
||||
name: str
|
||||
equipment_code: str = ""
|
||||
model: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
installation_date: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
rated_capacity: Optional[str] = None
|
||||
power_rating: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class MachineUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
equipment_code: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
installation_date: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
rated_capacity: Optional[str] = None
|
||||
power_rating: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class MachineResponse(BaseModel):
|
||||
@@ -33,6 +45,12 @@ class MachineResponse(BaseModel):
|
||||
name: str
|
||||
equipment_code: str
|
||||
model: Optional[str]
|
||||
manufacturer: Optional[str] = None
|
||||
installation_date: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
rated_capacity: Optional[str] = None
|
||||
power_rating: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
parts_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
@@ -57,6 +75,12 @@ def _machine_to_response(m: Machine, parts_count: int = 0) -> MachineResponse:
|
||||
name=str(m.name),
|
||||
equipment_code=str(m.equipment_code or ""),
|
||||
model=str(m.model) if m.model else None,
|
||||
manufacturer=str(m.manufacturer) if m.manufacturer else None,
|
||||
installation_date=_format_ts(m.installation_date),
|
||||
location=str(m.location) if m.location else None,
|
||||
rated_capacity=str(m.rated_capacity) if m.rated_capacity else None,
|
||||
power_rating=str(m.power_rating) if m.power_rating else None,
|
||||
description=str(m.description) if m.description else None,
|
||||
parts_count=parts_count,
|
||||
created_at=_format_ts(m.created_at),
|
||||
updated_at=_format_ts(m.updated_at),
|
||||
@@ -97,11 +121,26 @@ async def create_machine(
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
|
||||
install_dt = None
|
||||
if body.installation_date:
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
install_dt = dt.fromisoformat(body.installation_date.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
machine = Machine(
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
equipment_code=body.equipment_code,
|
||||
model=body.model,
|
||||
manufacturer=body.manufacturer,
|
||||
installation_date=install_dt,
|
||||
location=body.location,
|
||||
rated_capacity=body.rated_capacity,
|
||||
power_rating=body.power_rating,
|
||||
description=body.description,
|
||||
)
|
||||
db.add(machine)
|
||||
await db.commit()
|
||||
@@ -154,6 +193,12 @@ async def get_machine(
|
||||
name=str(machine.name),
|
||||
equipment_code=str(machine.equipment_code or ""),
|
||||
model=str(machine.model) if machine.model else None,
|
||||
manufacturer=str(machine.manufacturer) if machine.manufacturer else None,
|
||||
installation_date=_format_ts(machine.installation_date),
|
||||
location=str(machine.location) if machine.location else None,
|
||||
rated_capacity=str(machine.rated_capacity) if machine.rated_capacity else None,
|
||||
power_rating=str(machine.power_rating) if machine.power_rating else None,
|
||||
description=str(machine.description) if machine.description else None,
|
||||
parts_count=len(active_parts),
|
||||
parts=parts_data,
|
||||
created_at=_format_ts(machine.created_at),
|
||||
@@ -187,6 +232,25 @@ async def update_machine(
|
||||
machine.equipment_code = body.equipment_code
|
||||
if body.model is not None:
|
||||
machine.model = body.model
|
||||
if body.manufacturer is not None:
|
||||
machine.manufacturer = body.manufacturer
|
||||
if body.installation_date is not None:
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
machine.installation_date = dt.fromisoformat(
|
||||
body.installation_date.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
if body.location is not None:
|
||||
machine.location = body.location
|
||||
if body.rated_capacity is not None:
|
||||
machine.rated_capacity = body.rated_capacity
|
||||
if body.power_rating is not None:
|
||||
machine.power_rating = body.power_rating
|
||||
if body.description is not None:
|
||||
machine.description = body.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(machine)
|
||||
|
||||
@@ -62,6 +62,12 @@ class Machine(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
equipment_code = Column(String(50), default="")
|
||||
model = Column(String(100), nullable=True)
|
||||
manufacturer = Column(String(100), nullable=True)
|
||||
installation_date = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
location = Column(String(200), nullable=True)
|
||||
rated_capacity = Column(String(100), nullable=True)
|
||||
power_rating = Column(String(100), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
|
||||
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -313,13 +319,18 @@ class Alarm(Base):
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
|
||||
equipment_part_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=False
|
||||
UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=True
|
||||
)
|
||||
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=False)
|
||||
alarm_type = Column(String(30), nullable=False) # threshold | critical
|
||||
severity = Column(String(20), nullable=False) # warning | critical
|
||||
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=True)
|
||||
inspection_session_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("inspection_sessions.id"),
|
||||
nullable=True,
|
||||
)
|
||||
alarm_type = Column(String(30), nullable=False)
|
||||
severity = Column(String(20), nullable=False)
|
||||
message = Column(String(500), nullable=False)
|
||||
lifecycle_pct_at_trigger = Column(Float, nullable=False)
|
||||
lifecycle_pct_at_trigger = Column(Float, nullable=True)
|
||||
is_acknowledged = Column(Boolean, default=False)
|
||||
acknowledged_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
acknowledged_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
@@ -328,6 +339,7 @@ class Alarm(Base):
|
||||
tenant = relationship("Tenant")
|
||||
equipment_part = relationship("EquipmentPart")
|
||||
machine = relationship("Machine")
|
||||
inspection_session = relationship("InspectionSession")
|
||||
acknowledged_user = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
Reference in New Issue
Block a user