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

- 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:
Johngreen
2026-02-10 15:45:51 +09:00
parent ca29e5b809
commit 00a17c0b86
10 changed files with 564 additions and 22 deletions

View File

@@ -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)

View File

@@ -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 이상이어야 합니다.")

View File

@@ -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,
},
}

View File

@@ -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)

View File

@@ -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__ = (