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:
@@ -0,0 +1,61 @@
|
||||
"""add machine spec fields and alarm inspection_session_id
|
||||
|
||||
Revision ID: d6e7f8a9b0c1
|
||||
Revises: c5d6e7f8a9b0
|
||||
Create Date: 2026-02-10 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "d6e7f8a9b0c1"
|
||||
down_revision: Union[str, None] = "c5d6e7f8a9b0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("machines", sa.Column("manufacturer", sa.String(100), nullable=True))
|
||||
op.add_column(
|
||||
"machines",
|
||||
sa.Column(
|
||||
"installation_date", postgresql.TIMESTAMP(timezone=True), nullable=True
|
||||
),
|
||||
)
|
||||
op.add_column("machines", sa.Column("location", sa.String(200), nullable=True))
|
||||
op.add_column(
|
||||
"machines", sa.Column("rated_capacity", sa.String(100), nullable=True)
|
||||
)
|
||||
op.add_column("machines", sa.Column("power_rating", sa.String(100), nullable=True))
|
||||
op.add_column("machines", sa.Column("description", sa.Text, nullable=True))
|
||||
|
||||
op.add_column(
|
||||
"alarms",
|
||||
sa.Column(
|
||||
"inspection_session_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("inspection_sessions.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.alter_column("alarms", "equipment_part_id", nullable=True)
|
||||
op.alter_column("alarms", "machine_id", nullable=True)
|
||||
op.alter_column("alarms", "lifecycle_pct_at_trigger", nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("alarms", "lifecycle_pct_at_trigger", nullable=False)
|
||||
op.alter_column("alarms", "machine_id", nullable=False)
|
||||
op.alter_column("alarms", "equipment_part_id", nullable=False)
|
||||
op.drop_column("alarms", "inspection_session_id")
|
||||
|
||||
op.drop_column("machines", "description")
|
||||
op.drop_column("machines", "power_rating")
|
||||
op.drop_column("machines", "rated_capacity")
|
||||
op.drop_column("machines", "location")
|
||||
op.drop_column("machines", "installation_date")
|
||||
op.drop_column("machines", "manufacturer")
|
||||
@@ -261,6 +261,55 @@ export default function MachineDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(machine.manufacturer || machine.location || machine.rated_capacity || machine.power_rating || machine.description || machine.installation_date) && (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
설비 제원
|
||||
</h3>
|
||||
</div>
|
||||
<div className="spec-grid">
|
||||
{machine.manufacturer && (
|
||||
<div className="spec-item">
|
||||
<span className="spec-label">제조사</span>
|
||||
<span className="spec-value">{machine.manufacturer}</span>
|
||||
</div>
|
||||
)}
|
||||
{machine.installation_date && (
|
||||
<div className="spec-item">
|
||||
<span className="spec-label">설치일</span>
|
||||
<span className="spec-value">{new Date(machine.installation_date).toLocaleDateString('ko-KR')}</span>
|
||||
</div>
|
||||
)}
|
||||
{machine.location && (
|
||||
<div className="spec-item">
|
||||
<span className="spec-label">설치 위치</span>
|
||||
<span className="spec-value">{machine.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{machine.rated_capacity && (
|
||||
<div className="spec-item">
|
||||
<span className="spec-label">정격 용량</span>
|
||||
<span className="spec-value">{machine.rated_capacity}</span>
|
||||
</div>
|
||||
)}
|
||||
{machine.power_rating && (
|
||||
<div className="spec-item">
|
||||
<span className="spec-label">전력 사양</span>
|
||||
<span className="spec-value">{machine.power_rating}</span>
|
||||
</div>
|
||||
)}
|
||||
{machine.description && (
|
||||
<div className="spec-item spec-item-full">
|
||||
<span className="spec-label">설명</span>
|
||||
<span className="spec-value">{machine.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">
|
||||
@@ -320,9 +369,15 @@ export default function MachineDetailPage() {
|
||||
<td>{COUNTER_SOURCES.find((s) => s.value === part.counter_source)?.label || part.counter_source}</td>
|
||||
<td>
|
||||
<div className="part-actions">
|
||||
<button className="btn-icon" onClick={() => openCounterModal(part)} title="카운터 업데이트">
|
||||
<span className="material-symbols-outlined">speed</span>
|
||||
</button>
|
||||
{part.counter_source === 'auto_time' || part.lifecycle_type === 'date' ? (
|
||||
<span className="btn-icon btn-icon-disabled" title="자동 계산 부품">
|
||||
<span className="material-symbols-outlined">timer</span>
|
||||
</span>
|
||||
) : (
|
||||
<button className="btn-icon" onClick={() => openCounterModal(part)} title="카운터 업데이트">
|
||||
<span className="material-symbols-outlined">speed</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon" onClick={() => openReplaceModal(part)} title="부품 교체">
|
||||
<span className="material-symbols-outlined">swap_horiz</span>
|
||||
</button>
|
||||
|
||||
@@ -1922,6 +1922,50 @@ a {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.spec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.spec-item-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.spec-label {
|
||||
font-size: 12px;
|
||||
color: var(--md-on-surface-variant);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
font-size: 14px;
|
||||
color: var(--md-on-surface);
|
||||
}
|
||||
|
||||
.btn-icon-disabled {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-icon-disabled .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
color: var(--md-on-surface-variant);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.topnav-center {
|
||||
@@ -1970,6 +2014,53 @@ a {
|
||||
.part-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.spec-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-content.modal-lg {
|
||||
width: 95vw;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.part-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alarm-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.alarm-card-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.inspection-summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -1995,4 +2086,48 @@ a {
|
||||
.tenant-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.spec-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.part-table th:nth-child(n+6),
|
||||
.part-table td:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-outline {
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 8px;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-content.modal-sm {
|
||||
width: 95vw;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.inspection-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface Machine {
|
||||
name: string;
|
||||
equipment_code: string;
|
||||
model: string | null;
|
||||
manufacturer: string | null;
|
||||
installation_date: string | null;
|
||||
location: string | null;
|
||||
rated_capacity: string | null;
|
||||
power_rating: string | null;
|
||||
description: string | null;
|
||||
parts_count: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
@@ -146,12 +152,13 @@ export interface PartReplacementLog {
|
||||
export interface Alarm {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
equipment_part_id: string;
|
||||
machine_id: string;
|
||||
alarm_type: 'threshold' | 'critical';
|
||||
equipment_part_id: string | null;
|
||||
machine_id: string | null;
|
||||
inspection_session_id: string | null;
|
||||
alarm_type: 'threshold' | 'critical' | 'inspection_fail';
|
||||
severity: 'warning' | 'critical';
|
||||
message: string;
|
||||
lifecycle_pct_at_trigger: number;
|
||||
lifecycle_pct_at_trigger: number | null;
|
||||
is_acknowledged: boolean;
|
||||
acknowledged_by: string | null;
|
||||
acknowledged_at: string | null;
|
||||
|
||||
@@ -78,12 +78,30 @@ MACHINES = {
|
||||
"name": "CVD Chamber #1",
|
||||
"equipment_code": "SF-CVD-001",
|
||||
"model": "AMAT Centura 5200",
|
||||
"manufacturer": "Applied Materials",
|
||||
"location": "클린룸 Bay 3",
|
||||
"rated_capacity": "200mm 웨이퍼",
|
||||
"power_rating": "30kW",
|
||||
"description": "화학기상증착 장비. SiO2, Si3N4 박막 증착용.",
|
||||
},
|
||||
{
|
||||
"name": "Etcher DRY-A",
|
||||
"equipment_code": "SF-ETCH-001",
|
||||
"model": "LAM 9600",
|
||||
"manufacturer": "Lam Research",
|
||||
"location": "클린룸 Bay 5",
|
||||
"rated_capacity": "200mm 웨이퍼",
|
||||
"power_rating": "25kW",
|
||||
"description": "건식 식각 장비. Poly-Si, Metal 식각용.",
|
||||
},
|
||||
{"name": "Etcher DRY-A", "equipment_code": "SF-ETCH-001", "model": "LAM 9600"},
|
||||
{
|
||||
"name": "Sputter MC-200",
|
||||
"equipment_code": "SF-SPT-001",
|
||||
"model": "Ulvac SH-450",
|
||||
"manufacturer": "ULVAC",
|
||||
"location": "클린룸 Bay 7",
|
||||
"rated_capacity": "Ti/TiN 300mm",
|
||||
"power_rating": "20kW",
|
||||
},
|
||||
],
|
||||
"enkid": [
|
||||
@@ -91,16 +109,29 @@ MACHINES = {
|
||||
"name": "CNC 선반 #1",
|
||||
"equipment_code": "EK-CNC-001",
|
||||
"model": "두산 LYNX 220",
|
||||
"manufacturer": "두산공작기계",
|
||||
"location": "1공장 A라인",
|
||||
"rated_capacity": "최대 가공경 220mm",
|
||||
"power_rating": "15kW (주축 모터)",
|
||||
"description": "2축 CNC 선반. 알루미늄/스틸 가공용.",
|
||||
},
|
||||
{
|
||||
"name": "프레스 500T",
|
||||
"equipment_code": "EK-PRS-001",
|
||||
"model": "현대위아 HF-500",
|
||||
"manufacturer": "현대위아",
|
||||
"location": "1공장 B라인",
|
||||
"rated_capacity": "500톤",
|
||||
"power_rating": "37kW",
|
||||
},
|
||||
{
|
||||
"name": "용접 로봇 #1",
|
||||
"equipment_code": "EK-WLD-001",
|
||||
"model": "화낙 ARC Mate 120",
|
||||
"manufacturer": "FANUC",
|
||||
"location": "2공장 용접 셀 1",
|
||||
"rated_capacity": "6축 120kg 가반하중",
|
||||
"power_rating": "6kVA",
|
||||
},
|
||||
],
|
||||
"alpet": [
|
||||
@@ -108,16 +139,29 @@ MACHINES = {
|
||||
"name": "반응기 R-101",
|
||||
"equipment_code": "AP-RCT-101",
|
||||
"model": "Custom Reactor 5000L",
|
||||
"manufacturer": "한화솔루션 엔지니어링",
|
||||
"location": "1플랜트 반응 구역",
|
||||
"rated_capacity": "5,000L (SUS316L)",
|
||||
"power_rating": "22kW (교반기)",
|
||||
"description": "교반식 배치 반응기. HCl 합성 공정.",
|
||||
},
|
||||
{
|
||||
"name": "증류탑 D-201",
|
||||
"equipment_code": "AP-DST-201",
|
||||
"model": "Sulzer Packed Tower",
|
||||
"manufacturer": "Sulzer",
|
||||
"location": "1플랜트 분리 구역",
|
||||
"rated_capacity": "처리량 2,000 kg/h",
|
||||
"power_rating": "리보일러 150kW",
|
||||
},
|
||||
{
|
||||
"name": "열교환기 E-301",
|
||||
"equipment_code": "AP-HEX-301",
|
||||
"model": "Alfa Laval M10-BW",
|
||||
"manufacturer": "Alfa Laval",
|
||||
"location": "1플랜트 유틸리티",
|
||||
"rated_capacity": "열전달 500kW",
|
||||
"power_rating": "펌프 7.5kW",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -183,6 +227,16 @@ PARTS = {
|
||||
"auto_time",
|
||||
5000,
|
||||
),
|
||||
(
|
||||
"교정 인증서",
|
||||
"CAL-SPT-01",
|
||||
"인증",
|
||||
"date",
|
||||
365,
|
||||
80,
|
||||
"manual",
|
||||
0,
|
||||
),
|
||||
],
|
||||
},
|
||||
"enkid": {
|
||||
@@ -262,6 +316,16 @@ PARTS = {
|
||||
"auto_time",
|
||||
1500,
|
||||
),
|
||||
(
|
||||
"안전 검사 인증",
|
||||
"CERT-WLD-01",
|
||||
"인증",
|
||||
"date",
|
||||
180,
|
||||
85,
|
||||
"manual",
|
||||
0,
|
||||
),
|
||||
],
|
||||
},
|
||||
"alpet": {
|
||||
@@ -323,6 +387,16 @@ PARTS = {
|
||||
15000,
|
||||
),
|
||||
("압력 센서", "PS-HEX-01", "계측", "hours", 5000, 90, "auto_time", 2000),
|
||||
(
|
||||
"내압 검사 인증",
|
||||
"CERT-HEX-01",
|
||||
"인증",
|
||||
"date",
|
||||
730,
|
||||
85,
|
||||
"manual",
|
||||
0,
|
||||
),
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -654,6 +728,7 @@ async def seed():
|
||||
machine_map[(tenant_id, m["name"])] = row.id
|
||||
continue
|
||||
machine_id = uuid.uuid4()
|
||||
install_date = NOW - timedelta(days=365 * 2)
|
||||
db.add(
|
||||
Machine(
|
||||
id=machine_id,
|
||||
@@ -661,6 +736,12 @@ async def seed():
|
||||
name=m["name"],
|
||||
equipment_code=m["equipment_code"],
|
||||
model=m["model"],
|
||||
manufacturer=m.get("manufacturer"),
|
||||
installation_date=install_date,
|
||||
location=m.get("location"),
|
||||
rated_capacity=m.get("rated_capacity"),
|
||||
power_rating=m.get("power_rating"),
|
||||
description=m.get("description"),
|
||||
)
|
||||
)
|
||||
machine_map[(tenant_id, m["name"])] = machine_id
|
||||
|
||||
@@ -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