초기화

This commit is contained in:
2026-07-15 18:37:19 +09:00
commit 94abc5461d
1268 changed files with 380198 additions and 0 deletions
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
# communication package marker
from .mqtt_client import start_mqtt_client, stop_mqtt_client
+11
View File
@@ -0,0 +1,11 @@
{
"lines": [
{
"line_id": "line1",
"equipment_id": 8,
"mqtt_prefix": "line1",
"plc_slave_id": 1,
"description": "양산 1호기 (CC_MC_TYPE_A)"
}
]
}
+56
View File
@@ -0,0 +1,56 @@
import json
import logging
import os
import re
from typing import Dict, List
logger = logging.getLogger("line_manager")
class LineManager:
def __init__(self, config_file: str):
self.lines: Dict[str, dict] = {}
self.equipment_to_prefix: Dict[int, str] = {}
self.load_config(config_file)
def load_config(self, config_file: str):
try:
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
for line_cfg in config.get('lines', []):
line_id = line_cfg.get('line_id')
equipment_id = line_cfg.get('equipment_id')
self.lines[line_id] = line_cfg
self.equipment_to_prefix[equipment_id] = line_id
logger.info(f"[LINE CONFIG] Loaded {len(self.lines)} line configurations")
else:
logger.warning(f"[LINE CONFIG] Config file not found: {config_file}. Defaults to line1 -> ID 8.")
except Exception as e:
logger.error(f"[LINE CONFIG ERROR] Failed to load config: {e}")
def get_equipment_id(self, prefix: str) -> int:
if prefix in self.lines:
return self.lines[prefix].get('equipment_id', 8)
# Default fallback
if "line1" in prefix:
return 8
match = re.search(r'\d+', prefix)
if match:
line_num = int(match.group())
return 7 + line_num
return 8
def get_prefix(self, equipment_id: int) -> str:
return self.equipment_to_prefix.get(equipment_id, "line1")
def get_all_prefixes(self) -> List[str]:
if not self.lines:
return ["line1"]
return list(self.lines.keys())
# 전역 인스턴스
config_path = os.path.join(os.path.dirname(__file__), "line_config.json")
line_manager = LineManager(config_path)
+321
View File
@@ -0,0 +1,321 @@
import paho.mqtt.client as mqtt
import json
import logging
import time
import re
import asyncio
logger = logging.getLogger("mqtt_client")
# ── 설정 ──────────────────────────────────────────────────
BROKER_HOST = "localhost"
BROKER_PORT = 1883
# MQTT 토픽 규칙:
# 장비→서버(읽기): /{prefix}/m/in, /{prefix}/d/in, /{prefix}/event
# 서버→장비(쓰기): /{prefix}/m/out, /{prefix}/d/out
_client: mqtt.Client = None
from .line_manager import line_manager
def _get_equipment_id_by_prefix(prefix: str) -> int:
"""
토픽 프리픽스를 기반으로 DB의 설비 ID를 반환합니다 (LineManager 연동).
"""
return line_manager.get_equipment_id(prefix)
def _publish_hmi_update(equipment_id: int):
"""
HMI 실시간 상태 업데이트 데이터를 조회하여 WebSocket으로 브로드캐스트합니다.
MQTT 수신 스레드가 블로킹되는 현상을 피하기 위해 Future 대기 없이 이벤트 루프에 비동기 처리만 위임합니다.
"""
try:
from equipment.db_manager import get_machine_status_detail_joined
from communication.websocket_manager import ws_manager
result = get_machine_status_detail_joined(equipment_id)
if result:
# Decimal 타입 등의 변환 오류 방지 안전 필터 적용
# 그룹 0,5(순환펌프 재작동 시간)만 정수, 나머지 d_read와 모든 d_write는 PLC REAL이므로 소수점 보존
for i in range(10):
key = f"d_read_g{i}"
if key in result and result[key] is not None:
try:
result[key] = int(result[key]) if i in (0, 5) else float(result[key])
except:
result[key] = 0
for i in range(4):
key = f"d_write_g{i}"
if key in result and result[key] is not None:
try:
result[key] = float(result[key])
except:
result[key] = 0
# 웹소켓 매니저의 메인 이벤트 루프로 비동기 브로드캐스트 위임
loop = getattr(ws_manager, 'loop', None)
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
ws_manager.broadcast({
"type": "equipment_update",
"equipment_id": equipment_id,
"data": result,
"timestamp": int(time.time() * 1000)
}),
loop
)
except Exception as e:
logger.error(f"[WS BROADCAST ERROR] Failed to broadcast for equipment_id {equipment_id}: {e}", exc_info=True)
def _on_connect(client, userdata, flags, rc):
if rc == 0:
logger.info("[MQTT] Connected to broker successfully.")
# line_config.json에 등록된 모든 라인의 토픽 동적 구독
prefixes = line_manager.get_all_prefixes()
for prefix in prefixes:
client.subscribe(f"/{prefix}/m/in")
client.subscribe(f"/{prefix}/d/in")
client.subscribe(f"/{prefix}/event")
client.subscribe(f"/{prefix}/ack")
client.subscribe(f"/{prefix}/status")
logger.info(f"[MQTT] Dynamically subscribed to topics for prefixes: {prefixes}")
else:
logger.error(f"[MQTT] Connection failed: rc={rc}")
def _on_message(client, userdata, msg):
"""
배치 데이터 [[id, val], ...] 수집 및 DB 동기화, HMI 실시간 전계 (성능 모니터링 포함)
"""
start_time = time.time()
topic = msg.topic
try:
payload_str = msg.payload.decode()
logger.info(f"[MQTT RECEIVE] Topic: {topic} | Payload: {payload_str}")
payload = json.loads(payload_str)
# 토픽 프리픽스 동적 해석
match = re.search(r'/([^/]+)/(m|d|event|ack|status)', topic)
line_prefix = match.group(1) if match else "line1"
equipment_id = _get_equipment_id_by_prefix(line_prefix)
from database.connection import get_db_connection
if "/ack" in topic:
command_id = payload.get("command_id")
status = payload.get("status")
applied_value = payload.get("applied_value") # 적용 데이터 파싱
logger.info(f"[MQTT ACK Callback] Received Event command {command_id} status={status} applied_value={applied_value} for prefix: {line_prefix}")
# DB 상태 및 적용값 분리 저장 업데이트
from equipment.db_manager import update_control_command_status
update_control_command_status(command_id, status, applied_value)
# WebSocket HMI 브로드캐스트
from communication.websocket_manager import ws_manager
loop = getattr(ws_manager, 'loop', None)
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
ws_manager.broadcast({
"type": "command_ack",
"command_id": command_id,
"status": status,
"applied_value": applied_value,
"timestamp": int(time.time() * 1000)
}),
loop
)
elif "/status" in topic:
gateway = payload.get("gateway", "OFFLINE")
plc_state = payload.get("plc_state", "OFFLINE")
logger.info(
f"[MQTT STATUS Callback] gateway={gateway}, "
f"plc_state={plc_state} for prefix: {line_prefix}"
)
# 상태 토픽을 기준으로 장비 상태와 동기화 상태를 함께 갱신
sync_state = None
if gateway == "OFFLINE" or plc_state == "OFFLINE":
device_state = "OFFLINE"
elif gateway == "SYNCING":
device_state = "SYNCING"
sync_state = "SYNCING"
else:
device_state = "ONLINE"
sync_state = "IDLE"
with get_db_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
UPDATE machine_status
SET gateway_state = %s,
plc_state = %s,
device_state = %s,
sync_state = COALESCE(%s, sync_state),
timestamp = CURRENT_TIMESTAMP
WHERE equipment_id = %s
""",
(
gateway,
plc_state,
device_state,
sync_state,
equipment_id
)
)
conn.commit()
_publish_hmi_update(equipment_id)
elif "/event" in topic:
event_id = payload.get("id")
event_val = payload.get("val")
logger.info(f"[MQTT Event Callback] Received Event {event_id} (value={event_val}) for prefix: {line_prefix}")
# Full-Sync 혹은 PLC 오프라인 상태값 업데이트
device_state = "ONLINE"
sync_state = "IDLE"
if event_id == 999: # Full-Sync 시작 (STATE_INIT 진입)
device_state = "SYNCING"
sync_state = "SYNCING"
elif event_id == 998: # PLC Modbus 통신 실패 감지
device_state = "OFFLINE"
elif event_id == 997: # Full-Sync 완료 (정상 루프 진입)
device_state = "ONLINE"
sync_state = "IDLE"
with get_db_connection() as conn:
with conn.cursor() as cursor:
if event_id == 998:
cursor.execute(
"UPDATE machine_status SET device_state = %s, plc_state = 'OFFLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s",
(device_state, equipment_id)
)
else:
cursor.execute(
"UPDATE machine_status SET device_state = %s, sync_state = %s, timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s",
(device_state, sync_state, equipment_id)
)
conn.commit()
logger.info(f"[MQTT Event DB Update Completed] device_state updated to: {device_state}")
_publish_hmi_update(equipment_id)
elif "/m/in" in topic:
if not isinstance(payload, list):
raise ValueError("M_in payload is not a JSON list")
logger.info(f"[MQTT Processing M_in] Size of incoming batch: {len(payload)} for prefix: {line_prefix}")
# 1. 기존 m_read_raw 및 m_write_raw JSON 데이터 조회
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT m_read_raw, m_write_raw FROM machine_status WHERE equipment_id = %s", (equipment_id,))
row = cursor.fetchone()
m_read = {}
if row and row.get("m_read_raw"):
try:
m_read = json.loads(row["m_read_raw"])
except:
pass
m_write = {}
if row and row.get("m_write_raw"):
try:
m_write = json.loads(row["m_write_raw"])
except:
pass
# 2. 배치 배열 수신값 머지 [[bit_id, val], ...]
for item in payload:
if isinstance(item, list) and len(item) == 2:
bit_id = str(item[0])
val = int(item[1])
m_read[bit_id] = val
# 3. 모멘터리 비트 정리: 쓰기 버퍼(m_write_raw) 중 1로 켜져있던 비트들을 0으로 복구
# (ESP32 측에서 이미 쓰기 실행 및 자동 리셋을 처리했으므로 서버측 DB만 동기화하고 패킷은 송신 안함)
for k, v in m_write.items():
if v == 1:
m_write[k] = 0
# 4. DB에 병합된 M 데이터 업데이트
cursor.execute(
"UPDATE machine_status SET m_read_raw = %s, m_write_raw = %s, device_state = 'ONLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s",
(json.dumps(m_read), json.dumps(m_write), equipment_id)
)
conn.commit()
logger.info(f"[MQTT Processing M_in Completed] Successfully merged and saved {len(payload)} bits to DB.")
_publish_hmi_update(equipment_id)
elif "/d/in" in topic:
if not isinstance(payload, list):
raise ValueError("D_in payload is not a JSON list")
logger.info(f"[MQTT Processing D_in] Size of incoming batch: {len(payload)} for prefix: {line_prefix}")
# D영역 업데이트: [[group_id, val], ...]
with get_db_connection() as conn:
with conn.cursor() as cursor:
for item in payload:
if isinstance(item, list) and len(item) == 2:
group_id = int(item[0])
# 그룹 0,5(순환펌프 재작동 시간)는 UINT16 정수, 그 외는 PLC REAL이므로 소수점 보존
val = int(item[1]) if group_id in (0, 5) else float(item[1])
if 0 <= group_id <= 9:
sql = f"UPDATE machine_status SET d_read_g{group_id} = %s, device_state = 'ONLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s"
cursor.execute(sql, (val, equipment_id))
conn.commit()
logger.info(f"[MQTT Processing D_in Completed] Successfully saved {len(payload)} D-registers to DB.")
_publish_hmi_update(equipment_id)
# 성공 처리 시간 기록
duration_ms = (time.time() - start_time) * 1000
from monitoring.metrics import record_metric
record_metric(topic, duration_ms)
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
from monitoring.metrics import record_metric
record_metric(topic, duration_ms, error=True, error_msg=str(e))
logger.error(f"[MQTT ERROR] Message parse/update error: {e}", exc_info=True)
def start_mqtt_client(db=None):
"""
FastAPI 어플리케이션 구동 시 호출됩니다.
"""
global _client
_client = mqtt.Client(client_id="ChemiFactory_HMI_Server")
# MQTT 인증 계정 정보 적용 (rc=5 방지)
_client.username_pw_set("ctnt_root", "Umsang6595!!")
_client.on_connect = _on_connect
_client.on_message = _on_message
try:
logger.info(f"[MQTT INIT] Connecting to broker at {BROKER_HOST}:{BROKER_PORT}...")
_client.connect(BROKER_HOST, BROKER_PORT, 60)
_client.loop_start()
logger.info("[MQTT INIT] Background client started and loop active.")
except Exception as e:
logger.error(f"[MQTT INIT ERROR] Startup failed: {e}", exc_info=True)
def stop_mqtt_client():
global _client
if _client:
_client.loop_stop()
_client.disconnect()
logger.info("[MQTT] Background client stopped successfully.")
+317
View File
@@ -0,0 +1,317 @@
import json
import logging
import re
import os
from datetime import datetime
from database.connection import get_db_connection
import mysql.connector
logger = logging.getLogger(__name__)
# --- 3단계 핵심: plc_mapping.json 매핑 설정 로드 ---
MAPPING_FILE_PATH = os.path.join(os.path.dirname(__file__), "plc_mapping.json")
plc_mapping = {}
def load_plc_mapping():
global plc_mapping
try:
if os.path.exists(MAPPING_FILE_PATH):
with open(MAPPING_FILE_PATH, "r", encoding="utf-8") as f:
plc_mapping = json.load(f)
logger.info("[MAPPING] Successfully loaded plc_mapping.json settings.")
else:
logger.warning("[MAPPING] plc_mapping.json does not exist. Default placeholders will be used.")
plc_mapping = {"m_area": {}, "d_area": {}}
except Exception as e:
logger.error(f"[MAPPING] Failed to load plc_mapping.json: {e}")
plc_mapping = {"m_area": {}, "d_area": {}}
# 최초 1회 즉시 로드
load_plc_mapping()
# 각 라인 프리픽스별 동적 모멘터리 리셋 대기 비트 집합 (라인 -> set)
pending_momentary_resets = {}
# --- 헬스체크 ---
def handle_health_check(client, payload_str, topic):
logger.info(f"[MQTT] Health Check Ping received (Topic: {topic}). Replying with Pong.")
client.publish('/app/health_check/pong', payload_str)
# --- 3단계 핵심: 특수 이벤트 핸들러 (999/998/997) ---
def handle_system_event(client, payload_str, topic):
"""
이벤트 수신 시 DB 초기화 및 상태 제어를 담당합니다.
- 999 (BOOT / Full-Sync 시작): DB 초기화 준비
- 998 (PLC_REBOOT 감지 / Modbus 실패 10회): DB의 기계 상태값을 전부 0으로 강제 리셋
- 997 (Full-Sync 완료): 정상 대기 상태 전환
"""
logger.info(f"[MQTT EVENT] Event received: {payload_str} (Topic: {topic})")
try:
data = json.loads(payload_str)
event_id = data.get("id")
match = re.search(r'/([^/]+)/event', topic)
line_prefix = match.group(1) if match else "line1"
if event_id == 998:
logger.warn(f"[MQTT EVENT] PLC_REBOOT(998) 감지! {line_prefix} 관련 DB 실시간 상태를 0으로 리셋합니다.")
_reset_machine_status_db(line_prefix)
elif event_id == 999:
logger.info(f"[MQTT EVENT] 게이트웨이 BOOT(999) 감지. {line_prefix} 관련 DB 실시간 상태를 0으로 리셋하고 Full-Sync를 개시합니다.")
_reset_machine_status_db(line_prefix)
elif event_id == 997:
logger.info(f"[MQTT EVENT] Full-Sync 완료(997). 정상 대기 상태 전환.")
except Exception as e:
logger.error(f"[MQTT EVENT] Failed to process event: {e}", exc_info=True)
# --- 3단계 핵심: M영역/D영역 변경 배치 수신 및 DB Upsert ---
def handle_m_in_batch(client, payload_str, topic):
"""
ESP32 -> 서버 (/line1/m/in)
배치 데이터 [[id, val], [id, val], ...] 수신 시 DB 업데이트
"""
global pending_momentary_resets
logger.debug(f"[MQTT M_IN] Batch raw data received: {payload_str}")
try:
batch = json.loads(payload_str)
if not isinstance(batch, list):
logger.warning("[MQTT M_IN] Payload is not a list array.")
return
match = re.search(r'/([^/]+)/m/in', topic)
line_prefix = match.group(1) if match else "line1"
equipment_id = _get_equipment_id_by_prefix(line_prefix)
_update_batch_values_in_db(equipment_id, "m_area", batch, client)
# [신규 요구사항] 다음 읽기 영역 데이터를 받은 직후, 대기 중인 모멘터리 비트들을 0으로 복귀
if line_prefix in pending_momentary_resets and pending_momentary_resets[line_prefix]:
reset_bits = list(pending_momentary_resets[line_prefix])
off_batch = [[bit_id, 0] for bit_id in reset_bits]
reset_payload = json.dumps(off_batch)
# 대기 목록 비우기
pending_momentary_resets[line_prefix].clear()
# ESP32 쓰기 영역 토픽(/m/out)으로 리셋 패킷 송신
out_topic = f"/{line_prefix}/m/out"
client.publish(out_topic, reset_payload)
logger.info(f"[MQTT MOMENTARY DYNAMIC RESET] Sent reset command for bits {reset_bits} to {out_topic} after M_IN received.")
except Exception as e:
logger.error(f"[MQTT M_IN] Error processing batch: {e}", exc_info=True)
def handle_d_in_batch(client, payload_str, topic):
"""
ESP32 -> 서버 (/line1/d/in)
배치 데이터 [[id, val], [id, val], ...] 수신 시 DB 업데이트
"""
logger.debug(f"[MQTT D_IN] Batch raw data received: {payload_str}")
try:
batch = json.loads(payload_str)
if not isinstance(batch, list):
logger.warning("[MQTT D_IN] Payload is not a list array.")
return
match = re.search(r'/([^/]+)/d/in', topic)
line_prefix = match.group(1) if match else "line1"
equipment_id = _get_equipment_id_by_prefix(line_prefix)
_update_batch_values_in_db(equipment_id, "d_area", batch, client)
except Exception as e:
logger.error(f"[MQTT D_IN] Error processing batch: {e}", exc_info=True)
# --- 3단계 핵심: 모멘터리(Momentary) 제어 로직 ---
def handle_momentary_control(client, payload_str, topic):
"""
서버 -> ESP32 방향 (/line1/m/out)으로 제어 명령 전송 시 동작
모멘터리 제어: 비트 ON 제어 명령을 대기열에 등록하고, 다음 M_IN(읽기 영역 수신) 직후 0으로 복귀시킵니다.
"""
global pending_momentary_resets
logger.info(f"[MQTT MOMENTARY] Control command detected: {payload_str} (Topic: {topic})")
try:
batch = json.loads(payload_str)
if not isinstance(batch, list):
return
match = re.search(r'/([^/]+)/m/out', topic)
line_prefix = match.group(1) if match else "line1"
# 1(ON)로 쓴 비트들만 추출하여 대기열에 추가
on_bits = [item[0] for item in batch if len(item) >= 2 and item[1] == 1]
if on_bits:
if line_prefix not in pending_momentary_resets:
pending_momentary_resets[line_prefix] = set()
for bit_id in on_bits:
pending_momentary_resets[line_prefix].add(bit_id)
logger.info(f"[MQTT MOMENTARY] Registered bits {on_bits} for line {line_prefix} for dynamic reset on next M_IN packet.")
except Exception as e:
logger.error(f"[MQTT MOMENTARY] Error handling momentary control: {e}")
# --- DB 제어 관련 내부 헬퍼 함수 ---
def _get_equipment_id_by_prefix(prefix: str) -> int:
"""
토픽의 라인 구분자(prefix)를 기준으로 실제 DB의 설비 ID(PK)를 반환합니다.
- line1 (ESP32 Gateway 토픽) -> 실제 DB의 양산 1호기 id = 8번에 대응
"""
if "line1" in prefix:
return 8
match = re.search(r'\d+', prefix)
return int(match.group()) if match else 8
def _reset_machine_status_db(line_prefix: str):
"""PLC 연결 통신 장애 감지(998) 시 관련 장비 상태 데이터를 0 및 OFFLINE으로 강제 초기화"""
equipment_id = _get_equipment_id_by_prefix(line_prefix)
# 208개 M_IN 비트와 188개 M_OUT 비트를 명시적으로 0으로 채운 JSON 문자열 생성
initial_m_read = json.dumps({str(i): 0 for i in range(208)})
initial_m_write = json.dumps({str(i): 0 for i in range(188)})
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
sql = """
UPDATE machine_status
SET device_state = 'OFFLINE',
m_read_raw = %s,
m_write_raw = %s,
d_read_g0 = 0, d_read_g1 = 0, d_read_g2 = 0, d_read_g3 = 0, d_read_g4 = 0,
d_read_g5 = 0, d_read_g6 = 0, d_read_g7 = 0, d_read_g8 = 0, d_read_g9 = 0,
timestamp = 0
WHERE equipment_id = %s
"""
cursor.execute(sql, (initial_m_read, initial_m_write, equipment_id))
conn.commit()
logger.info(f"[DB RESET] Successfully reset machine_status columns (explicitly set all M bits to 0, excluded D-write columns) for equipment_id: {equipment_id}")
except mysql.connector.Error as err:
logger.error(f"[DB RESET] Failed to reset db: {err}")
def _update_batch_values_in_db(equipment_id: int, area_type: str, batch: list, client):
"""
ESP32로부터 수신된 변경 배치 데이터를 DB에 누적 병합(Merge)합니다.
- M영역: m_read_raw JSON에 비트상태 누적 업데이트
- D영역: 10개 d_read_g{0~9} 개별 컬럼에 수치값 매핑 업데이트
"""
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM machine_status WHERE equipment_id = %s", (equipment_id,))
status = cursor.fetchone()
if not status:
cursor.execute(
"INSERT INTO machine_status (equipment_id, device_state, m_read_raw, m_write_raw) VALUES (%s, 'ONLINE', '{}', '{}')",
(equipment_id,)
)
conn.commit()
cursor.execute("SELECT * FROM machine_status WHERE equipment_id = %s", (equipment_id,))
status = cursor.fetchone()
# 기존 JSON 데이터 로드
m_read = json.loads(status.get("m_read_raw") or '{}')
m_write = json.loads(status.get("m_write_raw") or '{}')
updated = False
update_fields = {}
if area_type == "m_area":
for item in batch:
if len(item) < 2:
continue
bit_id = str(item[0])
val = item[1]
m_read[bit_id] = val
update_fields["m_read_raw"] = json.dumps(m_read)
updated = True
elif area_type == "d_area":
for item in batch:
if len(item) < 2:
continue
group_id = int(item[0])
val = int(item[1]) if group_id in (0, 5) else float(item[1])
if 0 <= group_id <= 9:
# d_read_g0 ~ d_read_g9 컬럼 바인딩
update_fields[f"d_read_g{group_id}"] = val
updated = True
if updated:
import time
current_ts = int(time.time() * 1000)
update_fields["timestamp"] = current_ts
update_fields["device_state"] = 'ONLINE'
# 동적 SQL 쿼리 구성
set_clause = ", ".join([f"{k} = %s" for k in update_fields.keys()])
sql_update = f"UPDATE machine_status SET {set_clause} WHERE equipment_id = %s"
params = list(update_fields.values()) + [equipment_id]
cursor.execute(sql_update, params)
conn.commit()
logger.debug(f"[DB UPDATE] Successfully updated machine_status for machine {equipment_id}")
_publish_hmi_update(equipment_id, client)
except Exception as e:
logger.error(f"[DB BATCH] Error merging & updating batch values: {e}", exc_info=True)
def _get_prefix_by_equipment_id(equipment_id: int) -> str:
"""DB 설비 ID에 대응하는 게이트웨이 토픽 프리픽스 조회"""
if equipment_id == 8:
return "line1"
return f"line{equipment_id}"
def _publish_hmi_update(equipment_id: int, client):
try:
from equipment.db_manager import get_machine_status_detail_joined
result = get_machine_status_detail_joined(equipment_id)
if result:
# Decimal 등의 변환 이슈 방지 (C-2)
for i in range(10):
key = f"d_read_g{i}"
if key in result and result[key] is not None:
try:
result[key] = int(result[key])
except Exception:
pass
for i in range(4):
key = f"d_write_g{i}"
if key in result and result[key] is not None:
try:
result[key] = int(result[key])
except Exception:
pass
# 2. --- 웹소켓 실시간 브로드캐스트 전송 (스레드 세이프 호출) ---
import asyncio
from communication.websocket_manager import ws_manager
try:
loop = getattr(ws_manager, 'loop', None)
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
ws_manager.broadcast({
"type": "equipment_update",
"equipment_id": equipment_id,
"data": result
}),
loop
)
except Exception as wse:
logger.warning(f"[WS BROADCAST] Could not schedule websocket broadcast: {wse}")
except Exception as e:
logger.error(f"Failed to publish HMI update: {e}")
+425
View File
@@ -0,0 +1,425 @@
{
"comment": "이 파일은 PLC의 읽기(입력) 영역과 쓰기(제어) 영역의 센서/동작명을 정의합니다.",
"m_read_area": {
"0": "A그룹 | 인터락 리셋 상태",
"1": "A그룹 | E/STOP 상태",
"2": "A그룹 | DOOR좌 인터락 상태",
"3": "A그룹 | DOOR우 인터락 상태",
"4": "A그룹 | 수위 인터락 상태",
"5": "A그룹 | 히터 인터락 상태",
"6": "A그룹 | 전체그룹 활성화 ON 상태",
"7": "A그룹 | 전체그룹 활성화 OFF 상태(반전신호)",
"8": "A그룹 | 전체그룹 정지 상태",
"9": "A그룹 | 전체그룹 일시정지 상태",
"10": "A그룹 | 전체그룹 재시작 상태",
"11": "A그룹 | 함침,이송 그룹 ON 상태",
"12": "A그룹 | 함침,이송 그룹 OFF 상태(반전신호)",
"13": "A그룹 | 건조부 그룹 ON 상태",
"14": "A그룹 | 건조부 그룹 OFF 상태(반전신호)",
"15": "A그룹 | 커팅부 그룹 ON 상태",
"16": "A그룹 | 커팅부 그룹 OFF 상태(반전신호)",
"17": "A그룹 | 이송부 그룹 ON 상태",
"18": "A그룹 | 이송부 그룹 OFF 상태(반전신호)",
"19": "B그룹 | 인터락 리셋 상태",
"20": "B그룹 | E/STOP 상태",
"21": "B그룹 | DOOR좌 인터락 상태",
"22": "B그룹 | DOOR우 인터락 상태",
"23": "B그룹 | 수위 인터락 상태",
"24": "B그룹 | 히터 인터락 상태",
"25": "B그룹 | 전체그룹 활성화 ON 상태",
"26": "B그룹 | 전체그룹 활성화 OFF 상태(반전신호)",
"27": "B그룹 | 전체그룹 정지 상태",
"28": "B그룹 | 전체그룹 일시정지 상태",
"29": "B그룹 | 전체그룹 재시작 상태",
"30": "B그룹 | 함침,이송 그룹 ON 상태",
"31": "B그룹 | 함침,이송 그룹 OFF 상태(반전신호)",
"32": "B그룹 | 건조부 그룹 ON 상태",
"33": "B그룹 | 건조부 그룹 OFF 상태(반전신호)",
"34": "B그룹 | 커팅부 그룹 ON 상태",
"35": "B그룹 | 커팅부 그룹 OFF 상태(반전신호)",
"36": "B그룹 | 이송부 그룹 ON 상태",
"37": "B그룹 | 이송부 그룹 OFF 상태(반전신호)",
"38": "A그룹 | 함침부 자동모드 ON 상태",
"39": "A그룹 | 함침부 수동모드 ON 상태",
"40": "A그룹 | 순환 펌프 상태",
"41": "A그룹 | 배출 펌프 상태",
"42": "B그룹 | 함침부 자동모드 ON 상태",
"43": "B그룹 | 함침부 수동모드 ON 상태",
"44": "B그룹 | 순환 펌프 상태",
"45": "B그룹 | 배출 펌프 상태",
"46": "A그룹 | 건조부 자동모드 ON 상태",
"47": "A그룹 | 건조부 수동모드 ON 상태",
"48": "A그룹 | 송풍기 트립 상태",
"49": "A그룹 | 히터 제어요청 상태",
"50": "A그룹 | 송풍기1 작동 상태",
"51": "A그룹 | 히터 1 작동 상태",
"52": "A그룹 | 히터 2 작동 상태",
"53": "A그룹 | 히터 3 작동 상태",
"54": "A그룹 | 히터 4 작동 상태",
"55": "A그룹 | 히터 5 작동 상태",
"56": "B그룹 | 건조부 자동모드 ON 상태",
"57": "B그룹 | 건조부 수동모드 ON 상태",
"58": "B그룹 | 송풍기2 트립 상태",
"59": "B그룹 | 히터 제어요청 상태",
"60": "B그룹 | 송풍기2 작동 상태",
"61": "B그룹 | 히터 6 작동 상태",
"62": "B그룹 | 히터 7 작동 상태",
"63": "B그룹 | 히터 8 작동 상태",
"64": "B그룹 | 히터 9 작동 상태",
"65": "B그룹 | 히터 10 작동 상태",
"66": "A그룹 | 커팅모터 활성 상태",
"67": "A그룹 | 커팅모터 시작 상태",
"68": "B그룹 | 커팅모터 활성 상태",
"69": "B그룹 | 커팅모터 시작 상태",
"70": "A그룹 | 이송부 자동모드 ON 상태",
"71": "A그룹 | 이송부 수동모드 ON 상태",
"72": "A그룹 | 이송모터 자동시작 상태",
"73": "A그룹 | 이송모터 자동정지 상태",
"74": "A그룹 | 이송모터 수동 정방향 시작 상태",
"75": "A그룹 | 이송모터 수동 역방향 시작 상태",
"76": "A그룹 | 이송모터 수동 정지 상태",
"77": "A그룹 | 이송모터 제로셋 상태",
"78": "A그룹 | 이송모터 에러리셋 상태",
"79": "B그룹 | 이송부 자동모드 ON 상태",
"80": "B그룹 | 이송부 수동모드 ON 상태",
"81": "B그룹 | 이송모터 자동시작 상태",
"82": "B그룹 | 이송모터 자동정지 상태",
"83": "B그룹 | 이송모터 수동 정방향 시작 상태",
"84": "B그룹 | 이송모터 수동 역방향 시작 상태",
"85": "B그룹 | 이송모터 수동 정지 상태",
"86": "B그룹 | 이송모터 제로셋 상태",
"87": "B그룹 | 이송모터 에러리셋 상태",
"88": "A그룹 | 포트01 자동하강 상태",
"89": "A그룹 | 포트02 자동하강 상태",
"90": "A그룹 | 포트03 자동하강 상태",
"91": "A그룹 | 포트04 자동하강 상태",
"92": "A그룹 | 포트05 자동하강 상태",
"93": "A그룹 | 포트06 자동하강 상태",
"94": "A그룹 | 포트07 자동하강 상태",
"95": "A그룹 | 포트08 자동하강 상태",
"96": "A그룹 | 포트09 자동하강 상태",
"97": "A그룹 | 포트10 자동하강 상태",
"98": "A그룹 | 포트11 자동하강 상태",
"99": "A그룹 | 포트12 자동하강 상태",
"100": "A그룹 | 포트13 자동하강 상태",
"101": "A그룹 | 포트14 자동하강 상태",
"102": "A그룹 | 포트15 자동하강 상태",
"103": "A그룹 | 포트16 자동하강 상태",
"104": "A그룹 | 포트17 자동하강 상태",
"105": "A그룹 | 포트18 자동하강 상태",
"106": "A그룹 | 포트19 자동하강 상태",
"107": "A그룹 | 포트20 자동하강 상태",
"108": "B그룹 | 포트21 자동하강 상태",
"109": "B그룹 | 포트22 자동하강 상태",
"110": "B그룹 | 포트23 자동하강 상태",
"111": "B그룹 | 포트24 자동하강 상태",
"112": "B그룹 | 포트25 자동하강 상태",
"113": "B그룹 | 포트26 자동하강 상태",
"114": "B그룹 | 포트27 자동하강 상태",
"115": "B그룹 | 포트28 자동하강 상태",
"116": "B그룹 | 포트29 자동하강 상태",
"117": "B그룹 | 포트30 자동하강 상태",
"118": "B그룹 | 포트31 자동하강 상태",
"119": "B그룹 | 포트32 자동하강 상태",
"120": "B그룹 | 포트33 자동하강 상태",
"121": "B그룹 | 포트34 자동하강 상태",
"122": "B그룹 | 포트35 자동하강 상태",
"123": "B그룹 | 포트36 자동하강 상태",
"124": "B그룹 | 포트37 자동하강 상태",
"125": "B그룹 | 포트38 자동하강 상태",
"126": "B그룹 | 포트39 자동하강 상태",
"127": "B그룹 | 포트40 자동하강 상태",
"128": "A그룹 | 포트01 수동하강 상태",
"129": "A그룹 | 포트02 수동하강 상태",
"130": "A그룹 | 포트03 수동하강 상태",
"131": "A그룹 | 포트04 수동하강 상태",
"132": "A그룹 | 포트05 수동하강 상태",
"133": "A그룹 | 포트06 수동하강 상태",
"134": "A그룹 | 포트07 수동하강 상태",
"135": "A그룹 | 포트08 수동하강 상태",
"136": "A그룹 | 포트09 수동하강 상태",
"137": "A그룹 | 포트10 수동하강 상태",
"138": "A그룹 | 포트11 수동하강 상태",
"139": "A그룹 | 포트12 수동하강 상태",
"140": "A그룹 | 포트13 수동하강 상태",
"141": "A그룹 | 포트14 수동하강 상태",
"142": "A그룹 | 포트15 수동하강 상태",
"143": "A그룹 | 포트16 수동하강 상태",
"144": "A그룹 | 포트17 수동하강 상태",
"145": "A그룹 | 포트18 수동하강 상태",
"146": "A그룹 | 포트19 수동하강 상태",
"147": "A그룹 | 포트20 수동하강 상태",
"148": "B그룹 | 포트21 수동하강 상태",
"149": "B그룹 | 포트22 수동하강 상태",
"150": "B그룹 | 포트23 수동하강 상태",
"151": "B그룹 | 포트24 수동하강 상태",
"152": "B그룹 | 포트25 수동하강 상태",
"153": "B그룹 | 포트26 수동하강 상태",
"154": "B그룹 | 포트27 수동하강 상태",
"155": "B그룹 | 포트28 수동하강 상태",
"156": "B그룹 | 포트29 수동하강 상태",
"157": "B그룹 | 포트30 수동하강 상태",
"158": "B그룹 | 포트31 수동하강 상태",
"159": "B그룹 | 포트32 수동하강 상태",
"160": "B그룹 | 포트33 수동하강 상태",
"161": "B그룹 | 포트34 수동하강 상태",
"162": "B그룹 | 포트35 수동하강 상태",
"163": "B그룹 | 포트36 수동하강 상태",
"164": "B그룹 | 포트37 수동하강 상태",
"165": "B그룹 | 포트38 수동하강 상태",
"166": "B그룹 | 포트39 수동하강 상태",
"167": "B그룹 | 포트40 수동하강 상태",
"168": "A그룹 | 포트01 자동상승 상태",
"169": "A그룹 | 포트02 수동상승 상태",
"170": "A그룹 | 포트03 수동상승 상태",
"171": "A그룹 | 포트04 수동상승 상태",
"172": "A그룹 | 포트05 수동상승 상태",
"173": "A그룹 | 포트06 수동상승 상태",
"174": "A그룹 | 포트07 수동상승 상태",
"175": "A그룹 | 포트08 수동상승 상태",
"176": "A그룹 | 포트09 수동상승 상태",
"177": "A그룹 | 포트10 수동상승 상태",
"178": "A그룹 | 포트11 수동상승 상태",
"179": "A그룹 | 포트12 수동상승 상태",
"180": "A그룹 | 포트13 수동상승 상태",
"181": "A그룹 | 포트14 수동상승 상태",
"182": "A그룹 | 포트15 수동상승 상태",
"183": "A그룹 | 포트16 수동상승 상태",
"184": "A그룹 | 포트17 수동상승 상태",
"185": "A그룹 | 포트18 수동상승 상태",
"186": "A그룹 | 포트19 수동상승 상태",
"187": "A그룹 | 포트20 수동상승 상태",
"188": "B그룹 | 포트21 수동상승 상태",
"189": "B그룹 | 포트22 수동상승 상태",
"190": "B그룹 | 포트23 수동상승 상태",
"191": "B그룹 | 포트24 수동상승 상태",
"192": "B그룹 | 포트25 수동상승 상태",
"193": "B그룹 | 포트26 수동상승 상태",
"194": "B그룹 | 포트27 수동상승 상태",
"195": "B그룹 | 포트28 수동상승 상태",
"196": "B그룹 | 포트29 수동상승 상태",
"197": "B그룹 | 포트30 수동상승 상태",
"198": "B그룹 | 포트31 수동상승 상태",
"199": "B그룹 | 포트32 수동상승 상태",
"200": "B그룹 | 포트33 수동상승 상태",
"201": "B그룹 | 포트34 수동상승 상태",
"202": "B그룹 | 포트35 수동상승 상태",
"203": "B그룹 | 포트36 수동상승 상태",
"204": "B그룹 | 포트37 수동상승 상태",
"205": "B그룹 | 포트38 수동상승 상태",
"206": "B그룹 | 포트39 수동상승 상태",
"207": "B그룹 | 포트40 수동상승 상태"
},
"d_read_area": {
"0": "A그룹 | 순환펌프 재작동 시간",
"1": "A그룹 | 커팅모터 속도",
"2": "A그룹 | 이송모터 자동 목표 이동량",
"3": "A그룹 | 이송모터 1Hz 기준 현재 이동량",
"4": "A그룹 | 이송모터 수동 목표 속도",
"5": "B그룹 | 순환펌프 재작동 시간",
"6": "B그룹 | 커팅모터 속도",
"7": "B그룹 | 이송모터 자동 목표 이동량",
"8": "B그룹 | 이송모터 1Hz 기준 현재 이동량",
"9": "B그룹 | 이송모터 수동 목표 속도"
},
"m_write_area": {
"0": "A그룹 | 인터락 리셋 버튼",
"1": "A그룹 | 전체그룹 활성화 ON 버튼",
"2": "A그룹 | 전체그룹 활성화 OFF 버튼",
"3": "A그룹 | 전체그룹 정지 버튼",
"4": "A그룹 | 함침,이송 그룹 ON 버튼",
"5": "A그룹 | 함침,이송 그룹 OFF 버튼",
"6": "A그룹 | 건조부 그룹 ON 버튼",
"7": "A그룹 | 건조부 그룹 OFF 버튼",
"8": "A그룹 | 커팅부 그룹 ON 버튼",
"9": "A그룹 | 커팅부 그룹 OFF 버튼",
"10": "A그룹 | 이송부 그룹 ON 버튼",
"11": "A그룹 | 이송부 그룹 OFF 버튼",
"12": "B그룹 | 인터락 리셋 버튼",
"13": "B그룹 | 전체그룹 활성화 ON 버튼",
"14": "B그룹 | 전체그룹 활성화 OFF 버튼",
"15": "B그룹 | 전체그룹 정지 버튼",
"16": "B그룹 | 함침,이송 그룹 ON 버튼",
"17": "B그룹 | 함침,이송 그룹 OFF 버튼",
"18": "B그룹 | 건조부 그룹 ON 버튼",
"19": "B그룹 | 건조부 그룹 OFF 버튼",
"20": "B그룹 | 커팅부 그룹 ON 버튼",
"21": "B그룹 | 커팅부 그룹 OFF 버튼",
"22": "B그룹 | 이송부 그룹 ON 버튼",
"23": "B그룹 | 이송부 그룹 OFF 버튼",
"24": "A그룹 | 함침부 자동모드 ON 버튼",
"25": "A그룹 | 함침부 수동모드 ON 버튼",
"26": "A그룹 | 순환 펌프 작동 버튼",
"27": "A그룹 | 배출 펌프 작동 버튼",
"28": "B그룹 | 함침부 자동모드 ON 버튼",
"29": "B그룹 | 함침부 수동모드 ON 버튼",
"30": "B그룹 | 순환 펌프 작동 버튼",
"31": "B그룹 | 배출 펌프 작동 버튼",
"32": "A그룹 | 건조부 자동모드 ON 버튼",
"33": "A그룹 | 건조부 수동모드 ON 버튼",
"34": "A그룹 | 송풍기1 작동 버튼",
"35": "A그룹 | 히터 1 작동 버튼",
"36": "A그룹 | 히터 2 작동 버튼",
"37": "A그룹 | 히터 3 작동 버튼",
"38": "A그룹 | 히터 4 작동 버튼",
"39": "A그룹 | 히터 5 작동 버튼",
"40": "B그룹 | 건조부 자동모드 ON 버튼",
"41": "B그룹 | 건조부 수동모드 ON 버튼",
"42": "B그룹 | 송풍기2 작동 버튼",
"43": "B그룹 | 히터 6 작동 버튼",
"44": "B그룹 | 히터 7 작동 버튼",
"45": "B그룹 | 히터 8 작동 버튼",
"46": "B그룹 | 히터 9 작동 버튼",
"47": "B그룹 | 히터 10 작동 버튼",
"48": "A그룹 | 커팅모터 작동 버튼",
"49": "B그룹 | 커팅모터 작동 버튼",
"50": "A그룹 | 이송부 자동모드 ON 버튼",
"51": "A그룹 | 이송부 수동모드 ON 버튼",
"52": "A그룹 | 이송모터 자동시작 버튼",
"53": "A그룹 | 이송모터 자동정지 버튼",
"54": "A그룹 | 이송모터 수동 정방향 시작 버튼",
"55": "A그룹 | 이송모터 수동 역방향 시작 버튼",
"56": "A그룹 | 이송모터 수동 정지 버튼",
"57": "A그룹 | 이송모터 제로셋 버튼",
"58": "A그룹 | 이송모터 에러리셋 버튼",
"59": "B그룹 | 이송부 자동모드 ON 버튼",
"60": "B그룹 | 이송부 수동모드 ON 버튼",
"61": "B그룹 | 이송모터 자동시작 버튼",
"62": "B그룹 | 이송모터 자동정지 버튼",
"63": "B그룹 | 이송모터 수동 정방향 시작 버튼",
"64": "B그룹 | 이송모터 수동 역방향 시작 버튼",
"65": "B그룹 | 이송모터 수동 정지 버튼",
"66": "B그룹 | 이송모터 제로셋 버튼",
"67": "B그룹 | 이송모터 에러리셋 버튼",
"68": "A그룹 | 포트01 자동하강 버튼",
"69": "A그룹 | 포트02 자동하강 버튼",
"70": "A그룹 | 포트03 자동하강 버튼",
"71": "A그룹 | 포트04 자동하강 버튼",
"72": "A그룹 | 포트05 자동하강 버튼",
"73": "A그룹 | 포트06 자동하강 버튼",
"74": "A그룹 | 포트07 자동하강 버튼",
"75": "A그룹 | 포트08 자동하강 버튼",
"76": "A그룹 | 포트09 자동하강 버튼",
"77": "A그룹 | 포트10 자동하강 버튼",
"78": "A그룹 | 포트11 자동하강 버튼",
"79": "A그룹 | 포트12 자동하강 버튼",
"80": "A그룹 | 포트13 자동하강 버튼",
"81": "A그룹 | 포트14 자동하강 버튼",
"82": "A그룹 | 포트15 자동하강 버튼",
"83": "A그룹 | 포트16 자동하강 버튼",
"84": "A그룹 | 포트17 자동하강 버튼",
"85": "A그룹 | 포트18 자동하강 버튼",
"86": "A그룹 | 포트19 자동하강 버튼",
"87": "A그룹 | 포트20 자동하강 버튼",
"88": "B그룹 | 포트21 자동하강 버튼",
"89": "B그룹 | 포트22 자동하강 버튼",
"90": "B그룹 | 포트23 자동하강 버튼",
"91": "B그룹 | 포트24 자동하강 버튼",
"92": "B그룹 | 포트25 자동하강 버튼",
"93": "B그룹 | 포트26 자동하강 버튼",
"94": "B그룹 | 포트27 자동하강 버튼",
"95": "B그룹 | 포트28 자동하강 버튼",
"96": "B그룹 | 포트29 자동하강 버튼",
"97": "B그룹 | 포트30 자동하강 버튼",
"98": "B그룹 | 포트31 자동하강 버튼",
"99": "B그룹 | 포트32 자동하강 버튼",
"100": "B그룹 | 포트33 자동하강 버튼",
"101": "B그룹 | 포트34 자동하강 버튼",
"102": "B그룹 | 포트35 자동하강 버튼",
"103": "B그룹 | 포트36 자동하강 버튼",
"104": "B그룹 | 포트37 자동하강 버튼",
"105": "B그룹 | 포트38 자동하강 버튼",
"106": "B그룹 | 포트39 자동하강 버튼",
"107": "B그룹 | 포트40 자동하강 버튼",
"108": "A그룹 | 포트01 수동하강 버튼",
"109": "A그룹 | 포트02 수동하강 버튼",
"110": "A그룹 | 포트03 수동하강 버튼",
"111": "A그룹 | 포트04 수동하강 버튼",
"112": "A그룹 | 포트05 수동하강 버튼",
"113": "A그룹 | 포트06 수동하강 버튼",
"114": "A그룹 | 포트07 수동하강 버튼",
"115": "A그룹 | 포트08 수동하강 버튼",
"116": "A그룹 | 포트09 수동하강 버튼",
"117": "A그룹 | 포트10 수동하강 버튼",
"118": "A그룹 | 포트11 수동하강 버튼",
"119": "A그룹 | 포트12 수동하강 버튼",
"120": "A그룹 | 포트13 수동하강 버튼",
"121": "A그룹 | 포트14 수동하강 버튼",
"122": "A그룹 | 포트15 수동하강 버튼",
"123": "A그룹 | 포트16 수동하강 버튼",
"124": "A그룹 | 포트17 수동하강 버튼",
"125": "A그룹 | 포트18 수동하강 버튼",
"126": "A그룹 | 포트19 수동하강 버튼",
"127": "A그룹 | 포트20 수동하강 버튼",
"128": "B그룹 | 포트21 수동하강 버튼",
"129": "B그룹 | 포트22 수동하강 버튼",
"130": "B그룹 | 포트23 수동하강 버튼",
"131": "B그룹 | 포트24 수동하강 버튼",
"132": "B그룹 | 포트25 수동하강 버튼",
"133": "B그룹 | 포트26 수동하강 버튼",
"134": "B그룹 | 포트27 수동하강 버튼",
"135": "B그룹 | 포트28 수동하강 버튼",
"136": "B그룹 | 포트29 수동하강 버튼",
"137": "B그룹 | 포트30 수동하강 버튼",
"138": "B그룹 | 포트31 수동하강 버튼",
"139": "B그룹 | 포트32 수동하강 버튼",
"140": "B그룹 | 포트33 수동하강 버튼",
"141": "B그룹 | 포트34 수동하강 버튼",
"142": "B그룹 | 포트35 수동하강 버튼",
"143": "B그룹 | 포트36 수동하강 버튼",
"144": "B그룹 | 포트37 수동하강 버튼",
"145": "B그룹 | 포트38 수동하강 버튼",
"146": "B그룹 | 포트39 수동하강 버튼",
"147": "B그룹 | 포트40 수동하강 버튼",
"148": "A그룹 | 포트01 수동상승 버튼",
"149": "A그룹 | 포트02 수동상승 버튼",
"150": "A그룹 | 포트03 수동상승 버튼",
"151": "A그룹 | 포트04 수동상승 버튼",
"152": "A그룹 | 포트05 수동상승 버튼",
"153": "A그룹 | 포트06 수동상승 버튼",
"154": "A그룹 | 포트07 수동상승 버튼",
"155": "A그룹 | 포트08 수동상승 버튼",
"156": "A그룹 | 포트09 수동상승 버튼",
"157": "A그룹 | 포트10 수동상승 버튼",
"158": "A그룹 | 포트11 수동상승 버튼",
"159": "A그룹 | 포트12 수동상승 버튼",
"160": "A그룹 | 포트13 수동상승 버튼",
"161": "A그룹 | 포트14 수동상승 버튼",
"162": "A그룹 | 포트15 수동상승 버튼",
"163": "A그룹 | 포트16 수동상승 버튼",
"164": "A그룹 | 포트17 수동상승 버튼",
"165": "A그룹 | 포트18 수동상승 버튼",
"166": "A그룹 | 포트19 수동상승 버튼",
"167": "A그룹 | 포트20 수동상승 버튼",
"168": "B그룹 | 포트21 수동상승 버튼",
"169": "B그룹 | 포트22 수동상승 버튼",
"170": "B그룹 | 포트23 수동상승 버튼",
"171": "B그룹 | 포트24 수동상승 버튼",
"172": "B그룹 | 포트25 수동상승 버튼",
"173": "B그룹 | 포트26 수동상승 버튼",
"174": "B그룹 | 포트27 수동상승 버튼",
"175": "B그룹 | 포트28 수동상승 버튼",
"176": "B그룹 | 포트29 수동상승 버튼",
"177": "B그룹 | 포트30 수동상승 버튼",
"178": "B그룹 | 포트31 수동상승 버튼",
"179": "B그룹 | 포트32 수동상승 버튼",
"180": "B그룹 | 포트33 수동상승 버튼",
"181": "B그룹 | 포트34 수동상승 버튼",
"182": "B그룹 | 포트35 수동상승 버튼",
"183": "B그룹 | 포트36 수동상승 버튼",
"184": "B그룹 | 포트37 수동상승 버튼",
"185": "B그룹 | 포트38 수동상승 버튼",
"186": "B그룹 | 포트39 수동상승 버튼",
"187": "B그룹 | 포트40 수동상승 버튼"
},
"d_write_area": {
"0": "A그룹 | 이송모터 자동 목표 이동량",
"1": "A그룹 | 이송모터 수동 목표 속도",
"2": "B그룹 | 이송모터 자동 목표 이동량",
"3": "B그룹 | 이송모터 수동 목표 속도"
}
}
+43
View File
@@ -0,0 +1,43 @@
import logging
from typing import List
from fastapi import WebSocket, WebSocketDisconnect
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# 활성화된 웹소켓 커넥션 목록 관리
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
logger.info(f"[WS] Client connected. Total active connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"[WS] Client disconnected. Total active connections: {len(self.active_connections)}")
async def broadcast(self, message: dict):
"""
접속한 모든 웹 브라우저 클라이언트에게 JSON 데이터를 전송합니다.
"""
if not self.active_connections:
return
logger.debug(f"[WS BROADCAST] Broadcasting message to {len(self.active_connections)} clients.")
disconnected_clients = []
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception as e:
logger.warning(f"[WS BROADCAST] Failed to send message to client, marked for cleanup: {e}")
disconnected_clients.append(connection)
# 오류가 난 비정상 커넥션 정리
for conn in disconnected_clients:
self.disconnect(conn)
# 전역 싱글톤 매니저 인스턴스
ws_manager = ConnectionManager()
+17
View File
@@ -0,0 +1,17 @@
# customer package marker
from .db_manager import (
add_customer_to_db,
update_customer_in_db,
delete_customer_from_db,
get_all_customers_from_db,
get_customer_details_from_db,
add_contact_to_db,
update_contact_in_db,
delete_contact_from_db,
add_sales_activity_to_db,
update_sales_activity_in_db,
delete_sales_activity_from_db,
get_customer_id_from_contact_id,
get_customer_id_from_sales_activity_id
)
from .router import router
+243
View File
@@ -0,0 +1,243 @@
import mysql.connector
from database.connection import get_db_connection
import logging
# 고객사 추가
def add_customer_to_db(customer_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO customers (name_kr, name_en, business_registration_number, contact_number, address)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
customer_data.get('name_kr'),
customer_data.get('name_en'),
customer_data.get('business_registration_number'),
customer_data.get('contact_number'),
customer_data.get('address')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding customer: {err}")
return None
# 고객사 수정
def update_customer_in_db(customer_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE customers SET
name_kr = %s,
name_en = %s,
business_registration_number = %s,
contact_number = %s,
address = %s
WHERE id = %s
"""
cursor.execute(sql, (
customer_data.get('name_kr'),
customer_data.get('name_en'),
customer_data.get('business_registration_number'),
customer_data.get('contact_number'),
customer_data.get('address'),
customer_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating customer: {err}")
return False
# 고객사 삭제
def delete_customer_from_db(customer_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM sales_activities WHERE customer_id = %s", (customer_id,))
cursor.execute("DELETE FROM contacts WHERE customer_id = %s", (customer_id,))
cursor.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting customer: {err}")
return False
# 모든 고객사 목록 조회
def get_all_customers_from_db():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT id, name_kr, name_en, business_registration_number, contact_number, address FROM customers")
return cursor.fetchall()
except mysql.connector.Error as err:
logging.error(f"Error getting all customers: {err}")
return []
# 특정 고객사 상세 정보 조회 (담당자, 영업활동 포함)
def get_customer_details_from_db(customer_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM customers WHERE id = %s", (customer_id,))
customer_data = cursor.fetchone()
if not customer_data:
return None
cursor.execute("SELECT * FROM contacts WHERE customer_id = %s", (customer_id,))
contacts_data = cursor.fetchall()
cursor.execute("SELECT * FROM sales_activities WHERE customer_id = %s", (customer_id,))
sales_activities_data = cursor.fetchall()
return {
"customer": customer_data,
"contacts": contacts_data,
"salesActivities": sales_activities_data
}
except mysql.connector.Error as err:
logging.error(f"Error getting customer details: {err}")
return None
# 담당자 추가
def add_contact_to_db(contact_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO contacts (customer_id, name, email, phone_number, position, work_location)
VALUES (%s, %s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
contact_data.get('customer_id'),
contact_data.get('name'),
contact_data.get('email'),
contact_data.get('phone_number'),
contact_data.get('position'),
contact_data.get('work_location')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding contact: {err}")
return None
# 담당자 수정
def update_contact_in_db(contact_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE contacts SET
name = %s, email = %s, phone_number = %s, position = %s, work_location = %s
WHERE id = %s
"""
cursor.execute(sql, (
contact_data.get('name'),
contact_data.get('email'),
contact_data.get('phone_number'),
contact_data.get('position'),
contact_data.get('work_location'),
contact_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating contact: {err}")
return False
# 담당자 삭제
def delete_contact_from_db(contact_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM contacts WHERE id = %s", (contact_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting contact: {err}")
return False
# 담당자 ID로 고객사 ID 조회
def get_customer_id_from_contact_id(contact_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT customer_id FROM contacts WHERE id = %s", (contact_id,))
result = cursor.fetchone()
return result['customer_id'] if result else None
except mysql.connector.Error as err:
logging.error(f"Error getting customer ID from contact: {err}")
return None
# 영업 활동 추가
def add_sales_activity_to_db(activity_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO sales_activities (customer_id, contact_id, activity_date, activity_type, memo)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
activity_data.get('customer_id'),
activity_data.get('contact_id'),
activity_data.get('activity_date'),
activity_data.get('activity_type'),
activity_data.get('memo')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding sales activity: {err}")
return None
# 영업 활동 수정
def update_sales_activity_in_db(activity_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE sales_activities SET
contact_id = %s, activity_date = %s, activity_type = %s, memo = %s
WHERE id = %s
"""
cursor.execute(sql, (
activity_data.get('contact_id'),
activity_data.get('activity_date'),
activity_data.get('activity_type'),
activity_data.get('memo'),
activity_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating sales activity: {err}")
return False
# 영업 활동 삭제
def delete_sales_activity_from_db(activity_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM sales_activities WHERE id = %s", (activity_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting sales activity: {err}")
return False
# 영업 활동 ID로 고객사 ID 조회
def get_customer_id_from_sales_activity_id(activity_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT customer_id FROM sales_activities WHERE id = %s", (activity_id,))
result = cursor.fetchone()
return result['customer_id'] if result else None
except mysql.connector.Error as err:
logging.error(f"Error getting customer ID from sales activity: {err}")
return None
+127
View File
@@ -0,0 +1,127 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
from .db_manager import (
add_customer_to_db,
update_customer_in_db,
delete_customer_from_db,
get_all_customers_from_db,
get_customer_details_from_db,
add_contact_to_db,
update_contact_in_db,
delete_contact_from_db,
add_sales_activity_to_db,
update_sales_activity_in_db,
delete_sales_activity_from_db
)
router = APIRouter(prefix="/customers", tags=["customers"])
# --- Pydantic Schemas ---
class CustomerSchema(BaseModel):
id: Optional[int] = None
name_kr: str
name_en: Optional[str] = None
business_registration_number: Optional[str] = None
contact_number: Optional[str] = None
address: Optional[str] = None
class ContactSchema(BaseModel):
id: Optional[int] = None
customer_id: int
name: str
email: Optional[str] = None
phone_number: Optional[str] = None
position: Optional[str] = None
work_location: Optional[str] = None
class SalesActivitySchema(BaseModel):
id: Optional[int] = None
customer_id: int
contact_id: Optional[int] = None
activity_date: str # YYYY-MM-DD
activity_type: str
memo: Optional[str] = None
# --- Customers APIs ---
@router.post("/", response_model=dict)
def create_customer(customer: CustomerSchema):
result = add_customer_to_db(customer.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create customer")
return {"id": result, "status": "success"}
@router.put("/{customer_id}", response_model=dict)
def update_customer(customer_id: int, customer: CustomerSchema):
data = customer.dict()
data['id'] = customer_id
success = update_customer_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update customer")
return {"status": "success"}
@router.delete("/{customer_id}", response_model=dict)
def delete_customer(customer_id: int):
success = delete_customer_from_db(customer_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete customer")
return {"status": "success"}
@router.get("/", response_model=List[CustomerSchema])
def list_customers():
return get_all_customers_from_db()
@router.get("/{customer_id}", response_model=dict)
def get_customer_details(customer_id: int):
details = get_customer_details_from_db(customer_id)
if not details:
raise HTTPException(status_code=404, detail="Customer not found")
return details
# --- Contacts APIs ---
@router.post("/contacts", response_model=dict)
def create_contact(contact: ContactSchema):
result = add_contact_to_db(contact.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create contact")
return {"id": result, "status": "success"}
@router.put("/contacts/{contact_id}", response_model=dict)
def update_contact(contact_id: int, contact: ContactSchema):
data = contact.dict()
data['id'] = contact_id
success = update_contact_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update contact")
return {"status": "success"}
@router.delete("/contacts/{contact_id}", response_model=dict)
def delete_contact(contact_id: int):
success = delete_contact_from_db(contact_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete contact")
return {"status": "success"}
# --- Sales Activities APIs ---
@router.post("/activities", response_model=dict)
def create_sales_activity(activity: SalesActivitySchema):
result = add_sales_activity_to_db(activity.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create sales activity")
return {"id": result, "status": "success"}
@router.put("/activities/{activity_id}", response_model=dict)
def update_sales_activity(activity_id: int, activity: SalesActivitySchema):
data = activity.dict()
data['id'] = activity_id
success = update_sales_activity_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update sales activity")
return {"status": "success"}
@router.delete("/activities/{activity_id}", response_model=dict)
def delete_sales_activity(activity_id: int):
success = delete_sales_activity_from_db(activity_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete sales activity")
return {"status": "success"}
+1
View File
@@ -0,0 +1 @@
# database package marker
+14
View File
@@ -0,0 +1,14 @@
import mysql.connector
# --- DB 설정 ---
DB_HOST = "localhost"
DB_PORT = 3306
DB_USER = "ctnt_root"
DB_PASSWORD = "Umsang6595!!"
DB_NAME = "carbon_cutting_mc_db"
from .pool import get_pooled_connection
def get_db_connection():
"""DB 연결 풀에서 연결을 획득합니다."""
return get_pooled_connection()
+38
View File
@@ -0,0 +1,38 @@
import mysql.connector.pooling
import logging
logger = logging.getLogger("db_pool")
DB_CONFIG = {
"host": "localhost",
"port": 3306,
"user": "ctnt_root",
"password": "Umsang6595!!",
"database": "carbon_cutting_mc_db",
"autocommit": True,
}
try:
dbpool = mysql.connector.pooling.MySQLConnectionPool(
pool_name="carbon_cutting_pool",
pool_size=5,
pool_reset_session=True,
**DB_CONFIG
)
logger.info("[DB POOL] Connection pool initialized (pool_size=5)")
except Exception as e:
logger.error(f"[DB POOL ERROR] Failed to initialize pool: {e}", exc_info=True)
dbpool = None
def get_pooled_connection():
"""풀에서 연결 획득"""
if dbpool is None:
raise RuntimeError("[DB POOL ERROR] Pool not initialized")
conn = dbpool.get_connection()
try:
# DB 세션이 만료되거나 끊어진 경우 자동 재연결 시도
conn.ping(reconnect=True, attempts=3, delay=1)
except Exception as e:
logger.warning(f"[DB POOL] Ping/Reconnect failed: {e}")
return conn
+12
View File
@@ -0,0 +1,12 @@
# equipment package marker
from .db_manager import (
add_equipment_to_db,
update_equipment_in_db,
delete_equipment_from_db,
get_all_equipment_from_db,
get_equipment_details_from_db,
save_to_db,
get_all_machine_statuses_joined,
get_machine_status_detail_joined
)
from .router import router
+365
View File
@@ -0,0 +1,365 @@
import mysql.connector
from database.connection import get_db_connection
import logging
import json
# 설비 추가
def add_equipment_to_db(equip_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
# DB 테이블에 만약 equipment_type 컬럼이 추가되었는지 파악
# 없을 경우 기본값을 채워 안전하게 동작
sql = """
INSERT INTO equipment (name, yarn_bobbin_count, dryer_type, power_consumption, max_cutting_speed, max_feed_speed)
VALUES (%s, %s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
equip_data.get('name'),
equip_data.get('yarn_bobbin_count'),
equip_data.get('dryer_type'),
equip_data.get('power_consumption'),
equip_data.get('max_cutting_speed'),
equip_data.get('max_feed_speed')
))
conn.commit()
new_equipment_id = cursor.lastrowid
if new_equipment_id:
init_status_sql = "INSERT INTO machine_status (equipment_id, device_state, sync_state) VALUES (%s, %s, %s)"
cursor.execute(init_status_sql, (new_equipment_id, "OFFLINE", "IDLE"))
conn.commit()
return new_equipment_id
except mysql.connector.Error as err:
logging.error(f"Error adding equipment: {err}")
return None
# 설비 수정
def update_equipment_in_db(equip_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE equipment SET
name = %s,
yarn_bobbin_count = %s,
dryer_type = %s,
power_consumption = %s,
max_cutting_speed = %s,
max_feed_speed = %s
WHERE id = %s
"""
cursor.execute(sql, (
equip_data.get('name'),
equip_data.get('yarn_bobbin_count'),
equip_data.get('dryer_type'),
equip_data.get('power_consumption'),
equip_data.get('max_cutting_speed'),
equip_data.get('max_feed_speed'),
equip_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating equipment: {err}")
return False
# 설비 삭제
def delete_equipment_from_db(equipment_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM machine_status WHERE equipment_id = %s", (equipment_id,))
cursor.execute("DELETE FROM plan_assignments WHERE equipment_id = %s", (equipment_id,))
cursor.execute("DELETE FROM equipment WHERE id = %s", (equipment_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting equipment: {err}")
return False
# 모든 설비 목록 조회
def get_all_equipment_from_db():
# --- DEBUG LOG ---
print("[DB DEBUG] get_all_equipment_from_db function starts.")
# -----------------
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
# machine_status 테이블과 JOIN하여 실시간 장치 상태(device_state)를 함께 조회
sql = """
SELECT e.*, ms.device_state
FROM equipment e
LEFT JOIN machine_status ms ON e.id = ms.equipment_id
"""
cursor.execute(sql)
results = cursor.fetchall()
# --- DEBUG LOG ---
print(f"[DB DEBUG] Query executed. Raw rows fetched: {len(results)}")
# -----------------
for r in results:
# device_state가 'ONLINE'이면 is_online을 True로 설정
r['is_online'] = (r.get('device_state') == 'ONLINE')
if 'equipment_type' not in r or not r['equipment_type']:
r['equipment_type'] = 'CC_MC_TYPE_A'
# 프론트엔드가 카드를 렌더링할 때 요구하는 필수 필드 보완 (방어 코드)
if 'type' not in r or not r['type']:
r['type'] = 'cutter'
if 'ip_address' not in r or not r['ip_address']:
r['ip_address'] = '127.0.0.1'
if 'line_prefix' not in r or not r['line_prefix']:
r['line_prefix'] = 'line1'
return results
except mysql.connector.Error as err:
logging.error(f"Error getting all equipment: {err}")
# --- DEBUG LOG ---
print(f"[DB DEBUG] Error fetching equipment: {err}")
# -----------------
return []
# 특정 설비 상세 정보 조회
def get_equipment_details_from_db(equipment_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM equipment WHERE id = %s", (equipment_id,))
equipment_data = cursor.fetchone()
if not equipment_data:
return None
if 'equipment_type' not in equipment_data:
equipment_data['equipment_type'] = 'CC_MC_TYPE_A'
return {
"equipment": equipment_data
}
except mysql.connector.Error as err:
logging.error(f"Error getting equipment details: {err}")
return None
# 실시간 설비 상태 DB에 저장/업데이트 (UPSERT)
def save_to_db(data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO machine_status (
equipment_id, timestamp, device_state, operation_mode,
yarn_sensors, dryer_states, temperatures, pump_states,
cutting_motor_is_running, cutting_motor_value,
transfer_motor_is_running, transfer_motor_value,
fan_state, door_sensors, water_level_sensor
) VALUES (
%(equipment_id)s, %(timestamp)s, %(device_state)s, %(operation_mode)s,
%(yarn_sensors)s, %(dryer_states)s, %(temperatures)s, %(pump_states)s,
%(cutting_motor_is_running)s, %(cutting_motor_value)s,
%(transfer_motor_is_running)s, %(transfer_motor_value)s,
%(fan_state)s, %(door_sensors)s, %(water_level_sensor)s
)
ON DUPLICATE KEY UPDATE
timestamp = VALUES(timestamp),
device_state = VALUES(device_state),
operation_mode = VALUES(operation_mode),
yarn_sensors = VALUES(yarn_sensors),
dryer_states = VALUES(dryer_states),
temperatures = VALUES(temperatures),
pump_states = VALUES(pump_states),
cutting_motor_is_running = VALUES(cutting_motor_is_running),
cutting_motor_value = VALUES(cutting_motor_value),
transfer_motor_is_running = VALUES(transfer_motor_is_running),
transfer_motor_value = VALUES(transfer_motor_value),
fan_state = VALUES(fan_state),
door_sensors = VALUES(door_sensors),
water_level_sensor = VALUES(water_level_sensor)
"""
params = data.copy()
for key, value in params.items():
if isinstance(value, list) or isinstance(value, dict):
params[key] = json.dumps(value)
cursor.execute(sql, params)
conn.commit()
logging.info(f"Successfully saved status for equipment_id: {data['equipment_id']}")
return True
except mysql.connector.Error as err:
logging.error(f"DB Error in save_to_db: {err}")
return False
except Exception as e:
logging.error(f"An unexpected error occurred in save_to_db: {e}")
return False
# 모든 설비의 현재 상태를 설비 정보와 JOIN하여 조회
def get_all_machine_statuses_joined():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
SELECT
e.id as equipment_id,
e.name,
ms.timestamp,
ms.device_state,
ms.d_read_g0,
ms.d_read_g1,
ms.d_read_g2
FROM equipment e
LEFT JOIN machine_status ms ON e.id = ms.equipment_id
ORDER BY e.id
"""
cursor.execute(sql)
results = cursor.fetchall()
for r in results:
if 'equipment_type' not in r:
r['equipment_type'] = 'CC_MC_TYPE_A'
return results
except mysql.connector.Error as err:
logging.error(f"Error in get_all_machine_statuses_joined: {err}")
return []
# 특정 설비의 상세 상태를 설비 정보와 JOIN하여 조회
def get_machine_status_detail_joined(equipment_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
SELECT
e.*,
ms.timestamp,
ms.device_state,
ms.sync_state,
ms.m_read_raw,
ms.m_write_raw,
ms.d_read_g0, ms.d_read_g1, ms.d_read_g2, ms.d_read_g3, ms.d_read_g4,
ms.d_read_g5, ms.d_read_g6, ms.d_read_g7, ms.d_read_g8, ms.d_read_g9,
ms.d_write_g0, ms.d_write_g1, ms.d_write_g2, ms.d_write_g3
FROM equipment e
LEFT JOIN machine_status ms ON e.id = ms.equipment_id
WHERE e.id = %s
"""
cursor.execute(sql, (equipment_id,))
result = cursor.fetchone()
if result:
if 'equipment_type' not in result:
result['equipment_type'] = 'CC_MC_TYPE_A'
# JSON 컬럼 자동 파싱 처리
for key in ["m_read_raw", "m_write_raw"]:
val = result.get(key)
if isinstance(val, str):
try:
result[key] = json.loads(val)
except json.JSONDecodeError:
logging.warning(f"Could not decode JSON for key {key}: {val}")
return result
except mysql.connector.Error as err:
logging.error(f"Error in get_machine_status_detail_joined: {err}")
return None
def update_m_write_in_db(equipment_id: int, payload: list):
"""M영역 쓰기 설정값을 machine_status의 m_write_raw JSON에 병합 업데이트합니다."""
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
# 기존 데이터 가져오기
cursor.execute("SELECT m_write_raw FROM machine_status WHERE equipment_id = %s", (equipment_id,))
row = cursor.fetchone()
m_write = {}
if row and row.get("m_write_raw"):
try:
m_write = json.loads(row["m_write_raw"])
except:
pass
# 새로운 값 병합
for item in payload:
if len(item) >= 2:
bit_id = str(item[0])
val = int(item[1])
m_write[bit_id] = val
# 업데이트 수행
sql = "UPDATE machine_status SET m_write_raw = %s WHERE equipment_id = %s"
cursor.execute(sql, (json.dumps(m_write), equipment_id))
conn.commit()
return True
except Exception as e:
logging.error(f"Error in update_m_write_in_db: {e}")
return False
def update_d_write_in_db(equipment_id: int, payload: list):
"""D영역 쓰기 설정값을 machine_status의 d_write_g0~g3 개별 컬럼에 업데이트합니다."""
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
for item in payload:
if len(item) >= 2:
group_id = int(item[0])
val = float(item[1])
if 0 <= group_id <= 3:
sql = f"UPDATE machine_status SET d_write_g{group_id} = %s WHERE equipment_id = %s"
cursor.execute(sql, (val, equipment_id))
conn.commit()
return True
except Exception as e:
logging.error(f"Error in update_d_write_in_db: {e}")
return False
def save_control_command(equipment_id: int, command_id: str, cmd_type: str, requested_value: list, status: str):
"""제어 명령 요청을 생성하여 control_commands 테이블에 기록합니다."""
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
sql = """
INSERT INTO control_commands (equipment_id, command_id, type, requested_value, command_status)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(sql, (equipment_id, command_id, cmd_type, json.dumps(requested_value), status))
conn.commit()
return True
except Exception as e:
logging.error(f"Error in save_control_command: {e}")
return False
def update_control_command_status(command_id: str, status: str, applied_value: list = None):
"""수신된 ACK 신호나 타임아웃에 기반하여 제어 명령 상태를 업데이트합니다."""
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
if applied_value is not None:
sql = """
UPDATE control_commands
SET command_status = %s, applied_value = %s, applied_at = CURRENT_TIMESTAMP
WHERE command_id = %s
"""
cursor.execute(sql, (status, json.dumps(applied_value), command_id))
elif status == "PLC_APPLIED":
sql = """
UPDATE control_commands
SET command_status = %s, applied_value = requested_value,
applied_at = CURRENT_TIMESTAMP
WHERE command_id = %s
"""
cursor.execute(sql, (status, command_id))
elif status in ("FAILED", "TIMEOUT"):
sql = """
UPDATE control_commands
SET command_status = %s, applied_at = CURRENT_TIMESTAMP
WHERE command_id = %s
"""
cursor.execute(sql, (status, command_id))
else:
cursor.execute(
"UPDATE control_commands SET command_status = %s WHERE command_id = %s",
(status, command_id)
)
conn.commit()
return True
except Exception as e:
logging.error(f"Error in update_control_command_status: {e}")
return False
+316
View File
@@ -0,0 +1,316 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import logging
import math
import uuid
from datetime import datetime
logger = logging.getLogger("equipment_router")
router = APIRouter(prefix="/equipment", tags=["equipment"])
class EquipInfo(BaseModel):
equip_id: str
equip_name: str
equip_type: str
description: Optional[str] = ""
class ControlRequestM(BaseModel):
bit: Optional[int] = None
value: int
from .db_manager import (
get_all_equipment_from_db,
get_equipment_details_from_db,
get_all_machine_statuses_joined,
get_machine_status_detail_joined,
save_control_command
)
def update_m_write_in_db(equipment_id: int, payload: list):
try:
from database.connection import get_db_connection
import json
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT m_write_raw FROM machine_status WHERE equipment_id = %s", (equipment_id,))
row = cursor.fetchone()
m_write = {}
if row and row.get("m_write_raw"):
try:
m_write = json.loads(row["m_write_raw"])
except:
pass
for item in payload:
if len(item) >= 2:
m_write[str(item[0])] = int(item[1])
cursor.execute(
"UPDATE machine_status SET m_write_raw = %s WHERE equipment_id = %s",
(json.dumps(m_write), equipment_id)
)
conn.commit()
return True
except Exception as e:
logger.error(f"[DB Error in update_m_write_in_db]: {e}")
return False
def update_d_write_in_db(equipment_id: int, payload: list):
try:
from database.connection import get_db_connection
with get_db_connection() as conn:
with conn.cursor() as cursor:
for item in payload:
if len(item) >= 2:
group_id = int(item[0])
val = float(item[1])
if 0 <= group_id <= 3:
cursor.execute(
f"UPDATE machine_status SET d_write_g{group_id} = %s WHERE equipment_id = %s",
(val, equipment_id)
)
conn.commit()
return True
except Exception as e:
logger.error(f"[DB Error in update_d_write_in_db]: {e}")
return False
def _get_line_prefix_by_id(equipment_id: int) -> str:
if equipment_id == 8:
return "line1"
return f"line{equipment_id}"
def _ensure_equipment_is_ready(equipment_id: int):
status = get_machine_status_detail_joined(equipment_id)
if not status:
raise HTTPException(status_code=404, detail="Machine status detail not found")
if status.get("sync_state") == "SYNCING" or status.get("device_state") != "ONLINE":
raise HTTPException(status_code=423, detail="Equipment initialization is in progress")
@router.get("", response_model=List[dict])
def list_equipment_root():
return get_all_equipment_from_db()
@router.get("/status/all", response_model=List[dict])
def list_machine_statuses():
return get_all_machine_statuses_joined()
@router.get("/monitoring/metrics", response_model=dict)
def get_performance_metrics():
from monitoring.metrics import get_metrics_summary
return get_metrics_summary()
@router.get("/{equipment_id}", response_model=dict)
def get_equipment_details(equipment_id: int):
details = get_equipment_details_from_db(equipment_id)
if not details:
raise HTTPException(status_code=404, detail="Equipment not found")
return details
@router.get("/{equipment_id}/status", response_model=dict)
def get_machine_status_detail(equipment_id: int):
status = get_machine_status_detail_joined(equipment_id)
if not status:
raise HTTPException(status_code=404, detail="Machine status detail not found")
return status
@router.post("/{equipment_id}/control/m", response_model=dict, status_code=202)
def send_momentary_control_m(equipment_id: int, payload: List[List[int]]):
from communication.mqtt_client import _client
import json
logger.info(f"[REST API WRITE M] Started. equipment_id: {equipment_id} | Payload: {payload}")
_ensure_equipment_is_ready(equipment_id)
if not payload:
raise HTTPException(status_code=400, detail="Empty payload not allowed")
seen_ids = set()
for item in payload:
if len(item) < 2:
raise HTTPException(status_code=400, detail="Invalid payload format: each item must be [id, value]")
bit_id, val = item[0], item[1]
if bit_id in seen_ids:
raise HTTPException(status_code=400, detail=f"Duplicate bit ID in payload: {bit_id}")
seen_ids.add(bit_id)
if not (0 <= bit_id <= 187):
raise HTTPException(status_code=400, detail=f"M-bit ID {bit_id} out of bounds (0~187)")
if val not in (0, 1):
raise HTTPException(status_code=400, detail=f"Invalid M-bit value {val} (must be 0 or 1)")
if _client is None or not _client.is_connected():
logger.error("[REST API WRITE M ERROR] MQTT client is offline")
raise HTTPException(status_code=503, detail="MQTT broker connection is offline")
command_id = str(uuid.uuid4())
db_ok = save_control_command(equipment_id, command_id, "m_write", payload, "ACCEPTED")
if not db_ok:
logger.error("[REST API WRITE M ERROR] Failed to save control command in database")
raise HTTPException(status_code=500, detail="Database transaction failed (Command not queued)")
ok = update_m_write_in_db(equipment_id, payload)
if not ok:
logger.error("[REST API WRITE M ERROR] Failed to update machine status write buffer in database")
from .db_manager import update_control_command_status
update_control_command_status(command_id, "FAILED")
raise HTTPException(status_code=500, detail="Database write buffer update failed")
prefix = _get_line_prefix_by_id(equipment_id)
topic = f"/{prefix}/m/out"
envelope = {
"command_id": command_id,
"type": "m_write",
"items": payload,
"requested_at": datetime.utcnow().isoformat()
}
try:
msg_payload = json.dumps(envelope)
result = _client.publish(topic, msg_payload, qos=1)
if result.rc != 0:
raise Exception(f"Paho MQTT publish returned error code: {result.rc}")
logger.info(f"[REST API WRITE M SUCCESS] Published to topic: {topic} | Payload: {msg_payload}")
except Exception as e:
logger.error(f"[REST API WRITE M MQTT ERROR] Publish failed: {e}", exc_info=True)
from .db_manager import update_control_command_status
update_control_command_status(command_id, "FAILED")
raise HTTPException(status_code=500, detail=f"MQTT publish failed: {e}")
return {
"status": "accepted",
"command_id": command_id,
"message": "Command queued, ACK pending"
}
@router.post("/{equipment_id}/control/d", response_model=dict, status_code=202)
def send_register_control_d(equipment_id: int, payload: List[List[int]]):
from communication.mqtt_client import _client
import json
logger.info(f"[REST API WRITE D] Started. equipment_id: {equipment_id} | Payload: {payload}")
_ensure_equipment_is_ready(equipment_id)
if not payload:
raise HTTPException(status_code=400, detail="Empty payload not allowed")
float_payload = []
seen_ids = set()
for item in payload:
if len(item) < 2:
raise HTTPException(status_code=400, detail="Invalid payload format: each item must be [id, value]")
group_id, val = int(item[0]), float(item[1])
if group_id in seen_ids:
raise HTTPException(status_code=400, detail=f"Duplicate group ID in payload: {group_id}")
seen_ids.add(group_id)
if not (0 <= group_id <= 3):
raise HTTPException(status_code=400, detail=f"D-group ID {group_id} out of bounds (0~3)")
if not math.isfinite(val):
raise HTTPException(status_code=400, detail=f"Invalid REAL value {val}")
float_payload.append([group_id, val])
if _client is None or not _client.is_connected():
logger.error("[REST API WRITE D ERROR] MQTT client is offline")
raise HTTPException(status_code=503, detail="MQTT broker connection is offline")
command_id = str(uuid.uuid4())
db_ok = save_control_command(equipment_id, command_id, "d_write", float_payload, "ACCEPTED")
if not db_ok:
logger.error("[REST API WRITE D ERROR] Failed to save control command in database")
raise HTTPException(status_code=500, detail="Database transaction failed (Command not queued)")
ok = update_d_write_in_db(equipment_id, float_payload)
if not ok:
logger.error("[REST API WRITE D ERROR] Failed to update machine status write buffer in database")
from .db_manager import update_control_command_status
update_control_command_status(command_id, "FAILED")
raise HTTPException(status_code=500, detail="Database write buffer update failed")
prefix = _get_line_prefix_by_id(equipment_id)
topic = f"/{prefix}/d/out"
envelope = {
"command_id": command_id,
"type": "d_write",
"items": float_payload,
"requested_at": datetime.utcnow().isoformat()
}
try:
msg_payload = json.dumps(envelope)
result = _client.publish(topic, msg_payload, qos=1)
if result.rc != 0:
raise Exception(f"Paho MQTT publish returned error code: {result.rc}")
logger.info(f"[REST API WRITE D SUCCESS] Published to topic: {topic} | Payload: {msg_payload}")
except Exception as e:
logger.error(f"[REST API WRITE D MQTT ERROR] Publish failed: {e}", exc_info=True)
from .db_manager import update_control_command_status
update_control_command_status(command_id, "FAILED")
raise HTTPException(status_code=500, detail=f"MQTT publish failed: {e}")
return {
"status": "accepted",
"command_id": command_id,
"message": "Command queued, ACK pending"
}
@router.post("/{equipment_id}/sync", response_model=dict)
def trigger_manual_sync(equipment_id: int):
from communication.mqtt_client import _client
import json
logger.info(f"[REST API SYNC] Manual Sync requested for equipment_id: {equipment_id}")
if _client is None or not _client.is_connected():
logger.error("[REST API SYNC ERROR] MQTT client is offline")
raise HTTPException(status_code=503, detail="MQTT broker connection is offline")
prefix = _get_line_prefix_by_id(equipment_id)
initial_m_write = json.dumps({str(i): 0 for i in range(188)})
try:
from database.connection import get_db_connection
with get_db_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(
"UPDATE machine_status SET m_write_raw = %s, device_state = 'SYNCING' WHERE equipment_id = %s",
(initial_m_write, equipment_id)
)
conn.commit()
logger.info(f"[REST API SYNC] Successfully cleared database status values for equipment_id: {equipment_id}")
except Exception as dbe:
logger.error(f"[REST API SYNC ERROR] DB status reset failed: {dbe}", exc_info=True)
raise HTTPException(status_code=500, detail="Database reset transaction failed")
m_reset_payload = [[i, 0] for i in range(188)]
try:
result_m = _client.publish(f"/{prefix}/m/out", json.dumps(m_reset_payload), qos=1)
if result_m.rc != 0:
raise Exception(f"M output reset publish failed with rc: {result_m.rc}")
logger.info(f"[REST API SYNC] Published M output RESET payload to /{prefix}/m/out")
except Exception as e:
logger.error(f"[REST API SYNC ERROR] M reset payload publish failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"MQTT reset publish failed: {e}")
cmd_topic = f"/{prefix}/cmd"
try:
result_cmd = _client.publish(cmd_topic, "sync", qos=1)
if result_cmd.rc != 0:
raise Exception(f"Sync command publish failed with rc: {result_cmd.rc}")
logger.info(f"[REST API SYNC SUCCESS] Published 'sync' command to topic: {cmd_topic}")
except Exception as e:
logger.error(f"[REST API SYNC ERROR] Sync command publish failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"MQTT sync command publish failed: {e}")
return {"status": "success", "reset_topic_m": f"/{prefix}/m/out", "reset_topic_d": "", "cmd_topic": cmd_topic}
+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 생산성 및 비용 시뮬레이터 (계산기)</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.analysis-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 992px) {
.analysis-grid {
grid-template-columns: 1fr;
}
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 600;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
/* 결과 박스 레이아웃 */
.result-section {
border-top: 1px solid var(--border-color);
padding-top: 20px;
margin-top: 20px;
}
.result-card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.result-card {
background-color: var(--overlay-subtle);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.result-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.result-value {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.result-value.profit {
color: var(--accent-green);
}
.result-value.cost {
color: var(--accent-orange);
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>생산성 및 비용 시뮬레이터 (계산/분석)</h1>
<p>기기 가동 물성 변수들을 조정하여 최종 생산 소요 기간 및 기대 회사 마진을 미리 측정합니다.</p>
</div>
</header>
<div class="analysis-grid">
<!-- 가동 물성 입력 카드 -->
<div class="card">
<h2 class="section-title" style="margin-top:0;">물성 변수 입력</h2>
<form id="simulator-form">
<div class="form-group">
<label for="sim-yarn">사용 예정 원사 자재 (재고) *</label>
<select id="sim-yarn" class="form-control" required></select>
</div>
<div class="form-group">
<label for="sim-bond">사용 예정 본드 자재 (선택)</label>
<select id="sim-bond" class="form-control"></select>
</div>
<div class="form-group">
<label for="sim-qty">목표 생산량 (kg) *</label>
<input type="number" id="sim-qty" value="1000" class="form-control" required>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="sim-dia">원사 선경 (㎛) *</label>
<input type="number" id="sim-dia" value="7.0" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="sim-k">K수 (K 번수) *</label>
<input type="number" id="sim-k" value="12" class="form-control" required>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="sim-ports">포트 수 (Ports) *</label>
<input type="number" id="sim-ports" value="40" class="form-control" required>
</div>
<div class="form-group">
<label for="sim-hz">사이클 타임 (Hz) *</label>
<input type="number" id="sim-hz" value="8.0" step="0.1" class="form-control" required>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="sim-cut">커팅 길이 (mm) *</label>
<input type="number" id="sim-cut" value="6.0" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="sim-hours">일 작업 시간 *</label>
<input type="number" id="sim-hours" value="16" class="form-control" required>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="sim-machines">투입 예정 설비 대수 *</label>
<input type="number" id="sim-machines" value="2" class="form-control" required>
</div>
<div class="form-group">
<label for="sim-bond-pct">본드 혼합비 (%)</label>
<input type="number" id="sim-bond-pct" value="3.0" step="0.1" class="form-control">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%; padding: 12px 0; font-weight:700;">시뮬레이션 가동 진단</button>
</form>
</div>
<!-- 시뮬레이션 산출 보고서 카드 -->
<div class="card" style="min-height:500px; display:flex; flex-direction:column; justify-content:space-between;">
<div>
<h2 class="section-title" style="margin-top:0; color:var(--accent-blue);">가동 연산 결과 보고서</h2>
<div class="result-card-grid">
<div class="result-card">
<span class="result-label">소요 작업일 (필요일수)</span>
<span class="result-value" id="res-days">- 일</span>
</div>
<div class="result-card">
<span class="result-label">총 가공비 (Processing Fee)</span>
<span class="result-value" id="res-fee">- 원</span>
</div>
<div class="result-card">
<span class="result-label">예상 총매출 (Estimated Sales)</span>
<span class="result-value" id="res-sales">- 원</span>
</div>
<div class="result-card">
<span class="result-label">회사 영업 이익 (Margin)</span>
<span class="result-value profit" id="res-margin">- 원</span>
</div>
</div>
<div class="result-section">
<h3 style="font-size: 14px; margin-bottom: 12px; color:var(--text-secondary);">원자재 소요 비용</h3>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;">
<div class="result-card">
<span class="result-label">원사 구매 소요 비용</span>
<span class="result-value cost" id="res-yarn-cost">- 원</span>
</div>
<div class="result-card">
<span class="result-label">본드 구매 소요 비용</span>
<span class="result-value cost" id="res-bond-cost">- 원</span>
</div>
</div>
</div>
</div>
<div style="margin-top:20px; font-size:12px; color:var(--text-muted); border-top:1px solid var(--border-color); padding-top:16px;">
※ 본 연산 시뮬레이터 결과치는 등록된 자재 매입 원장(Inventory)의 단가와 밀도 기준에 의해 자동으로 산출되며, 실제 생산 여건에 따라 오차가 있을 수 있습니다.
</div>
</div>
</div>
<!-- 만능 조절 & 건조 전력 계산기 그리드 -->
<div class="analysis-grid" style="margin-top: 30px;">
<!-- 만능 조절 계산기 -->
<div class="card">
<h2 class="section-title" style="margin-top:0; color:var(--accent-green);">만능 조절 계산기</h2>
<form id="adjustment-form">
<div class="form-group">
<label for="adj-raw-conc">원자재 본드 농도 (%) *</label>
<input type="number" id="adj-raw-conc" value="40" step="0.1" class="form-control" required>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="adj-curr-vol">현재 수조 부피 (L) *</label>
<input type="number" id="adj-curr-vol" value="100" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="adj-curr-conc">현재 수조 농도 (%) *</label>
<input type="number" id="adj-curr-conc" value="15" step="0.1" class="form-control" required>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="adj-tgt-vol">목표 부피 (L) *</label>
<input type="number" id="adj-tgt-vol" value="150" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="adj-tgt-conc">목표 농도 (%) *</label>
<input type="number" id="adj-tgt-conc" value="20" step="0.1" class="form-control" required>
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%; padding: 10px 0; font-weight:700; background-color:var(--accent-green); border:none;">조절 수량 연산</button>
</form>
<div class="result-section" id="adj-result-box" style="display:none;">
<h3 style="font-size: 13px; margin-bottom: 12px; color:var(--accent-green);">조절 연산 결과</h3>
<div style="display:flex; flex-direction:column; gap:6px; font-size:13px;" id="adj-results">
<!-- JS 결과 바인딩 -->
</div>
</div>
</div>
<!-- 전기 건조 필요 전력 계산기 -->
<div class="card">
<h2 class="section-title" style="margin-top:0; color:var(--accent-orange);">전기 건조 필요 전력 계산기</h2>
<form id="dryer-form">
<div class="form-group">
<label for="dry-speed">원사 이송 속도 (mm/s) *</label>
<input type="number" id="dry-speed" value="60" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="dry-conc">용액의 농도 (%) *</label>
<input type="number" id="dry-conc" value="20" step="0.1" class="form-control" required>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
<div class="form-group">
<label for="dry-usage">일간 용액 사용량 (L/day) *</label>
<input type="number" id="dry-usage" value="15" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="dry-loss">Loss Factor (%) *</label>
<input type="number" id="dry-loss" value="10" step="0.1" class="form-control" required>
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%; padding: 10px 0; font-weight:700; background-color:var(--accent-orange); border:none;">필요 전력 연산</button>
</form>
<div class="result-section" id="dry-result-box" style="display:none;">
<h3 style="font-size: 13px; margin-bottom: 12px; color:var(--accent-orange);">소요 전력 연산 결과</h3>
<div style="display:flex; flex-direction:column; gap:6px; font-size:13px;" id="dry-results">
<!-- JS 결과 바인딩 -->
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/analysis.js"></script>
</body>
</html>
+390
View File
@@ -0,0 +1,390 @@
/* --- Sleek Dark & Glassmorphism Design System --- */
/*
* 테마 색상은 CSS 변수로 통합 관리한다.
* :root = 다크 테마(기본 상수), [data-theme="light"] = 라이트 테마 오버라이드.
* data-theme 속성은 js/theme.js가 <html> 요소에 설정한다.
* 색상 하드코딩 금지 — 반드시 아래 시맨틱 변수를 사용할 것.
*/
:root {
/* 배경 / 표면 */
--bg-dark: #0b0f19;
--bg-card: #151c2c;
--border-color: rgba(255, 255, 255, 0.08);
--border-focus: #3b82f6;
/* 텍스트 */
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
/* 강조색 (accent) */
--accent-blue: #3b82f6;
--accent-blue-hover: #2563eb;
--accent-blue-glow: rgba(59, 130, 246, 0.35);
--accent-green: #10b981;
--accent-green-glow: rgba(16, 185, 129, 0.35);
--accent-red: #ef4444;
--accent-red-glow: rgba(239, 68, 68, 0.35);
--accent-orange: #f59e0b;
--accent-blue-soft: #60a5fa; /* 밝은 블루 텍스트 (plan.html 등) */
--accent-red-soft: #f87171; /* 밝은 레드 텍스트 (plan.html 등) */
/* 시맨틱 오버레이 / 상태 (테마별로 명암 반전) */
--overlay-subtle: rgba(255, 255, 255, 0.02); /* 아주 옅은 표면 틴트 */
--overlay-hover: rgba(255, 255, 255, 0.05); /* hover / 입력 배경 */
--overlay-strong: rgba(255, 255, 255, 0.08); /* 강한 hover / focus 배경 */
--overlay-border: rgba(255, 255, 255, 0.15); /* 강조 테두리 */
--nav-active-bg: rgba(59, 130, 246, 0.15); /* 활성 메뉴 배경 */
--card-shadow: rgba(0, 0, 0, 0.2); /* 카드 hover 그림자 */
--modal-backdrop: rgba(8, 15, 30, 0.82); /* 모달 뒷배경 */
--modal-bg: #111827; /* 모달 본체 배경 */
--text-on-accent: #ffffff; /* accent 위 텍스트(버튼 등) */
--toggle-off-bg: #374151; /* 토글 OFF 배경 */
--danger-solid: #dc2626; /* 삭제 버튼 등 솔리드 위험색 */
--badge-green-bg: rgba(16, 185, 129, 0.1);
--badge-green-border: rgba(16, 185, 129, 0.2);
--badge-red-bg: rgba(239, 68, 68, 0.1);
--badge-red-border: rgba(239, 68, 68, 0.2);
--sidebar-width: 260px;
--sidebar-collapsed-width: 70px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* --- Light Theme (기본값) --- */
/*
* 완전한 순백이 아니라 은은한 블루-그레이 톤을 사용해
* 다크 테마(남색 계열)와 통일감을 준다.
*/
[data-theme="light"] {
--bg-dark: #eef1f6; /* 페이지 배경: 옅은 블루-그레이 */
--bg-card: #ffffff; /* 카드/사이드바: 흰색 표면 */
--border-color: rgba(15, 23, 42, 0.10);
--border-focus: #3b82f6;
--text-primary: #1e293b; /* 진한 슬레이트 */
--text-secondary: #64748b;
--text-muted: #94a3b8;
--accent-blue: #3b82f6;
--accent-blue-hover: #2563eb;
--accent-blue-glow: rgba(59, 130, 246, 0.25);
--accent-green: #059669;
--accent-green-glow: rgba(16, 185, 129, 0.25);
--accent-red: #dc2626;
--accent-red-glow: rgba(239, 68, 68, 0.25);
--accent-orange: #d97706;
--accent-blue-soft: #2563eb;
--accent-red-soft: #dc2626;
/* 오버레이는 어두운 색 기반으로 반전 (밝은 배경 위에서 보이도록) */
--overlay-subtle: rgba(15, 23, 42, 0.02);
--overlay-hover: rgba(15, 23, 42, 0.04);
--overlay-strong: rgba(15, 23, 42, 0.07);
--overlay-border: rgba(15, 23, 42, 0.14);
--nav-active-bg: rgba(59, 130, 246, 0.12);
--card-shadow: rgba(15, 23, 42, 0.10);
--modal-backdrop: rgba(15, 23, 42, 0.45);
--modal-bg: #ffffff;
--text-on-accent: #ffffff;
--toggle-off-bg: #cbd5e1;
--danger-solid: #dc2626;
--badge-green-bg: rgba(5, 150, 105, 0.12);
--badge-green-border: rgba(5, 150, 105, 0.28);
--badge-red-bg: rgba(220, 38, 38, 0.10);
--badge-red-border: rgba(220, 38, 38, 0.24);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-dark);
color: var(--text-primary);
font-family: var(--font-family);
display: flex;
min-height: 100vh;
overflow-x: hidden;
}
/* --- Layout --- */
.app-container {
display: flex;
width: 100%;
}
/* --- Sidebar --- */
aside.sidebar {
width: var(--sidebar-width);
background-color: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 100;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-brand {
padding: 24px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
}
.brand-icon {
font-size: 24px;
}
.brand-name {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-blue-soft), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.5px;
}
.nav-links {
list-style: none;
padding: 20px 12px;
flex-grow: 1;
overflow-y: auto;
}
.nav-links li {
margin-bottom: 8px;
}
.nav-item-link {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
color: var(--text-secondary);
text-decoration: none;
border-radius: 8px;
transition: all 0.2s ease;
}
.nav-item-link:hover {
background-color: var(--overlay-hover);
color: var(--text-primary);
}
.nav-item-link.active {
background-color: var(--nav-active-bg);
color: var(--accent-blue);
font-weight: 600;
border-left: 3px solid var(--accent-blue);
}
.nav-icon {
font-size: 18px;
}
.nav-text {
font-size: 14px;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
}
.user-profile {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
font-size: 24px;
background: var(--overlay-hover);
padding: 8px;
border-radius: 50%;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-size: 14px;
font-weight: 600;
}
.user-role {
font-size: 11px;
color: var(--text-muted);
}
/* --- Main Content Area --- */
main.main-content {
margin-left: var(--sidebar-width);
width: calc(100% - var(--sidebar-width));
flex-grow: 1;
padding: 40px;
min-height: 100vh;
transition: margin-left 0.3s ease;
min-width: 0;
}
header.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title h1 {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
}
.page-title p {
color: var(--text-secondary);
font-size: 14px;
margin-top: 4px;
}
/* --- Responsive Layout (Grid) --- */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 30px;
}
.card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px var(--card-shadow);
border-color: var(--overlay-border);
}
/* --- Status Badges --- */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
}
.badge-online {
background-color: var(--badge-green-bg);
color: var(--accent-green);
border: 1px solid var(--badge-green-border);
}
.badge-offline {
background-color: var(--badge-red-bg);
color: var(--accent-red);
border: 1px solid var(--badge-red-border);
}
/* --- CSS Forms & UI Kit --- */
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.btn-primary {
background-color: var(--accent-blue);
color: var(--text-on-accent);
}
.btn-primary:hover {
background-color: var(--accent-blue-hover);
box-shadow: 0 0 12px var(--accent-blue-glow);
}
/* --- Common Form Inputs & Selects --- */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 600;
}
.form-control {
background-color: var(--overlay-hover) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
padding: 10px 14px !important;
border-radius: 8px !important;
font-size: 14px !important;
width: 100% !important;
transition: all 0.2s ease !important;
}
.form-control:focus {
outline: none !important;
border-color: var(--border-focus) !important;
background-color: var(--overlay-strong) !important;
}
select.form-control {
appearance: none !important;
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important;
background-position: right 14px center !important;
background-size: 16px !important;
padding-right: 40px !important;
}
select.form-control option {
background-color: var(--bg-card) !important;
color: var(--text-primary) !important;
}
/* 라이트 테마에서 select 화살표 색을 밝은 배경에 맞게 어둡게 */
[data-theme="light"] select.form-control {
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important;
}
/* --- Responsive Media Queries --- */
@media (max-width: 1200px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
aside.sidebar {
width: var(--sidebar-collapsed-width);
}
.brand-name, .nav-text, .user-info {
display: none;
}
main.main-content {
margin-left: var(--sidebar-collapsed-width);
padding: 20px;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
}
+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 고객사 관리</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.search-container {
display: flex;
gap: 12px;
width: 100%;
max-width: 400px;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 992px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.list-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.list-table th, .list-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.list-table th {
color: var(--text-secondary);
font-weight: 600;
}
.list-table tr {
cursor: pointer;
transition: background-color 0.2s;
}
.list-table tr:hover {
background-color: var(--overlay-subtle);
}
.list-table tr.active-row {
background-color: var(--nav-active-bg);
border-left: 3px solid var(--accent-blue);
}
/* 탭 구조 */
.tabs-header {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 16px;
}
.tab-btn {
background: none;
border: none;
color: var(--text-secondary);
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--accent-blue);
font-weight: 600;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: var(--accent-blue);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 30px var(--card-shadow);
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
}
.close-btn {
font-size: 24px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.btn-secondary {
background-color: var(--overlay-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--overlay-strong);
}
.btn-danger {
background-color: var(--accent-red);
color: var(--text-on-accent);
}
.btn-danger:hover {
background-color: var(--danger-solid);
box-shadow: 0 0 12px var(--accent-red-glow);
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>고객사 관리</h1>
<p>고객사 등록 정보, 담당자 연락망 및 거래 영업 활동 일지를 관리합니다.</p>
</div>
<button class="btn btn-primary" id="btn-add-customer">+ 고객사 추가</button>
</header>
<div class="grid-layout">
<!-- 왼쪽: 고객사 목록 -->
<div class="card">
<div class="header-row">
<h2 class="section-title" style="margin:0;">고객사 목록</h2>
<div class="search-container">
<input type="text" id="search-customer" class="form-control" placeholder="고객사명 검색...">
</div>
</div>
<div style="overflow-x: auto;">
<table class="list-table">
<thead>
<tr>
<th>고객사명(한글)</th>
<th>대표 번호</th>
<th>등록번호</th>
</tr>
</thead>
<tbody id="customer-list-body">
<tr>
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 30px 0;">데이터를 불러오는 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 오른쪽: 선택된 고객사 상세정보 -->
<div class="card" id="customer-detail-card" style="display: none;">
<div class="header-row" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
<div>
<h2 id="detail-name-kr" style="margin: 0; font-size: 20px; font-weight: 700;">고객사명</h2>
<p id="detail-name-en" style="color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0;">English Name</p>
</div>
<div>
<button class="btn btn-secondary" id="btn-edit-customer" style="padding: 6px 12px; font-size: 13px;">수정</button>
<button class="btn btn-danger" id="btn-delete-customer" style="padding: 6px 12px; font-size: 13px;">삭제</button>
</div>
</div>
<div style="margin-bottom: 24px; font-size: 14px;">
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">사업자등록번호:</strong> <span id="detail-brn"></span></p>
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">대표 연락처:</strong> <span id="detail-contact"></span></p>
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">주소:</strong> <span id="detail-address"></span></p>
</div>
<!-- 탭메뉴 -->
<div class="tabs-header">
<button class="tab-btn active" data-tab="tab-contacts">담당자 정보</button>
<button class="tab-btn" data-tab="tab-activities">영업/거래 일지</button>
</div>
<!-- 탭 1: 담당자 정보 -->
<div id="tab-contacts" class="tab-content active">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="font-size: 15px; font-weight: 600;">담당자 연락처 목록</h3>
<button class="btn btn-primary" id="btn-add-contact" style="padding: 6px 12px; font-size: 12px;">+ 담당자 추가</button>
</div>
<div style="overflow-x: auto;">
<table class="list-table" style="font-size: 13px;">
<thead>
<tr>
<th>이름</th>
<th>직급/소속</th>
<th>연락처</th>
<th>이메일</th>
<th>관리</th>
</tr>
</thead>
<tbody id="contact-list-body">
<!-- 담당자 정보 리스트 -->
</tbody>
</table>
</div>
</div>
<!-- 탭 2: 영업 활동 기록 -->
<div id="tab-activities" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="font-size: 15px; font-weight: 600;">활동 내역 이력</h3>
<button class="btn btn-primary" id="btn-add-activity" style="padding: 6px 12px; font-size: 12px;">+ 일지 추가</button>
</div>
<div style="overflow-x: auto;">
<table class="list-table" style="font-size: 13px;">
<thead>
<tr>
<th>날짜</th>
<th>활동 구분</th>
<th>상세 내용 및 메모</th>
<th>관리</th>
</tr>
</thead>
<tbody id="activity-list-body">
<!-- 영업 활동 이력 리스트 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 상세 대기 안내 카드 -->
<div class="card" id="customer-placeholder-card" style="display: flex; align-items: center; justify-content: center; height: 300px; color: var(--text-secondary);">
<div>왼쪽 목록에서 고객사를 선택하시면 상세 내역을 확인할 수 있습니다.</div>
</div>
</div>
</main>
</div>
<!-- 고객사 추가/수정 모달 -->
<div id="customer-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="customer-modal-title">고객사 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="customer-form">
<input type="hidden" id="customer-id">
<div class="form-group">
<label for="cust-name-kr">고객사 한글명 *</label>
<input type="text" id="cust-name-kr" class="form-control" required>
</div>
<div class="form-group">
<label for="cust-name-en">고객사 영문명</label>
<input type="text" id="cust-name-en" class="form-control">
</div>
<div class="form-group">
<label for="cust-brn">사업자등록번호</label>
<input type="text" id="cust-brn" class="form-control" placeholder="123-45-67890">
</div>
<div class="form-group">
<label for="cust-contact">대표 연락처</label>
<input type="text" id="cust-contact" class="form-control" placeholder="02-1234-5678">
</div>
<div class="form-group">
<label for="cust-address">회사 주소</label>
<input type="text" id="cust-address" class="form-control">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 담당자 추가/수정 모달 -->
<div id="contact-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="contact-modal-title">담당자 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="contact-form">
<input type="hidden" id="contact-id">
<div class="form-group">
<label for="cont-name">담당자 이름 *</label>
<input type="text" id="cont-name" class="form-control" required>
</div>
<div class="form-group">
<label for="cont-position">직급/부서</label>
<input type="text" id="cont-position" class="form-control" placeholder="예: 구매팀 과장">
</div>
<div class="form-group">
<label for="cont-phone">연락처</label>
<input type="text" id="cont-phone" class="form-control" placeholder="010-1234-5678">
</div>
<div class="form-group">
<label for="cont-email">이메일 주소</label>
<input type="email" id="cont-email" class="form-control">
</div>
<div class="form-group">
<label for="cont-location">근무처/사무실 위치</label>
<input type="text" id="cont-location" class="form-control" placeholder="예: 본사 2층">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 영업 활동 추가/수정 모달 -->
<div id="activity-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="activity-modal-title">활동 일지 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="activity-form">
<input type="hidden" id="activity-id">
<div class="form-group">
<label for="act-date">활동 날짜 *</label>
<input type="date" id="act-date" class="form-control" required>
</div>
<div class="form-group">
<label for="act-type">활동 구분 *</label>
<select id="act-type" class="form-control" required>
<option value="미팅">미팅 / 방문</option>
<option value="전화상담">전화 상담</option>
<option value="메일전송">메일 / 문서 전송</option>
<option value="계약 체결">계약 체결</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label for="act-memo">메모 / 상담 내용 *</label>
<textarea id="act-memo" class="form-control" style="height: 100px; resize: none;" required></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/customer.js"></script>
</body>
</html>
+333
View File
@@ -0,0 +1,333 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 설비 기기 관리</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
}
.search-container {
display: flex;
align-items: center;
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0 14px;
width: 300px;
transition: all 0.2s ease;
}
.search-container:focus-within {
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.search-input {
background: transparent;
border: none;
color: var(--text-primary);
padding: 10px 0;
font-size: 14px;
width: 100%;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-icon {
margin-right: 8px;
color: var(--text-secondary);
}
/* Device Cards Grid */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-top: 10px;
}
.device-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 220px;
}
.device-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px var(--card-shadow);
border-color: var(--overlay-border);
}
.device-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.device-name {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.device-badge {
font-size: 11px;
font-weight: 700;
background-color: var(--nav-active-bg);
color: var(--accent-blue);
border: 1px solid var(--accent-blue-glow);
padding: 4px 8px;
border-radius: 6px;
}
.device-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
font-size: 13px;
color: var(--text-secondary);
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-label {
font-size: 11px;
color: var(--text-muted);
}
.detail-value {
font-weight: 600;
color: var(--text-primary);
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 95%;
max-width: 550px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 30px var(--card-shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.close-btn {
font-size: 24px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 576px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.form-group {
margin-bottom: 16px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 600;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.modal-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.action-right {
display: flex;
gap: 12px;
}
.btn-secondary {
background-color: var(--overlay-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--overlay-strong);
}
.btn-danger {
background-color: var(--badge-red-bg);
color: var(--accent-red);
border: 1px solid var(--badge-red-border);
}
.btn-danger:hover {
background-color: var(--badge-red-bg);
box-shadow: 0 0 10px var(--accent-red-glow);
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>설비 기기 마스터 관리</h1>
<p>공장에 등록된 카본 절단 설비의 기본 사양 및 전장 파라미터를 등록하고 편집합니다.</p>
</div>
</header>
<div class="header-row">
<div class="search-container">
<span class="search-icon">🔍</span>
<input type="text" id="search-input" class="search-input" placeholder="기기명 또는 건조타입 검색...">
</div>
<button class="btn btn-primary" id="btn-add-device"> 기기 추가</button>
</div>
<!-- 설비 목록 그리드 -->
<div class="device-grid" id="device-grid-container">
<!-- 설비 카드 동적 바인딩 -->
</div>
</main>
</div>
<!-- 기기 등록/수정 다이얼로그 모달 -->
<div id="device-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="modal-title">신규 기기 등록</h3>
<button class="close-btn" id="close-modal-x">&times;</button>
</div>
<form id="device-form">
<input type="hidden" id="device-id">
<div class="form-grid">
<div class="form-group full-width">
<label for="device-name">설비명 *</label>
<input type="text" id="device-name" class="form-control" placeholder="예: 양산 3호기" required>
</div>
<div class="form-group">
<label for="device-bobbins">원사 보빈 수 *</label>
<input type="number" id="device-bobbins" class="form-control" value="10" min="1" required>
</div>
<div class="form-group">
<label for="device-dryer">건조기 타입</label>
<input type="text" id="device-dryer" class="form-control" value="전기" placeholder="예: 전기, 열풍">
</div>
<div class="form-group">
<label for="device-power">사용 전력 (kW)</label>
<input type="number" id="device-power" class="form-control" value="5.0" step="0.1">
</div>
<div class="form-group">
<label for="device-max-cut">최대 절단 속도 (rpm)</label>
<input type="number" id="device-max-cut" class="form-control" value="100">
</div>
<div class="form-group">
<label for="device-max-feed">최대 피드 속도 (mm/s)</label>
<input type="number" id="device-max-feed" class="form-control" value="100">
</div>
<div class="form-group">
<label for="device-ip">IP 주소 *</label>
<input type="text" id="device-ip" class="form-control" value="192.168.1.50" required>
</div>
<div class="form-group">
<label for="device-prefix">MQTT 라인 Prefix *</label>
<input type="text" id="device-prefix" class="form-control" value="line1" required>
</div>
<div class="form-group">
<label for="device-status">동작 상태</label>
<select id="device-status" class="form-control">
<option value="available">가동 가능</option>
<option value="maintenance">점검 중</option>
<option value="broken">고장/정지</option>
</select>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-danger" id="btn-delete-device" style="display:none;">삭제</button>
<span id="delete-spacer" style="display:none; flex-grow:1;"></span>
<div class="action-right">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</div>
</form>
</div>
</div>
<!-- 공통 및 디바이스 관리 JS 연결 -->
<script src="js/common.js"></script>
<script src="js/device.js"></script>
</body>
</html>
+728
View File
@@ -0,0 +1,728 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설비 관리 및 실시간 HMI - ChemiFactory</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
/* 설비 카드 그리드 전용 스타일 */
.equip-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.equip-card {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 180px;
}
.equip-card.active-card {
border-color: var(--accent-blue);
box-shadow: 0 0 15px rgba(59, 130, 246, 0.15);
}
.equip-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.equip-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.equip-type {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.equip-status-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
}
/* HMI 디테일 분할 레이아웃 */
.hmi-layout {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 24px;
margin-top: 20px;
}
.hmi-section {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
}
.hmi-panel-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
/* 실시간 램프 그리드 */
.lamp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
}
.lamp-card {
background: var(--overlay-subtle);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.lamp-label {
font-size: 12px;
color: var(--text-secondary);
}
.lamp-indicator {
width: 14px;
height: 14px;
border-radius: 50%;
background-color: var(--toggle-off-bg); /* OFF 상태 */
transition: all 0.2s ease;
}
.lamp-indicator.active {
background-color: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green-glow);
}
/* 제어 버튼 폼 */
.control-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
#hmi-detail-panel {
position: relative;
}
.initialization-overlay {
position: absolute;
inset: 0;
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
background: var(--modal-backdrop);
backdrop-filter: blur(2px);
border-radius: 10px;
cursor: wait;
}
.initialization-overlay.active {
display: flex;
}
.initialization-overlay-message {
padding: 22px 30px;
border: 1px solid rgba(59, 130, 246, 0.65);
border-radius: 10px;
background: var(--modal-bg);
color: var(--text-primary);
text-align: center;
font-weight: 700;
line-height: 1.7;
box-shadow: 0 12px 30px var(--card-shadow);
}
.btn-control {
padding: 14px;
font-weight: 600;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--overlay-subtle);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.btn-control:hover {
background: var(--overlay-strong);
border-color: var(--text-secondary);
}
.btn-control:active {
transform: scale(0.98);
}
.btn-control.momentary-active {
background: rgba(59, 130, 246, 0.2) !important;
border-color: var(--accent-blue) !important;
box-shadow: 0 0 10px var(--accent-blue-glow);
}
/* D영역 수치 입출력 */
.register-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.register-row {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--overlay-subtle);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.register-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.lamp-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.input-inline {
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 6px;
width: 80px;
text-align: right;
font-weight: 600;
}
.input-inline:focus {
border-color: var(--accent-blue);
outline: none;
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 450px;
box-shadow: 0 10px 30px var(--card-shadow);
}
.modal-header {
font-size: 18px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-control {
width: 100%;
background-color: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 12px;
border-radius: 8px;
}
.form-control:focus {
border-color: var(--accent-blue);
outline: none;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
/* HMI Functional Groups Styling */
.group-tab-container {
display: flex;
gap: 12px;
margin-bottom: 24px;
background: var(--overlay-subtle);
padding: 6px;
border-radius: 8px;
border: 1px solid var(--border-color);
width: fit-content;
}
.btn-tab {
padding: 8px 20px;
font-size: 14px;
font-weight: 600;
background: transparent;
color: var(--text-secondary);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-tab.active {
background: var(--accent-blue);
color: var(--text-on-accent);
box-shadow: 0 0 10px var(--accent-blue-glow);
}
.hmi-groups-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 20px;
}
.hmi-group-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.hmi-group-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* HMI Button with Integrated Status Glow */
.btn-hmi {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--overlay-subtle);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-hmi:hover {
background: var(--overlay-strong);
}
.btn-hmi:active {
transform: scale(0.98);
}
/* HMI Lamp Base (Global) */
.hmi-lamp {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--toggle-off-bg);
box-shadow: none;
transition: all 0.2s ease;
}
.hmi-lamp.active {
background-color: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green-glow);
}
.hmi-lamp.active-red {
background-color: var(--accent-red);
box-shadow: 0 0 8px var(--accent-red-glow);
}
/* HMI Button with Integrated Status Glow */
.btn-hmi.active {
border-color: var(--accent-green);
}
.btn-hmi.active .hmi-lamp {
background-color: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green-glow);
}
.btn-hmi.active-red {
border-color: var(--accent-red);
}
.btn-hmi.active-red .hmi-lamp {
background-color: var(--accent-red);
box-shadow: 0 0 8px var(--accent-red-glow);
}
/* Compact Port Hanger Grid */
.port-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.port-cell {
padding: 12px 6px;
font-size: 13px;
font-weight: 600;
text-align: center;
background: var(--overlay-subtle);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.port-cell:hover {
background: var(--overlay-strong);
border-color: var(--text-secondary);
}
.port-cell.port-active-auto-down {
border-color: var(--accent-blue);
color: var(--accent-blue);
box-shadow: inset 0 0 8px rgba(59, 130, 246, 0.15);
}
.port-cell.port-active-manual-down {
border-color: var(--accent-orange);
color: var(--accent-orange);
box-shadow: inset 0 0 8px rgba(245, 158, 11, 0.15);
}
.port-cell.port-active-manual-up {
border-color: var(--accent-green);
color: var(--accent-green);
box-shadow: inset 0 0 8px rgba(16, 185, 129, 0.15);
}
/* Popover Overlay */
.hmi-popover {
position: absolute;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px;
box-shadow: 0 10px 25px var(--card-shadow);
display: none;
flex-direction: column;
gap: 6px;
z-index: 500;
width: 130px;
}
.hmi-popover button {
width: 100%;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
text-align: center;
}
@media (max-width: 992px) {
.hmi-groups-container {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1 id="main-page-title">설비 및 실시간 HMI 제어</h1>
<p id="main-page-desc">등록된 설비 리스트 확인 및 개별 HMI 원격 제어 패널</p>
</div>
</header>
<!-- 설비 목록 그리드 -->
<div class="equip-grid" id="equipment-cards-container">
<!-- 동적 로드 -->
</div>
<!-- HMI 상세 원격 제어 판넬 (설비 선택 시 노출) -->
<div id="hmi-detail-panel" style="display: none; margin-top: 15px;">
<div id="initialization-overlay" class="initialization-overlay" aria-hidden="true">
<div class="initialization-overlay-message">설비 초기화 중<br><span style="font-size:13px; font-weight:400; color:var(--text-secondary);">PLC 상태 동기화가 완료될 때까지 조작할 수 없습니다.</span></div>
</div>
<!-- A/B 그룹 제어 토글 탭, 목록 복귀 & 수동 초기화 버튼 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; gap: 12px; align-items: center;">
<button class="btn" style="background: var(--overlay-hover); border: 1px solid var(--border-color); color: var(--text-primary); padding: 8px 16px; font-weight: 600; font-size: 14px;" onclick="goBackToEquipmentList()">📋 목록</button>
<div class="group-tab-container" style="margin-bottom: 0;">
<button class="btn-tab active" id="btn-tab-a" onclick="setHmiActiveGroup('A')">A그룹</button>
<button class="btn-tab" id="btn-tab-b" onclick="setHmiActiveGroup('B')">B그룹</button>
</div>
</div>
<button class="btn" style="background: var(--nav-active-bg); border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 8px 16px; font-weight: 600; font-size: 14px;" onclick="triggerManualSync()">🔄 Sync 초기화</button>
</div>
<!-- 6대 기능 그룹 그리드 -->
<div class="hmi-groups-container">
<!-- 1그룹: 그룹 활성화 및 리셋 -->
<div class="hmi-group-card">
<div class="hmi-group-title">
<span>메인 제어</span>
<span class="badge badge-online" id="hmi-websocket-status">실시간 연결</span>
</div>
<!-- 안전 인터락 상태 모니터링 영역 (A-1) -->
<div class="interlock-status-container" style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; padding: 6px; background: var(--overlay-subtle); border-radius: 6px; margin-bottom: 12px; border: 1px solid var(--border-color); text-align: center;">
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
<span>비상정지</span>
<span class="hmi-lamp" id="lamp-estop"></span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
<span>도어(좌)</span>
<span class="hmi-lamp" id="lamp-door-left"></span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
<span>도어(우)</span>
<span class="hmi-lamp" id="lamp-door-right"></span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
<span>수위장치</span>
<span class="hmi-lamp" id="lamp-water-level"></span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
<span>히터장치</span>
<span class="hmi-lamp" id="lamp-heater-interlock"></span>
</div>
</div>
<div class="control-grid" style="grid-template-columns: 1fr 1fr; margin-bottom: 0;">
<button class="btn-hmi" id="btn-group-active-on" onclick="sendHmiBit(1, 'group_active_on')">전체 활성화 ON <span class="hmi-lamp" id="lamp-group-active-on"></span></button>
<button class="btn-hmi" id="btn-group-active-off" onclick="sendHmiBit(2, 'group_active_off')">전체 활성화 OFF <span class="hmi-lamp" id="lamp-group-active-off"></span></button>
</div>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:10px; margin-top:8px;">
<button class="btn-hmi btn-danger" style="background: rgba(239,68,68,0.15);" id="btn-group-stop" onclick="sendHmiBit(3, 'group_stop')">전체정지 <span class="hmi-lamp" id="lamp-group-stop"></span></button>
<button class="btn-hmi" style="justify-content:center; cursor:default;" id="btn-group-pause">일시정지 <span class="hmi-lamp" style="margin-left:8px;" id="lamp-group-pause"></span></button>
<button class="btn-hmi" style="justify-content:center; cursor:default;" id="btn-group-restart">재시작 <span class="hmi-lamp" style="margin-left:8px;" id="lamp-group-restart"></span></button>
</div>
<button class="btn btn-secondary btn-hmi" style="justify-content:center; gap:8px;" id="btn-interlock-reset" onclick="sendHmiBit(0, 'interlock_reset')">⚡ 인터락 리셋 <span class="hmi-lamp" id="lamp-interlock-reset"></span></button>
</div>
<!-- 2그룹: 함침부 작동 -->
<div class="hmi-group-card">
<div class="hmi-group-title">
<span>🧪 함침부</span>
<div style="display: flex; gap: 6px;">
<button class="btn-hmi" id="btn-impreg-hanger-on-g2" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(4, 'impreg_hanger_on')">ON <span class="hmi-lamp" id="lamp-impreg-hanger-on-g2"></span></button>
<button class="btn-hmi" id="btn-impreg-hanger-off-g2" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(5, 'impreg_hanger_off')">OFF <span class="hmi-lamp" id="lamp-impreg-hanger-off-g2"></span></button>
</div>
</div>
<!-- 기존 조작 버튼들 -->
<div style="display:flex; justify-content:space-between; gap:10px;">
<button class="btn-hmi" style="flex:1;" id="btn-impreg-auto" onclick="sendHmiBit(24, 'impreg_auto')">자동 모드 <span class="hmi-lamp" id="lamp-impreg-auto"></span></button>
<button class="btn-hmi" style="flex:1;" id="btn-impreg-manual" onclick="sendHmiBit(25, 'impreg_manual')">수동 모드 <span class="hmi-lamp" id="lamp-impreg-manual"></span></button>
</div>
<div style="display:flex; justify-content:space-between; gap:10px;">
<button class="btn-hmi" style="flex:1;" id="btn-pump-circ" onclick="sendHmiBit(26, 'pump_circ')">순환 펌프 <span class="hmi-lamp" id="lamp-pump-circ"></span></button>
<button class="btn-hmi" style="flex:1;" id="btn-pump-drain" onclick="sendHmiBit(27, 'pump_drain')">배출 펌프 <span class="hmi-lamp" id="lamp-pump-drain"></span></button>
</div>
<div class="register-row">
<span class="lamp-label">순환펌프 재작동 시간</span>
<span class="register-value" id="val-pump-circ-time">0 <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);"></span></span>
</div>
</div>
<!-- 3그룹: 건조기 작동 -->
<div class="hmi-group-card">
<div class="hmi-group-title">
<span>♨️ 건조부</span>
<div style="display: flex; gap: 6px;">
<button class="btn-hmi" id="btn-dryer-group-on" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(6, 'dryer_on')">ON <span class="hmi-lamp" id="lamp-dryer-on"></span></button>
<button class="btn-hmi" id="btn-dryer-group-off" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(7, 'dryer_off')">OFF <span class="hmi-lamp" id="lamp-dryer-off"></span></button>
</div>
</div>
<!-- 기존 조작 버튼들 -->
<div style="display:flex; justify-content:space-between; gap:10px;">
<button class="btn-hmi" style="flex:1;" id="btn-dryer-auto" onclick="sendHmiBit(32, 'dryer_auto')">자동 모드 <span class="hmi-lamp" id="lamp-dryer-auto"></span></button>
<button class="btn-hmi" style="flex:1;" id="btn-dryer-manual" onclick="sendHmiBit(33, 'dryer_manual')">수동 모드 <span class="hmi-lamp" id="lamp-dryer-manual"></span></button>
</div>
<button class="btn-hmi" id="btn-dryer-fan" onclick="sendHmiBit(34, 'dryer_fan')">송풍기 작동 <span class="hmi-lamp" id="lamp-dryer-fan"></span></button>
<div style="display:grid; grid-template-columns: repeat(5, 1fr); gap:6px;">
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h1" onclick="sendHmiBit(35, 'dryer_h1')">H1 <span class="hmi-lamp" id="lamp-dryer-h1"></span></button>
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h2" onclick="sendHmiBit(36, 'dryer_h2')">H2 <span class="hmi-lamp" id="lamp-dryer-h2"></span></button>
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h3" onclick="sendHmiBit(37, 'dryer_h3')">H3 <span class="hmi-lamp" id="lamp-dryer-h3"></span></button>
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h4" onclick="sendHmiBit(38, 'dryer_h4')">H4 <span class="hmi-lamp" id="lamp-dryer-h4"></span></button>
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h5" onclick="sendHmiBit(39, 'dryer_h5')">H5 <span class="hmi-lamp" id="lamp-dryer-h5"></span></button>
</div>
<div style="display:flex; justify-content:space-between; gap:10px;">
<div class="btn-hmi" style="flex:1; cursor:default; background:var(--overlay-subtle);" id="lamp-card-fan-trip">송풍기 트립 <span class="hmi-lamp" id="lamp-fan-trip"></span></div>
<div class="btn-hmi" style="flex:1; cursor:default; background:var(--overlay-subtle);" id="lamp-card-heater-req">히터 제어 <span class="hmi-lamp" id="lamp-heater-req"></span></div>
</div>
</div>
<!-- 4그룹: 커팅부 작동 -->
<div class="hmi-group-card">
<div class="hmi-group-title">
<span>✂️ 커팅부</span>
<div style="display: flex; gap: 6px;">
<button class="btn-hmi" id="btn-cutter-group-on" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(8, 'cutter_on')">ON <span class="hmi-lamp" id="lamp-cutter-on"></span></button>
<button class="btn-hmi" id="btn-cutter-group-off" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(9, 'cutter_off')">OFF <span class="hmi-lamp" id="lamp-cutter-off"></span></button>
</div>
</div>
<!-- 기존 조작 버튼들 -->
<button class="btn-hmi" id="btn-cutter-motor" onclick="sendHmiBit(48, 'cutter_motor')">커팅모터 작동 기동 <span class="hmi-lamp" id="lamp-cutter-motor-start"></span></button>
<div style="display:flex; justify-content:space-between; gap:10px;">
<div class="btn-hmi" style="flex:1; cursor:default; background:var(--overlay-subtle);">모터 활성 피드백 <span class="hmi-lamp" id="lamp-cutter-motor-active"></span></div>
</div>
<div class="register-row">
<span class="lamp-label">실시간 커팅모터 속도</span>
<span class="register-value" id="val-cutter-speed">0.0 <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">Hz</span></span>
</div>
</div>
<!-- 5그룹: 이송 작동 -->
<div class="hmi-group-card">
<div class="hmi-group-title">
<span>🔄 이송부</span>
<div style="display: flex; gap: 6px;">
<button class="btn-hmi" id="btn-transfer-group-on" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(10, 'transfer_on')">ON <span class="hmi-lamp" id="lamp-transfer-on"></span></button>
<button class="btn-hmi" id="btn-transfer-group-off" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(11, 'transfer_off')">OFF <span class="hmi-lamp" id="lamp-transfer-off"></span></button>
</div>
</div>
<!-- 기존 조작 버튼들 -->
<div style="display:flex; justify-content:space-between; gap:10px;">
<button class="btn-hmi" style="flex:1;" id="btn-transfer-auto" onclick="sendHmiBit(50, 'transfer_auto')">자동 모드 <span class="hmi-lamp" id="lamp-transfer-auto"></span></button>
<button class="btn-hmi" style="flex:1;" id="btn-transfer-manual" onclick="sendHmiBit(51, 'transfer_manual')">수동 모드 <span class="hmi-lamp" id="lamp-transfer-manual"></span></button>
</div>
<div style="display:flex; justify-content:space-between; gap:10px;">
<button class="btn-hmi" style="flex:1;" id="btn-transfer-auto-start" onclick="sendHmiBit(52, 'transfer_auto_start')">자동 시작 <span class="hmi-lamp" id="lamp-transfer-auto-start"></span></button>
<button class="btn-hmi" style="flex:1;" id="btn-transfer-auto-stop" onclick="sendHmiBit(53, 'transfer_auto_stop')">자동 정지 <span class="hmi-lamp" id="lamp-transfer-auto-stop"></span></button>
</div>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:8px;">
<button class="btn-hmi" style="padding:10px 4px; font-size:12px;" id="btn-transfer-fwd" onclick="sendHmiBit(54, 'transfer_fwd')">수동(정) <span class="hmi-lamp" id="lamp-transfer-fwd"></span></button>
<button class="btn-hmi" style="padding:10px 4px; font-size:12px;" id="btn-transfer-rev" onclick="sendHmiBit(55, 'transfer_rev')">수동(역) <span class="hmi-lamp" id="lamp-transfer-rev"></span></button>
<button class="btn-hmi btn-danger" style="padding:10px 4px; font-size:12px; background: rgba(239,68,68,0.15);" id="btn-transfer-stop" onclick="sendHmiBit(56, 'transfer_stop')">수동 정지 <span class="hmi-lamp" id="lamp-transfer-stop"></span></button>
</div>
<div style="display:flex; justify-content:space-between; gap:10px;">
<button class="btn-hmi" style="flex:1;" id="btn-transfer-zero" onclick="sendHmiBit(57, 'transfer_zero')">제로셋 <span class="hmi-lamp" id="lamp-transfer-zero"></span></button>
<button class="btn-hmi" style="flex:1;" id="btn-transfer-err-reset" onclick="sendHmiBit(58, 'transfer_err_reset')">에러리셋 <span class="hmi-lamp" id="lamp-transfer-err-reset"></span></button>
</div>
<div class="register-row">
<span class="lamp-label">현재 이동량 (1초 기준)</span>
<span class="register-value" id="val-transfer-cur-pos">0.0 <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">mm/s</span></span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px;">
<!-- 자동 목표 이동량 설정 -->
<div style="display: flex; flex-direction: column; gap: 6px; background: var(--overlay-subtle); border: 1px solid var(--border-color); padding: 8px 12px; border-radius: 8px;">
<span class="lamp-label" style="font-size: 13px; color: var(--text-primary); font-weight: 600;">자동 이동량 (mm/rev)</span>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; align-items: center; width: 100%;">
<span style="font-size: 13px; color: var(--text-primary); white-space: nowrap; text-align: left;">현: <span id="val-transfer-target-pos" style="color: var(--text-primary); font-weight: 600;">0</span></span>
<input type="number" class="input-inline" style="width: 100%; box-sizing: border-box; padding: 4px 6px; font-size: 12px; text-align: center;" id="input-transfer-target-pos" value="0">
</div>
<button class="btn btn-primary" style="width: 100%; padding: 6px; font-size: 11px; white-space: nowrap; margin-top: 2px;" onclick="sendHmiRegister(0, 'input-transfer-target-pos')">전송</button>
</div>
<!-- 수동 목표 속도 설정 -->
<div style="display: flex; flex-direction: column; gap: 6px; background: var(--overlay-subtle); border: 1px solid var(--border-color); padding: 8px 12px; border-radius: 8px;">
<span class="lamp-label" style="font-size: 13px; color: var(--text-primary); font-weight: 600;">수동 속도 (mm/s)</span>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; align-items: center; width: 100%;">
<span style="font-size: 13px; color: var(--text-primary); white-space: nowrap; text-align: left;">현: <span id="val-transfer-target-speed" style="color: var(--text-primary); font-weight: 600;">0</span></span>
<input type="number" class="input-inline" style="width: 100%; box-sizing: border-box; padding: 4px 6px; font-size: 12px; text-align: center;" id="input-transfer-target-speed" value="0">
</div>
<button class="btn btn-primary" style="width: 100%; padding: 6px; font-size: 11px; white-space: nowrap; margin-top: 2px;" onclick="sendHmiRegister(1, 'input-transfer-target-speed')">전송</button>
</div>
</div>
</div>
<!-- 6그룹: 행거부 작동 -->
<div class="hmi-group-card" style="position:relative;">
<div class="hmi-group-title">
<span>🪝 행거부</span>
<div style="display: flex; gap: 6px;">
<button class="btn-hmi" id="btn-impreg-hanger-on-g6" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(4, 'impreg_hanger_on')">ON <span class="hmi-lamp" id="lamp-impreg-hanger-on-g6"></span></button>
<button class="btn-hmi" id="btn-impreg-hanger-off-g6" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(5, 'impreg_hanger_off')">OFF <span class="hmi-lamp" id="lamp-impreg-hanger-off-g6"></span></button>
</div>
</div>
<!-- 기존 조작 영역 -->
<div class="port-grid" id="hanger-ports-container">
<!-- 20개 컴팩트 셀 JS로 동적 렌더링 -->
</div>
<!-- 공용 팝오버 오버레이 -->
<div class="hmi-popover" id="hanger-popover">
<h4 style="margin:0 0 6px 0; font-size:11px; color:var(--text-primary); text-align:center;" id="popover-title">P01 제어</h4>
<button class="btn btn-primary" id="btn-popover-auto-down">자동하강</button>
<button class="btn" style="background:rgba(245,158,11,0.2); border-color:var(--accent-orange); color:var(--accent-orange);" id="btn-popover-manual-down">수동하강</button>
<button class="btn" style="background:rgba(16,185,129,0.2); border-color:var(--accent-green); color:var(--accent-green);" id="btn-popover-manual-up">수동상승</button>
</div>
</div>
</div>
</main>
</div>
<!-- 신규 설비 등록 모달 -->
<div class="modal" id="add-equipment-modal">
<div class="modal-content">
<div class="modal-header">신규 설비 등록</div>
<form id="add-equipment-form" onsubmit="saveEquipment(event)">
<div class="form-group">
<label for="eq-id">설비 코드 (Unique ID)</label>
<input type="text" id="eq-id" class="form-control" placeholder="예: CC_MC_TYPE_A" required>
</div>
<div class="form-group">
<label for="eq-name">설비명</label>
<input type="text" id="eq-name" class="form-control" placeholder="예: 카본절단기 1호기" required>
</div>
<div class="form-group">
<label for="eq-type">설비 타입</label>
<input type="text" id="eq-type" class="form-control" placeholder="예: cutter" required>
</div>
<div class="form-group">
<label for="eq-ip">IP 주소</label>
<input type="text" id="eq-ip" class="form-control" placeholder="예: 192.168.1.100" required>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeAddModal()" style="background:transparent; color:var(--text-secondary);">취소</button>
<button type="submit" class="btn btn-primary">등록하기</button>
</div>
</form>
</div>
</div>
<!-- 스크립트 파일 연결 -->
<script src="js/common.js"></script>
<script src="js/equipment.js"></script>
</body>
</html>
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 통합 회사 관리 시스템</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
/* 추가 대시보드 전용 스타일 */
.stat-card {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 14px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
margin: 12px 0 6px 0;
color: var(--text-primary);
}
.stat-desc {
font-size: 12px;
color: var(--text-muted);
}
.section-title {
margin-top: 10px;
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.grid-row-2 {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
.list-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 14px;
}
.list-table th {
padding: 14px 16px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
font-weight: 600;
}
.list-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
.list-table tr:hover {
background-color: var(--overlay-subtle);
}
/* HMI 위젯 스타일 */
.hmi-mini-card {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--overlay-subtle);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 10px;
border: 1px solid var(--border-color);
}
.indicator-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.dot-green {
background-color: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green-glow);
}
.dot-red {
background-color: var(--accent-red);
box-shadow: 0 0 8px var(--accent-red-glow);
}
@media (max-width: 992px) {
.grid-row-2 {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 (common.js 가 동적으로 렌더링) -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>통합 대시보드</h1>
<p>ChemiFactory 사업장 현황 및 설비 모니터링 개요</p>
</div>
<div class="header-actions">
<span id="api-status-badge" class="badge badge-offline">연결 중...</span>
</div>
</header>
<!-- 주요 현황 요약 카드 -->
<div class="dashboard-grid">
<div class="card stat-card">
<div class="stat-header">
<span>설비 가동 상태</span>
<span>⚙️</span>
</div>
<div class="stat-value" id="active-equip-count">0 / 0</div>
<div class="stat-desc">동작중인 설비 / 전체 설비</div>
</div>
<div class="card stat-card">
<div class="stat-header">
<span>오늘의 생산량</span>
<span>📈</span>
</div>
<div class="stat-value" id="today-prod">0 kg</div>
<div class="stat-desc">금일 목표 대비 진행도 0%</div>
</div>
<div class="card stat-card">
<div class="stat-header">
<span>재고 경보</span>
<span>📦</span>
</div>
<div class="stat-value" id="low-inventory-count" style="color: var(--accent-orange);">0 건</div>
<div class="stat-desc">안전재고 미달 품목</div>
</div>
<div class="card stat-card">
<div class="stat-header">
<span>등록 고객사</span>
<span>🤝</span>
</div>
<div class="stat-value" id="customer-count">0 개사</div>
<div class="stat-desc">비즈니스 파트너사 목록</div>
</div>
</div>
<!-- 하단 2단 레이아웃 -->
<div class="grid-row-2">
<!-- 생산 계획 현황 -->
<div class="card">
<h2 class="section-title">오늘의 생산 지시 및 실적</h2>
<table class="list-table">
<thead>
<tr>
<th>계획 ID</th>
<th>설비명</th>
<th>목표 수량</th>
<th>현재 생산 실적</th>
<th>상태</th>
</tr>
</thead>
<tbody id="plan-list">
<tr>
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 30px 0;">등록된 생산 지시가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<!-- 설비 미니 상태 HMI 모니터 -->
<div class="card">
<h2 class="section-title">실시간 설비 모니터링 (HMI)</h2>
<div id="equipment-mini-list">
<div style="text-align: center; color: var(--text-muted); padding: 30px 0;">설비 정보를 불러오는 중...</div>
</div>
</div>
</div>
</main>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/dashboard.js"></script>
</body>
</html>
+399
View File
@@ -0,0 +1,399 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 재고 및 자재 관리</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.search-container {
display: flex;
gap: 12px;
width: 100%;
max-width: 400px;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.list-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.list-table th, .list-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.list-table th {
color: var(--text-secondary);
font-weight: 600;
}
.list-table tr:hover {
background-color: var(--overlay-subtle);
}
/* 탭 구조 */
.tabs-header {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 24px;
}
.tab-btn {
background: none;
border: none;
color: var(--text-secondary);
padding: 12px 20px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--accent-blue);
font-weight: 600;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: var(--accent-blue);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 경고 뱃지 */
.depleted-badge {
background-color: rgba(239, 68, 68, 0.1);
color: var(--accent-red);
border: 1px solid rgba(239, 68, 68, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.active-badge {
background-color: rgba(16, 185, 129, 0.1);
color: var(--accent-green);
border: 1px solid rgba(16, 185, 129, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 30px var(--card-shadow);
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
}
.close-btn {
font-size: 24px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.btn-secondary {
background-color: var(--overlay-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--overlay-strong);
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>재고 및 물성 관리</h1>
<p>자재 물성 데이터 및 원자재 실재고 입출고 상태를 실시간 통합 관리합니다.</p>
</div>
<div class="header-actions" style="display: flex; gap: 12px;">
<button class="btn btn-secondary" id="btn-add-material">+ 자재 규격 추가</button>
<button class="btn btn-primary" id="btn-add-inventory">+ 자재 신규 입고</button>
</div>
</header>
<!-- 탭 헤더 -->
<div class="tabs-header">
<button class="tab-btn active" data-tab="tab-inventory">실재고 현황 이력</button>
<button class="tab-btn" data-tab="tab-materials">자재 기본 규격 (물성)</button>
</div>
<!-- 탭 1: 실재고 입고 및 잔량 현황 -->
<div id="tab-inventory" class="tab-content active card">
<div class="header-row">
<h2 class="section-title" style="margin:0;">재고 원장 목록</h2>
<div class="search-container">
<input type="text" id="search-inventory" class="form-control" placeholder="자재코드, 명칭, 품번 검색...">
</div>
</div>
<div style="overflow-x: auto;">
<table class="list-table">
<thead>
<tr>
<th>입고일자</th>
<th>품명/품번</th>
<th>자재 구분</th>
<th>입고량 (kg)</th>
<th>사용량 (kg)</th>
<th>현재고 (kg)</th>
<th>매입 단가 (원)</th>
<th>공급처</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody id="inventory-list-body">
<tr>
<td colspan="10" style="text-align: center; color: var(--text-muted); padding: 30px 0;">재고 데이터를 로드하고 있습니다...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 탭 2: 자재 기본 물성 규격 정보 -->
<div id="tab-materials" class="tab-content card">
<div class="header-row">
<h2 class="section-title" style="margin:0;">자재 기준 정보 관리</h2>
<div class="search-container">
<input type="text" id="search-material" class="form-control" placeholder="자재명, 자재코드 검색...">
</div>
</div>
<div style="overflow-x: auto;">
<table class="list-table">
<thead>
<tr>
<th>자재 코드</th>
<th>자재 명칭</th>
<th>자재 구분</th>
<th>밀도 (g/cm³)</th>
<th>상세 비고</th>
<th>관리</th>
</tr>
</thead>
<tbody id="material-list-body">
<tr>
<td colspan="6" style="text-align: center; color: var(--text-muted); padding: 30px 0;">자재 물성 정보를 로드하고 있습니다...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<!-- 자재 물성 규격 추가/수정 모달 -->
<div id="material-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="material-modal-title">자재 규격 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="material-form">
<input type="hidden" id="material-id">
<div class="form-group">
<label for="mat-code">자재 코드 *</label>
<input type="text" id="mat-code" class="form-control" placeholder="예: YRN-CF-12K" required>
</div>
<div class="form-group">
<label for="mat-name">자재 명칭 *</label>
<input type="text" id="mat-name" class="form-control" placeholder="예: 카본원사 12K" required>
</div>
<div class="form-group">
<label for="mat-type">자재 구분 *</label>
<select id="mat-type" class="form-control" required>
<option value="Yarn">원사 (Yarn)</option>
<option value="Bond">본드 (Bond)</option>
<option value="Etc">기타 자재</option>
</select>
</div>
<div class="form-group">
<label for="mat-density">밀도 (Density) *</label>
<input type="number" id="mat-density" step="0.001" class="form-control" placeholder="g/cm³ 단위 수치" required>
</div>
<div class="form-group">
<label for="mat-remarks">비고 설명</label>
<input type="text" id="mat-remarks" class="form-control">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 재고 입고 추가/수정 모달 -->
<div id="inventory-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="inventory-modal-title">자재 신규 입고 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="inventory-form">
<input type="hidden" id="inventory-id">
<div class="form-group">
<label for="inv-material">대상 자재 규격 *</label>
<select id="inv-material" class="form-control" required>
<!-- 자재 규격 리스트 바인딩 -->
</select>
</div>
<div class="form-group">
<label for="inv-supplier">원료 공급처 *</label>
<select id="inv-supplier" class="form-control" required>
<!-- 공급사 리스트 바인딩 -->
</select>
</div>
<div class="form-group">
<label for="inv-date">입고 일자 *</label>
<input type="date" id="inv-date" class="form-control" required>
</div>
<div class="form-group">
<label for="inv-item-name">품명 (입고 세부 명칭)</label>
<input type="text" id="inv-item-name" class="form-control" placeholder="예: 카본 원사 24K A급">
</div>
<div class="form-group">
<label for="inv-item-num">품번 (Lot 번호 / 품격)</label>
<input type="text" id="inv-item-num" class="form-control" placeholder="예: LOT-2026-001">
</div>
<div class="form-group">
<label for="inv-qty">입고 수량 (kg) *</label>
<input type="number" id="inv-qty" step="0.1" class="form-control" min="0.1" required>
</div>
<div class="form-group" id="usage-group" style="display: none;">
<label for="inv-usage">사용 수량 (kg)</label>
<input type="number" id="inv-usage" step="0.1" class="form-control" min="0">
</div>
<div class="form-group">
<label for="inv-cost">매입 단가 (원/kg) *</label>
<input type="number" id="inv-cost" class="form-control" min="1" required>
</div>
<div class="form-group">
<label for="inv-remarks">입고 특이사항 비고</label>
<input type="text" id="inv-remarks" class="form-control">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">입고 저장</button>
</div>
</form>
</div>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/inventory.js"></script>
</body>
</html>
+205
View File
@@ -0,0 +1,205 @@
// ChemiFactory MES - Productivity Cost Simulator Frontend Logic
const API_INVENTORY = "/inventory";
let allInventories = [];
document.addEventListener("DOMContentLoaded", () => {
fetchInventoryDropdowns();
document.getElementById("simulator-form").addEventListener("submit", runSimulation);
document.getElementById("adjustment-form").addEventListener("submit", runAdjustmentCalculator);
document.getElementById("dryer-form").addEventListener("submit", runDryingCalculator);
});
async function fetchInventoryDropdowns() {
try {
const res = await fetch(`${API_INVENTORY}/`);
if (!res.ok) throw new Error("Failed to fetch inventory dropdowns");
allInventories = await res.json();
const yarns = allInventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1);
const bonds = allInventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1);
const yarnSelect = document.getElementById("sim-yarn");
yarnSelect.innerHTML = '<option value="">-- 원재료 원사 선택 --</option>';
yarns.forEach(y => {
yarnSelect.innerHTML += `<option value="${y.id}">${y.item_name || y.material_name} (재고: ${y.stock}kg)</option>`;
});
const bondSelect = document.getElementById("sim-bond");
bondSelect.innerHTML = '<option value="">-- 원재료 본드 선택 (선택사항) --</option>';
bonds.forEach(b => {
bondSelect.innerHTML += `<option value="${b.id}">${b.item_name || b.material_name} (재고: ${b.stock}kg)</option>`;
});
} catch (err) {
console.error(err);
}
}
async function runSimulation(e) {
e.preventDefault();
const yarnId = document.getElementById("sim-yarn").value;
const qty = document.getElementById("sim-qty").value;
if (!yarnId || !qty) {
alert("원사 자재와 목표 수량을 올바르게 지정하십시오.");
return;
}
const payload = {
inputs: {
yarn_inventory_id: parseInt(yarnId),
bond_inventory_id: parseInt(document.getElementById("sim-bond").value) || null,
yarn_diameter_micron: parseFloat(document.getElementById("sim-dia").value),
yarn_k: parseInt(document.getElementById("sim-k").value),
ports: parseInt(document.getElementById("sim-ports").value),
cycle_time_hz: parseFloat(document.getElementById("sim-hz").value),
cut_length_mm: parseFloat(document.getElementById("sim-cut").value),
work_hours_day: parseInt(document.getElementById("sim-hours").value),
work_days_month: 20.0,
num_machines: parseFloat(document.getElementById("sim-machines").value),
bond_percentage: parseFloat(document.getElementById("sim-bond-pct").value) || 3.0
},
targetProductionQuantity: parseFloat(qty)
};
try {
const res = await fetch(`${API_INVENTORY}/analysis/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || "시뮬레이션 연산 실패");
}
const data = await res.json();
// Bind 결과 데이터
document.getElementById("res-days").innerText = `${data.required_days_target.toFixed(1)}`;
document.getElementById("res-fee").innerText = `${Math.round(data.plan_processing_fee).toLocaleString()}`;
document.getElementById("res-sales").innerText = `${Math.round(data.plan_estimated_sales).toLocaleString()}`;
document.getElementById("res-margin").innerText = `${Math.round(data.plan_company_margin).toLocaleString()}`;
document.getElementById("res-yarn-cost").innerText = `${Math.round(data.plan_yarn_cost).toLocaleString()}`;
document.getElementById("res-bond-cost").innerText = `${Math.round(data.plan_bond_cost).toLocaleString()}`;
} catch (err) {
console.error(err);
alert(`시뮬레이션 가동 중 오류 발생: ${err.message}`);
}
}
function runAdjustmentCalculator(e) {
e.preventDefault();
const resultBox = document.getElementById("adj-result-box");
const resultsContainer = document.getElementById("adj-results");
resultBox.style.display = "none";
resultsContainer.innerHTML = "";
const cRaw = parseFloat(document.getElementById("adj-raw-conc").value);
const vCurr = parseFloat(document.getElementById("adj-curr-vol").value);
const cCurr = parseFloat(document.getElementById("adj-curr-conc").value);
const vTarget = parseFloat(document.getElementById("adj-tgt-vol").value);
const cTarget = parseFloat(document.getElementById("adj-tgt-conc").value);
if ([cRaw, vCurr, cCurr, vTarget, cTarget].some(val => isNaN(val))) {
alert("모든 필드에 유효한 숫자를 입력해 주십시오.");
return;
}
if (vTarget < vCurr) {
alert("목표 부피는 현재 부피보다 작을 수 없습니다.");
return;
}
if (cRaw <= 0 || cTarget < 0 || cCurr < 0 || cRaw > 100 || cTarget > 100 || cCurr > 100) {
alert("농도 값은 0%에서 100% 사이여야 합니다.");
return;
}
if (cTarget > cRaw) {
alert("목표 농도는 원자재 본드 농도보다 높을 수 없습니다.");
return;
}
const cRawDec = cRaw / 100;
const cCurrDec = cCurr / 100;
const cTargetDec = cTarget / 100;
const currentBond = vCurr * cCurrDec;
const targetBond = vTarget * cTargetDec;
if (targetBond < currentBond - 0.001) {
alert("계산 불가: 목표 본드량이 현재 본드량보다 적습니다.");
return;
}
let rawMaterialToAdd = 0;
if (cRawDec > 0) {
rawMaterialToAdd = (targetBond - currentBond) / cRawDec;
}
if (rawMaterialToAdd < 0) rawMaterialToAdd = 0;
const waterToAdd = vTarget - vCurr - rawMaterialToAdd;
if (waterToAdd < -0.001) {
if (cRaw - cTarget > 0) {
const suggestedMinVolume = vCurr * (cRaw - cCurr) / (cRaw - cTarget);
alert(`목표 부피(${vTarget.toFixed(1)}L)로는 목표 농도(${cTarget}%)를 맞출 수 없습니다.\n해당 농도를 맞추기 위한 최소 목표 부피는 ${suggestedMinVolume.toFixed(2)}L 입니다.`);
} else {
alert("계산 불가: 목표 농도가 원자재 농도와 같거나 높아 도달할 수 없습니다.");
}
return;
}
const finalPureBond = targetBond;
const finalTotalWater = vTarget - finalPureBond;
resultsContainer.innerHTML = `
<p style="color:var(--text-secondary);">• 추가할 물: <strong style="color:#ffffff;">${waterToAdd.toFixed(2)} L</strong></p>
<p style="color:var(--text-secondary);">• 추가할 원자재: <strong style="color:#ffffff;">${rawMaterialToAdd.toFixed(2)} L</strong></p>
<hr style="border-color:var(--border-color); margin: 6px 0;">
<p style="color:var(--text-secondary);">• 최종 용액 총 부피: <strong style="color:var(--accent-green);">${vTarget.toFixed(1)} L</strong></p>
<p style="color:var(--text-muted); font-size:12px; margin-left: 10px;">- 순수 본드 성분: ${finalPureBond.toFixed(2)} L</p>
<p style="color:var(--text-muted); font-size:12px; margin-left: 10px;">- 포함된 총 물: ${finalTotalWater.toFixed(2)} L</p>
`;
resultBox.style.display = "block";
}
function runDryingCalculator(e) {
e.preventDefault();
const resultBox = document.getElementById("dry-result-box");
const resultsContainer = document.getElementById("dry-results");
resultBox.style.display = "none";
resultsContainer.innerHTML = "";
const speed = parseFloat(document.getElementById("dry-speed").value);
const concentration = parseFloat(document.getElementById("dry-conc").value);
const usage = parseFloat(document.getElementById("dry-usage").value);
const loss = parseFloat(document.getElementById("dry-loss").value);
if ([speed, concentration, usage, loss].some(val => isNaN(val))) {
alert("모든 필드에 유효한 숫자를 입력해 주십시오.");
return;
}
const usageMlPerSec = (usage * 1000) / 86400; // L/day -> ml/s
const waterFraction = 1 - (concentration / 100);
const waterRateGps = usageMlPerSec * waterFraction;
const latentHeat = 2260; // J/g
const powerW = waterRateGps * latentHeat;
const recommendedPowerW = powerW * (1 + (loss / 100));
resultsContainer.innerHTML = `
<p style="color:var(--text-secondary);">• 이론상 최소 필요 전력: <strong style="color:#ffffff;">${powerW.toFixed(2)} W</strong></p>
<p style="color:var(--text-secondary);">• 권장 히터 동작 전력 (Loss 포함): <strong style="color:var(--accent-orange);">${recommendedPowerW.toFixed(2)} W</strong></p>
<p style="color:var(--text-muted); font-size: 11px; margin-top: 6px;">* 물의 잠열 ${latentHeat} J/g 및 입력 건조 손실률 ${loss}%를 대입한 추산치입니다.</p>
`;
resultBox.style.display = "block";
}
+73
View File
@@ -0,0 +1,73 @@
// Common Navigation and UI Utilities for ChemiFactory MES/HMI
document.addEventListener("DOMContentLoaded", () => {
renderNavigation();
highlightActiveLink();
});
// Navigation configuration
const navItems = [
{ name: "대시보드", icon: "📊", path: "index.html" },
{ name: "생산 계획", icon: "📅", path: "plan.html" },
{ name: "생산 현황", icon: "⚙️", path: "equipment.html" },
{ name: "고객사 관리", icon: "🤝", path: "customer.html" },
{ name: "공급사 관리", icon: "🏭", path: "supplier.html" },
{ name: "기기 관리", icon: "🛠️", path: "device.html" },
{ name: "재고 관리", icon: "📦", path: "inventory.html" },
{ name: "계산/분석", icon: "📐", path: "analysis.html" },
{ name: "설정", icon: "⚙️", path: "setting.html" }
];
function renderNavigation() {
const sidebar = document.getElementById("sidebar");
if (!sidebar) return;
let html = `
<div class="sidebar-brand" style="display: flex; align-items: center; gap: 10px; padding: 20px 24px;">
<img src="source/logo.png" alt="Logo" class="brand-icon" style="width: 32px; height: 32px; object-fit: contain; font-size: 0;">
<img src="source/company_name.png" alt="ChemiFactory" class="brand-name" style="height: 24px; object-fit: contain; max-width: 150px;">
</div>
<ul class="nav-links">
`;
navItems.forEach(item => {
html += `
<li>
<a href="${item.path}" class="nav-item-link" data-path="${item.path}">
<span class="nav-icon">${item.icon}</span>
<span class="nav-text">${item.name}</span>
</a>
</li>
`;
});
html += `
</ul>
<div class="sidebar-footer">
<div class="user-profile">
<span class="user-avatar">👤</span>
<div class="user-info">
<span class="user-name">관리자</span>
<span class="user-role">SYSTEM ADMIN</span>
</div>
</div>
</div>
`;
sidebar.innerHTML = html;
}
function highlightActiveLink() {
// Current page filename detection
const path = window.location.pathname;
const page = path.split("/").pop() || "index.html";
const links = document.querySelectorAll(".nav-item-link");
links.forEach(link => {
const itemPath = link.getAttribute("data-path");
if (page === itemPath || (page === "" && itemPath === "index.html")) {
link.classList.add("active");
} else {
link.classList.remove("active");
}
});
}
+409
View File
@@ -0,0 +1,409 @@
// ChemiFactory MES - Customer Management Frontend Logic
const API_BASE = "/customers";
let allCustomers = [];
let selectedCustomerId = null;
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchCustomers();
});
// APIs Integration
async function fetchCustomers() {
try {
const res = await fetch(`${API_BASE}/`);
if (!res.ok) throw new Error("Failed to fetch customers");
allCustomers = await res.json();
renderCustomerList(allCustomers);
} catch (err) {
console.error(err);
showListError();
}
}
async function showCustomerDetail(id) {
try {
selectedCustomerId = id;
const res = await fetch(`${API_BASE}/${id}`);
if (!res.ok) throw new Error("Failed to fetch customer details");
const data = await res.json();
renderCustomerDetail(data);
} catch (err) {
console.error(err);
alert("고객사 정보를 상세히 불러오지 못했습니다.");
}
}
// Render Lists
function renderCustomerList(list) {
const tbody = document.getElementById("customer-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 고객사가 없습니다.</td></tr>`;
return;
}
list.forEach(cust => {
const tr = document.createElement("tr");
if (selectedCustomerId === cust.id) tr.className = "active-row";
tr.innerHTML = `
<td><strong>${cust.name_kr}</strong></td>
<td>${cust.contact_number || "-"}</td>
<td>${cust.business_registration_number || "-"}</td>
`;
tr.addEventListener("click", () => {
document.querySelectorAll("#customer-list-body tr").forEach(el => el.classList.remove("active-row"));
tr.classList.add("active-row");
showCustomerDetail(cust.id);
});
tbody.appendChild(tr);
});
}
function showListError() {
const tbody = document.getElementById("customer-list-body");
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--accent-red); padding:30px 0;">데이터 통신에 실패했습니다. DB 작동을 확인하십시오.</td></tr>`;
}
function renderCustomerDetail(data) {
// Show details card, hide placeholder
document.getElementById("customer-placeholder-card").style.display = "none";
document.getElementById("customer-detail-card").style.display = "block";
const cust = data.customer;
document.getElementById("detail-name-kr").innerText = cust.name_kr;
document.getElementById("detail-name-en").innerText = cust.name_en || "";
document.getElementById("detail-brn").innerText = cust.business_registration_number || "-";
document.getElementById("detail-contact").innerText = cust.contact_number || "-";
document.getElementById("detail-address").innerText = cust.address || "-";
// Bind Contacts
renderContactsList(data.contacts || []);
// Bind Sales Activities
renderActivitiesList(data.salesActivities || []);
}
function renderContactsList(contacts) {
const tbody = document.getElementById("contact-list-body");
tbody.innerHTML = "";
if (contacts.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:20px 0;">등록된 담당자가 없습니다.</td></tr>`;
return;
}
contacts.forEach(c => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><strong>${c.name}</strong></td>
<td>${c.position || "-"} ${c.work_location ? `(${c.work_location})` : ""}</td>
<td>${c.phone_number || "-"}</td>
<td>${c.email || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditContactModal(${JSON.stringify(c).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteContact(${c.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function renderActivitiesList(activities) {
const tbody = document.getElementById("activity-list-body");
tbody.innerHTML = "";
if (activities.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:20px 0;">영업 활동 내역이 없습니다.</td></tr>`;
return;
}
// Sort by date descending
activities.sort((a, b) => new Date(b.activity_date) - new Date(a.activity_date));
activities.forEach(act => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${act.activity_date}</td>
<td><span class="badge badge-online">${act.activity_type}</span></td>
<td style="max-width: 250px; white-wrap: wrap;">${act.memo}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditActivityModal(${JSON.stringify(act).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteActivity(${act.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
// Event Bindings
function initEvents() {
// Search Filter
document.getElementById("search-customer").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allCustomers.filter(c =>
c.name_kr.toLowerCase().includes(query) ||
(c.name_en && c.name_en.toLowerCase().includes(query)) ||
(c.business_registration_number && c.business_registration_number.includes(query))
);
renderCustomerList(filtered);
});
// Tab Toggle
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// Modals Control
const modals = ["customer-modal", "contact-modal", "activity-modal"];
modals.forEach(mId => {
const modal = document.getElementById(mId);
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
});
// Add Customer
document.getElementById("btn-add-customer").addEventListener("click", () => {
document.getElementById("customer-form").reset();
document.getElementById("customer-id").value = "";
document.getElementById("customer-modal-title").innerText = "고객사 등록";
document.getElementById("customer-modal").style.display = "flex";
});
document.getElementById("customer-form").addEventListener("submit", handleCustomerSubmit);
// Edit & Delete Customer
document.getElementById("btn-edit-customer").addEventListener("click", () => {
const activeCust = allCustomers.find(c => c.id === selectedCustomerId);
if (!activeCust) return;
document.getElementById("customer-id").value = activeCust.id;
document.getElementById("cust-name-kr").value = activeCust.name_kr;
document.getElementById("cust-name-en").value = activeCust.name_en || "";
document.getElementById("cust-brn").value = activeCust.business_registration_number || "";
document.getElementById("cust-contact").value = activeCust.contact_number || "";
document.getElementById("cust-address").value = activeCust.address || "";
document.getElementById("customer-modal-title").innerText = "고객사 정보 수정";
document.getElementById("customer-modal").style.display = "flex";
});
document.getElementById("btn-delete-customer").addEventListener("click", handleDeleteCustomer);
// Add Contact
document.getElementById("btn-add-contact").addEventListener("click", () => {
document.getElementById("contact-form").reset();
document.getElementById("contact-id").value = "";
document.getElementById("contact-modal-title").innerText = "담당자 등록";
document.getElementById("contact-modal").style.display = "flex";
});
document.getElementById("contact-form").addEventListener("submit", handleContactSubmit);
// Add Activity
document.getElementById("btn-add-activity").addEventListener("click", () => {
document.getElementById("activity-form").reset();
document.getElementById("activity-id").value = "";
document.getElementById("act-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("activity-modal-title").innerText = "활동 일지 등록";
document.getElementById("activity-modal").style.display = "flex";
});
document.getElementById("activity-form").addEventListener("submit", handleActivitySubmit);
}
// Form Handlers
async function handleCustomerSubmit(e) {
e.preventDefault();
const id = document.getElementById("customer-id").value;
const payload = {
name_kr: document.getElementById("cust-name-kr").value,
name_en: document.getElementById("cust-name-en").value || null,
business_registration_number: document.getElementById("cust-brn").value || null,
contact_number: document.getElementById("cust-contact").value || null,
address: document.getElementById("cust-address").value || null
};
try {
let res;
if (id) {
// Update
res = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
// Create
res = await fetch(`${API_BASE}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API error occurred");
const result = await res.json();
document.getElementById("customer-modal").style.display = "none";
await fetchCustomers();
if (id) {
showCustomerDetail(parseInt(id));
} else {
showCustomerDetail(result.id);
}
} catch (err) {
console.error(err);
alert("고객사 정보를 저장하는 중 오류가 발생했습니다.");
}
}
async function handleDeleteCustomer() {
if (!selectedCustomerId) return;
if (!confirm("이 고객사를 정말 삭제하시겠습니까?\n해당 거래처의 연락처 및 활동 일지 정보가 영구 삭제됩니다.")) return;
try {
const res = await fetch(`${API_BASE}/${selectedCustomerId}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete customer");
selectedCustomerId = null;
document.getElementById("customer-detail-card").style.display = "none";
document.getElementById("customer-placeholder-card").style.display = "flex";
await fetchCustomers();
} catch (err) {
console.error(err);
alert("고객사 삭제에 실패했습니다.");
}
}
// Contact Handlers
window.openEditContactModal = function(contact) {
document.getElementById("contact-id").value = contact.id;
document.getElementById("cont-name").value = contact.name;
document.getElementById("cont-position").value = contact.position || "";
document.getElementById("cont-phone").value = contact.phone_number || "";
document.getElementById("cont-email").value = contact.email || "";
document.getElementById("cont-location").value = contact.work_location || "";
document.getElementById("contact-modal-title").innerText = "담당자 정보 수정";
document.getElementById("contact-modal").style.display = "flex";
};
async function handleContactSubmit(e) {
e.preventDefault();
const id = document.getElementById("contact-id").value;
const payload = {
customer_id: selectedCustomerId,
name: document.getElementById("cont-name").value,
position: document.getElementById("cont-position").value || null,
phone_number: document.getElementById("cont-phone").value || null,
email: document.getElementById("cont-email").value || null,
work_location: document.getElementById("cont-location").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/contacts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/contacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Contact save API error");
document.getElementById("contact-modal").style.display = "none";
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("담당자 저장에 실패했습니다.");
}
}
window.deleteContact = async function(contactId) {
if (!confirm("이 담당자 정보를 정말 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/contacts/${contactId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete contact");
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("담당자 삭제에 실패했습니다.");
}
};
// Activity Handlers
window.openEditActivityModal = function(act) {
document.getElementById("activity-id").value = act.id;
document.getElementById("act-date").value = act.activity_date;
document.getElementById("act-type").value = act.activity_type;
document.getElementById("act-memo").value = act.memo || "";
document.getElementById("activity-modal-title").innerText = "활동 일지 수정";
document.getElementById("activity-modal").style.display = "flex";
};
async function handleActivitySubmit(e) {
e.preventDefault();
const id = document.getElementById("activity-id").value;
const payload = {
customer_id: selectedCustomerId,
contact_id: null, // Optional in back-end
activity_date: document.getElementById("act-date").value,
activity_type: document.getElementById("act-type").value,
memo: document.getElementById("act-memo").value
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/activities/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/activities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Activity save API error");
document.getElementById("activity-modal").style.display = "none";
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("일지 저장에 실패했습니다.");
}
}
window.deleteActivity = async function(actId) {
if (!confirm("이 활동 일지를 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/activities/${actId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete activity");
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("일지 삭제에 실패했습니다.");
}
};
+136
View File
@@ -0,0 +1,136 @@
// Dashboard Business logic and data visualization for ChemiFactory MES
document.addEventListener("DOMContentLoaded", () => {
fetchAPIStatus();
loadDashboardData();
});
const BACKEND_URL = `${window.location.protocol}//${window.location.host}`;
// API 연결 상태 확인
async function fetchAPIStatus() {
const badge = document.getElementById("api-status-badge");
try {
const response = await fetch(`${BACKEND_URL}/api/status`);
if (response.ok) {
badge.textContent = "시스템 정상";
badge.className = "badge badge-online";
} else {
throw new Error("HTTP Status Error");
}
} catch (e) {
badge.textContent = "연결 실패";
badge.className = "badge badge-offline";
}
}
// 대시보드 데이터 바인딩
async function loadDashboardData() {
try {
// 1. 고객사 개수 로드 (API: /customers/)
const custRes = await fetch(`${BACKEND_URL}/customers/`);
if (custRes.ok) {
const list = await custRes.json();
document.getElementById("customer-count").textContent = `${list.length} 개사`;
}
// 2. 재고 정보 로드 (API: /inventory/)
const invRes = await fetch(`${BACKEND_URL}/inventory/`);
if (invRes.ok) {
const list = await invRes.json();
// 품절되거나 소진 임계에 도달한 재고 경보 카운트
const lowStockCount = list.filter(item => item.is_depleted === 1 || item.stock <= 10).length;
document.getElementById("low-inventory-count").textContent = `${lowStockCount}`;
}
// 3. 설비 리스트 및 작동 요약 로드 (⚠️ 제외 대상에 따른 대시보드 미니 가동기기 출력 유지)
const equipRes = await fetch(`${BACKEND_URL}/equipment/`);
let totalEquip = 0;
let activeEquip = 0;
if (equipRes.ok) {
const list = await equipRes.json();
totalEquip = list.length;
// 미니 HMI 대시보드 리스트 생성
const hmiContainer = document.getElementById("equipment-mini-list");
if (list.length === 0) {
hmiContainer.innerHTML = `<div style="text-align: center; color: var(--text-muted); padding: 30px 0;">등록된 설비가 없습니다.</div>`;
} else {
hmiContainer.innerHTML = ""; // 초기화
list.forEach(eq => {
const isOnline = eq.status === "available" || eq.is_online; // 활성화 체크 필드
if (isOnline) activeEquip++;
const dotClass = isOnline ? "dot-green" : "dot-red";
const statusText = isOnline ? "가동준비" : "점검필요";
const miniItem = document.createElement("div");
miniItem.className = "hmi-mini-card";
miniItem.innerHTML = `
<div>
<strong style="display:block; font-size:15px;">${eq.name || eq.id}</strong>
<span style="font-size:12px; color:var(--text-secondary);">${eq.status || "상태미필"}</span>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:13px; color:var(--text-secondary);">${statusText}</span>
<span class="indicator-dot ${dotClass}"></span>
</div>
`;
hmiContainer.appendChild(miniItem);
});
}
document.getElementById("active-equip-count").textContent = `${activeEquip} / ${totalEquip}`;
}
// 4. 생산 지시 리스트 및 실적 요약 (API: /plans/)
const planRes = await fetch(`${BACKEND_URL}/plans/`);
if (planRes.ok) {
const list = await planRes.json();
const planTable = document.getElementById("plan-list");
// 실적 합계 계산
let totalActualQty = 0;
let totalTargetQty = 0;
if (list.length === 0) {
planTable.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--text-muted); padding: 30px 0;">등록된 생산 지시가 없습니다.</td></tr>`;
} else {
planTable.innerHTML = ""; // 초기화
// 최신 계획 5개만 대시보드에 표기
const recentPlans = list.slice(0, 5);
recentPlans.forEach(plan => {
// actual_quantity 속성이 없는 경우 baseline 0kg으로 방어
const actual = plan.actual_quantity || 0;
const target = plan.requested_quantity_kg || 0;
totalActualQty += actual;
totalTargetQty += target;
const row = document.createElement("tr");
row.innerHTML = `
<td><strong>Plan #${plan.plan_id}</strong></td>
<td>${plan.plan_name || "-"}</td>
<td>${target.toLocaleString()} kg</td>
<td>${actual.toLocaleString()} kg</td>
<td>
<span class="badge ${plan.status === 'Completed' ? 'badge-online' : 'badge-offline'}">
${plan.status === 'Completed' ? '완료' : '예정'}
</span>
</td>
`;
planTable.appendChild(row);
});
}
// 오늘의 생산량 카드 바인딩
document.getElementById("today-prod").textContent = `${totalActualQty.toLocaleString()} kg`;
const progress = totalTargetQty > 0 ? Math.round((totalActualQty / totalTargetQty) * 100) : 0;
const statCard = document.querySelector(".stat-card:nth-child(2) .stat-desc");
if (statCard) {
statCard.textContent = `전체 목표 대비 진행도 ${progress}%`;
}
}
} catch (e) {
console.error("Failed to load dashboard statistics:", e);
}
}
+216
View File
@@ -0,0 +1,216 @@
// ChemiFactory MES - Equipment Device Master Management Logic
const API_EQUIPMENT = "/api/equipment";
let allDevices = [];
document.addEventListener("DOMContentLoaded", () => {
initEvents();
loadDevices();
});
async function loadDevices() {
try {
const res = await fetch(API_EQUIPMENT);
if (!res.ok) throw new Error("Failed to fetch equipment devices");
allDevices = await res.json();
renderDevices(allDevices);
} catch (err) {
console.error(err);
document.getElementById("device-grid-container").innerHTML = `
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--accent-red);">
서버로부터 설비 정보를 조회하지 못했습니다. (DB 또는 네트워크 점검 필요)
</div>
`;
}
}
function renderDevices(devices) {
const container = document.getElementById("device-grid-container");
container.innerHTML = "";
if (devices.length === 0) {
container.innerHTML = `
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--text-muted);">
등록된 설비 기기가 없습니다.
</div>
`;
return;
}
devices.forEach(d => {
const card = document.createElement("div");
card.className = "device-card";
card.addEventListener("click", () => openEditModal(d));
// 상태 한글 변환
let statusText = "가동 가능";
let statusBadgeClass = "device-badge";
if (d.status === "maintenance") {
statusText = "점검 중";
} else if (d.status === "broken") {
statusText = "고장/정지";
}
card.innerHTML = `
<div>
<div class="device-card-header">
<span class="device-name">${d.name}</span>
<span class="${statusBadgeClass}">${statusText}</span>
</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top:-8px; margin-bottom: 12px;">
IP: ${d.ip_address} | Prefix: ${d.line_prefix}
</div>
</div>
<div class="device-details">
<div class="detail-item">
<span class="detail-label">보빈 수</span>
<span class="detail-value">${d.yarn_bobbin_count}개</span>
</div>
<div class="detail-item">
<span class="detail-label">건조기 타입</span>
<span class="detail-value">${d.dryer_type}</span>
</div>
<div class="detail-item">
<span class="detail-label">소비 전력</span>
<span class="detail-value">${d.power_consumption} kW</span>
</div>
<div class="detail-item">
<span class="detail-label">최대 속도</span>
<span class="detail-value">Cut: ${d.max_cutting_speed} / Feed: ${d.max_feed_speed}</span>
</div>
</div>
`;
container.appendChild(card);
});
}
function initEvents() {
const modal = document.getElementById("device-modal");
const closeBtns = modal.querySelectorAll(".close-btn, .close-modal-btn");
const addBtn = document.getElementById("btn-add-device");
const form = document.getElementById("device-form");
const deleteBtn = document.getElementById("btn-delete-device");
const searchInput = document.getElementById("search-input");
// 모달 닫기
closeBtns.forEach(btn => {
btn.addEventListener("click", () => {
modal.style.display = "none";
});
});
// 신규 추가 오픈
addBtn.addEventListener("click", () => {
form.reset();
document.getElementById("device-id").value = "";
document.getElementById("modal-title").innerText = "신규 기기 등록";
deleteBtn.style.display = "none";
document.getElementById("delete-spacer").style.display = "none";
modal.style.display = "flex";
});
// 검색 필터링
searchInput.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().trim();
const filtered = allDevices.filter(d =>
d.name.toLowerCase().includes(query) ||
(d.dryer_type && d.dryer_type.toLowerCase().includes(query)) ||
(d.ip_address && d.ip_address.includes(query))
);
renderDevices(filtered);
});
// 폼 저장 제출
form.addEventListener("submit", saveDevice);
// 삭제 실행
deleteBtn.addEventListener("click", deleteDevice);
}
function openEditModal(device) {
const modal = document.getElementById("device-modal");
const deleteBtn = document.getElementById("btn-delete-device");
document.getElementById("device-id").value = device.id;
document.getElementById("device-name").value = device.name;
document.getElementById("device-bobbins").value = device.yarn_bobbin_count;
document.getElementById("device-dryer").value = device.dryer_type;
document.getElementById("device-power").value = device.power_consumption;
document.getElementById("device-max-cut").value = device.max_cutting_speed;
document.getElementById("device-max-feed").value = device.max_feed_speed;
document.getElementById("device-ip").value = device.ip_address;
document.getElementById("device-prefix").value = device.line_prefix;
document.getElementById("device-status").value = device.status;
document.getElementById("modal-title").innerText = "설비 정보 편집";
// 삭제 버튼 노출
deleteBtn.style.display = "block";
document.getElementById("delete-spacer").style.display = "block";
modal.style.display = "flex";
}
async function saveDevice(e) {
e.preventDefault();
const id = document.getElementById("device-id").value;
const payload = {
name: document.getElementById("device-name").value,
yarn_bobbin_count: parseInt(document.getElementById("device-bobbins").value),
dryer_type: document.getElementById("device-dryer").value,
power_consumption: parseFloat(document.getElementById("device-power").value),
max_cutting_speed: parseFloat(document.getElementById("device-max-cut").value),
max_feed_speed: parseFloat(document.getElementById("device-max-feed").value),
ip_address: document.getElementById("device-ip").value,
line_prefix: document.getElementById("device-prefix").value,
status: document.getElementById("device-status").value
};
try {
let res;
if (id) {
// 수정
res = await fetch(`${API_EQUIPMENT}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
// 추가
res = await fetch(API_EQUIPMENT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Failed to save device data");
document.getElementById("device-modal").style.display = "none";
loadDevices();
} catch (err) {
console.error(err);
alert("기기 저장 중 요류가 발생했습니다.");
}
}
async function deleteDevice() {
const id = document.getElementById("device-id").value;
if (!id) return;
if (!confirm("정말로 해당 설비를 삭제하시겠습니까? 관련 데이터가 소실될 수 있습니다.")) {
return;
}
try {
const res = await fetch(`${API_EQUIPMENT}/${id}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete device");
document.getElementById("device-modal").style.display = "none";
loadDevices();
} catch (err) {
console.error(err);
alert("기기 삭제에 실패했습니다.");
}
}
+684
View File
@@ -0,0 +1,684 @@
// Real-time HMI Websocket Client & REST API Manager
let socket = null;
let currentSelectedEquipment = null;
let currentSelectedPrefix = "line1"; // 기본 프리픽스 설정
let currentActiveGroup = "A"; // 현재 선택된 제어 그룹 ('A' 또는 'B')
let lastReceivedStatusData = null; // 가장 최근 수신한 실시간 상태 정보 캐시
const pendingControlCommands = new Map();
let equipmentControlsLocked = true;
const BACKEND_URL = `${window.location.protocol}//${window.location.host}`;
const WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:";
const WS_URL = `${WS_PROTOCOL}//${window.location.host}/ws/equipment`;
document.addEventListener("DOMContentLoaded", () => {
loadEquipmentCards();
connectWebsocket();
initHangerPopoverOutsideClick();
});
// Websocket 연결 및 자동 재접속 로직
function connectWebsocket() {
const wsStatusBadge = document.getElementById("hmi-websocket-status");
socket = new WebSocket(WS_URL);
socket.onopen = () => {
if (wsStatusBadge) {
wsStatusBadge.textContent = "실시간 연결";
wsStatusBadge.className = "badge badge-online";
}
};
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "equipment_update" && currentSelectedEquipment) {
// 선택한 설비의 업데이트 정보인 경우 HMI 갱신 수행
if (String(msg.equipment_id) === String(currentSelectedEquipment.id)) {
lastReceivedStatusData = msg.data;
updateHMILiveDisplay(msg.data);
}
} else if (msg.type === "command_ack") {
handleCommandAck(msg);
}
} catch (err) {
console.error("Websocket parsing error:", err);
}
};
socket.onclose = () => {
if (wsStatusBadge) {
wsStatusBadge.textContent = "연결 안됨 (재접속중...)";
wsStatusBadge.className = "badge badge-offline";
}
setTimeout(connectWebsocket, 3000); // 3초 후 자동 재연결 시도
};
socket.onerror = (err) => {
console.error("Websocket error:", err);
};
}
function handleCommandAck(message) {
if (!message.command_id || !["PLC_APPLIED", "FAILED", "TIMEOUT", "BUSY"].includes(message.status)) return;
const label = pendingControlCommands.get(message.command_id) || "PLC 제어 명령";
pendingControlCommands.delete(message.command_id);
if (message.status === "PLC_APPLIED") {
// alert(`${label}이(가) PLC에 정상 적용되었습니다.`);
} else if (message.status === "TIMEOUT") {
// alert(`${label} 적용 확인 시간이 초과되었습니다.`);
} else if (message.status === "BUSY") {
// alert(`${label} 요청 실패: 게이트웨이가 이전 제어 명령을 처리 중입니다 (바쁨).`);
} else {
// alert(`${label} 적용에 실패했습니다.`);
}
}
function setEquipmentControlsLocked(locked) {
equipmentControlsLocked = locked;
const overlay = document.getElementById("initialization-overlay");
if (!overlay) return;
overlay.classList.toggle("active", locked);
overlay.setAttribute("aria-hidden", String(!locked));
}
let cachedEquipments = [];
// 설비 카드 목록 로드
async function loadEquipmentCards() {
const container = document.getElementById("equipment-cards-container");
try {
const response = await fetch(`${BACKEND_URL}/api/equipment`);
if (response.ok) {
const list = await response.json();
cachedEquipments = list;
container.innerHTML = ""; // 초기화
if (currentSelectedEquipment) {
// 상세페이지 뷰 모드
container.style.display = "none";
document.getElementById("hmi-detail-panel").style.display = "block";
// 페이지 헤더 타이틀 업데이트
document.getElementById("main-page-title").innerText = "실시간 HMI 제어";
document.getElementById("main-page-desc").style.display = "none";
return;
}
// 목록 뷰 모드
container.style.display = "grid";
document.getElementById("hmi-detail-panel").style.display = "none";
// 페이지 헤더 타이틀 업데이트
document.getElementById("main-page-title").innerText = "설비 리스트";
document.getElementById("main-page-desc").innerText = "등록된 설비 목록 및 상세 사양 정보를 확인할 수 있습니다.";
document.getElementById("main-page-desc").style.display = "block";
if (list.length === 0) {
container.innerHTML = `<div style="grid-column: 1/-1; text-align:center; padding: 40px; color: var(--text-muted);">등록된 설비가 없습니다.</div>`;
return;
}
list.forEach(eq => {
const isOnline = eq.status === "running" || eq.is_online;
const card = document.createElement("div");
card.className = `card equip-card`;
card.onclick = () => selectEquipment(eq);
card.innerHTML = `
<div class="equip-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<div>
<span class="equip-title" style="font-size:16px; font-weight:700;">${eq.name || eq.equipment_id}</span>
<div class="equip-type" style="font-size:11px; color:var(--text-muted); margin-top:2px;">타입: ${eq.type || "N/A"}</div>
</div>
<span class="badge ${isOnline ? 'badge-online' : 'badge-offline'}">${isOnline ? 'ONLINE' : 'OFFLINE'}</span>
</div>
<div style="font-size:11px; color:var(--text-muted); margin-bottom: 12px;">IP Address: ${eq.ip_address || "0.0.0.0"}</div>
<div class="equip-specs" style="display:grid; grid-template-columns: 1fr 1fr; gap:6px 12px; font-size:12px; color:var(--text-secondary); border-top:1px solid var(--border-color); padding-top:12px;">
<div><span style="color:var(--text-muted);">보빈 수:</span> ${eq.yarn_bobbin_count || 0}개</div>
<div><span style="color:var(--text-muted);">건조기 타입:</span> ${eq.dryer_type || "N/A"}</div>
<div><span style="color:var(--text-muted);">소비 전력:</span> ${eq.power_consumption || 0} kW</div>
<div><span style="color:var(--text-muted);">PLC 연결상태:</span> <span style="font-weight:600; color:${eq.device_state === 'ONLINE' ? 'var(--accent-green)' : 'var(--text-muted)'};">${eq.device_state || 'OFFLINE'}</span></div>
<div style="grid-column:1/-1;"><span style="color:var(--text-muted);">최대 속도:</span> Cut: ${eq.max_cutting_speed || 0} / Feed: ${eq.max_feed_speed || 0}</div>
</div>
`;
container.appendChild(card);
});
}
} catch (e) {
console.error("Failed to load equipment list:", e);
}
}
// 전체 목록으로 돌아가기
function goBackToEquipmentList() {
currentSelectedEquipment = null;
document.getElementById("hmi-detail-panel").style.display = "none";
document.getElementById("equipment-cards-container").style.display = "grid";
loadEquipmentCards();
}
// 설비 선택 및 HMI 렌더링
async function selectEquipment(eq) {
currentSelectedEquipment = eq;
currentSelectedPrefix = eq.line_prefix || "line1";
// 목록 화면 숨김 & HMI 디테일 표출
document.getElementById("equipment-cards-container").style.display = "none";
document.getElementById("hmi-detail-panel").style.display = "block";
// 페이지 헤더 타이틀 업데이트
document.getElementById("main-page-title").innerText = "실시간 HMI 제어";
document.getElementById("main-page-desc").style.display = "none";
// 초기 그룹은 'A'로 설정 및 탭 활성화
setHmiActiveGroup('A');
// DB에서 실시간 상세 상태 데이터 가져와 UI 동기화
try {
const response = await fetch(`${BACKEND_URL}/api/equipment/${eq.id}/status`);
if (response.ok) {
const statusData = await response.json();
lastReceivedStatusData = statusData;
updateHMILiveDisplay(statusData);
}
} catch (e) {
console.error("실시간 설비 상태 로드 실패:", e);
}
}
// A그룹 / B그룹 제어 전환 설정
function setHmiActiveGroup(groupChar) {
currentActiveGroup = groupChar;
// 각 그룹 카드의 제목 span 태그를 찾아 뒤에 A 또는 B 추가
document.querySelectorAll('.hmi-group-title > span:first-child').forEach(span => {
if (!span.dataset.baseTitle) {
span.dataset.baseTitle = span.textContent.trim();
}
span.textContent = `${span.dataset.baseTitle} ${groupChar}`;
});
// 건조부 히터 번호 변경 (A그룹: H1~H5, B그룹: H6~H10)
for (let h = 1; h <= 5; h++) {
const btn = document.getElementById(`btn-dryer-h${h}`);
if (btn) {
const heaterNum = groupChar === 'A' ? h : (h + 5);
const lampSpan = btn.querySelector('.hmi-lamp');
btn.innerHTML = `H${heaterNum} `;
if (lampSpan) {
btn.appendChild(lampSpan);
}
}
}
// 탭 UI 교체
const tabA = document.getElementById("btn-tab-a");
const tabB = document.getElementById("btn-tab-b");
if (groupChar === 'A') {
tabA.classList.add("active");
tabB.classList.remove("active");
} else {
tabB.classList.add("active");
tabA.classList.remove("active");
}
// 행거 포트 그리드 재렌더링
renderHangerPorts();
// 팝오버 닫기
hideHangerPopover();
// 기존 캐싱 데이터가 존재한다면 즉시 새로운 오프셋 기준으로 리렌더링
if (lastReceivedStatusData) {
updateHMILiveDisplay(lastReceivedStatusData);
}
}
// 6그룹: 행거부 20개 포트 콤팩트 그리드 렌더링
function renderHangerPorts() {
const container = document.getElementById("hanger-ports-container");
container.innerHTML = "";
const startPortNum = currentActiveGroup === 'A' ? 1 : 21;
const endPortNum = currentActiveGroup === 'A' ? 20 : 40;
for (let p = startPortNum; p <= endPortNum; p++) {
const cell = document.createElement("div");
const paddedNum = String(p).padStart(2, '0');
cell.className = "port-cell port-idle";
cell.id = `port-cell-${p}`;
cell.innerText = `P${paddedNum}`;
cell.onclick = (e) => {
e.stopPropagation();
showHangerPopover(p, cell);
};
container.appendChild(cell);
}
}
// 행거 포트 클릭시 공용 제어 팝오버 표시
function showHangerPopover(portNum, cellElement) {
const popover = document.getElementById("hanger-popover");
const title = document.getElementById("popover-title");
const paddedNum = String(portNum).padStart(2, '0');
title.innerText = `P${paddedNum} 제어`;
// 팝오버 위치를 클릭된 셀 뒤로 가져다 붙임
popover.style.display = "flex";
// 1행인지 2행 이하인지 구분하여 위/아래 배치 결정 (그리드가 5열이므로 index 5(6번째 포트)부터 2행)
const startPortNum = currentActiveGroup === 'A' ? 1 : 21;
const relativeIndex = portNum - startPortNum;
if (relativeIndex >= 5) {
// 2행 이하: 셀 위에 배치 (위로 열림)
popover.style.top = `${cellElement.offsetTop - popover.offsetHeight - 6}px`;
} else {
// 1행: 셀 아래에 배치 (아래로 열림)
popover.style.top = `${cellElement.offsetTop + cellElement.offsetHeight + 6}px`;
}
popover.style.left = `${cellElement.offsetLeft + (cellElement.offsetWidth / 2) - 65}px`;
// 제어 버튼 바인딩 (PLC 쓰기 비트 인덱스 계산)
const btnAutoDown = document.getElementById("btn-popover-auto-down");
const btnManualDown = document.getElementById("btn-popover-manual-down");
const btnManualUp = document.getElementById("btn-popover-manual-up");
let autoDownBitId, manualDownBitId, manualUpBitId;
if (currentActiveGroup === 'A') {
autoDownBitId = 68 + (portNum - 1); // 68 ~ 87
manualDownBitId = 108 + (portNum - 1); // 108 ~ 127
manualUpBitId = 148 + (portNum - 1); // 148 ~ 167
} else {
autoDownBitId = 88 + (portNum - 21); // 88 ~ 107
manualDownBitId = 128 + (portNum - 21); // 128 ~ 147
manualUpBitId = 168 + (portNum - 21); // 168 ~ 187
}
btnAutoDown.onclick = () => { sendHmiBitDirect(autoDownBitId); hideHangerPopover(); };
btnManualDown.onclick = () => { sendHmiBitDirect(manualDownBitId); hideHangerPopover(); };
btnManualUp.onclick = () => { sendHmiBitDirect(manualUpBitId); hideHangerPopover(); };
}
function hideHangerPopover() {
const popover = document.getElementById("hanger-popover");
if (popover) popover.style.display = "none";
}
function initHangerPopoverOutsideClick() {
document.addEventListener("click", () => {
hideHangerPopover();
});
const popover = document.getElementById("hanger-popover");
if (popover) {
popover.addEventListener("click", (e) => {
e.stopPropagation(); // 팝오버 내부 클릭은 닫히지 않음
});
}
}
// 실시간 수신 데이터의 A/B 오프셋을 판단하여 UI 상태 갱신
function updateHMILiveDisplay(data) {
if (!data) return;
setEquipmentControlsLocked(data.sync_state === "SYNCING" || data.device_state !== "ONLINE");
const mRead = typeof data.m_read_raw === 'string' ? JSON.parse(data.m_read_raw || '{}') : (data.m_read_raw || {});
// A/B 그룹에 따른 읽기 비트 오프셋 설정
const isGroupB = (currentActiveGroup === 'B');
const mOffset = isGroupB ? 19 : 0;
const dOffset = isGroupB ? 5 : 0;
// ----------------------------------------------------
// 1그룹: 활성화 및 리셋 비트 상태 동기화 (m_read_raw)
// ----------------------------------------------------
// 안전 인터락 상태 (A-1) - 정상=녹색, 이상=적색
setInterlockLamp("lamp-estop", mRead[String(1 + mOffset)], 1);
setInterlockLamp("lamp-door-left", mRead[String(2 + mOffset)], 1);
setInterlockLamp("lamp-door-right", mRead[String(3 + mOffset)], 1);
setInterlockLamp("lamp-water-level", mRead[String(4 + mOffset)], 0);
setInterlockLamp("lamp-heater-interlock", mRead[String(5 + mOffset)], 1);
setLampStatus("lamp-group-active-on", mRead[String(6 + mOffset)]); // 활성화 ON
setLampStatus("lamp-group-active-off", mRead[String(7 + mOffset)], true); // 활성화 OFF (반전/빨간색)
// 함침&행거 ON/OFF (2그룹 & 6그룹 동시 업데이트)
setLampStatus("lamp-impreg-hanger-on-g2", mRead[String(11 + mOffset)]);
setLampStatus("lamp-impreg-hanger-off-g2", mRead[String(12 + mOffset)], true);
setLampStatus("lamp-impreg-hanger-on-g6", mRead[String(11 + mOffset)]);
setLampStatus("lamp-impreg-hanger-off-g6", mRead[String(12 + mOffset)], true);
setLampStatus("lamp-dryer-on", mRead[String(13 + mOffset)]); // 건조 ON
setLampStatus("lamp-dryer-off", mRead[String(14 + mOffset)], true);
setLampStatus("lamp-cutter-on", mRead[String(15 + mOffset)]); // 커팅 ON
setLampStatus("lamp-cutter-off", mRead[String(16 + mOffset)], true);
setLampStatus("lamp-transfer-on", mRead[String(17 + mOffset)]); // 이송 ON
setLampStatus("lamp-transfer-off", mRead[String(18 + mOffset)], true);
setLampStatus("lamp-group-stop", mRead[String(8 + mOffset)], true); // 전체정지
setLampStatus("lamp-group-pause", mRead[String(9 + mOffset)], true); // 일시정지
setLampStatus("lamp-group-restart", mRead[String(10 + mOffset)]); // 재시작
setLampStatus("lamp-interlock-reset", mRead[String(0 + mOffset)]); // 인터락 리셋
// ----------------------------------------------------
// 2그룹: 함침부 작동 상태 및 수치 동기화
// ----------------------------------------------------
const impregAutoOffset = isGroupB ? 42 : 38;
const impregManualOffset = isGroupB ? 43 : 39;
const pumpCircOffset = isGroupB ? 44 : 40;
const pumpDrainOffset = isGroupB ? 45 : 41;
setLampStatus("lamp-impreg-auto", mRead[String(impregAutoOffset)]);
setLampStatus("lamp-impreg-manual", mRead[String(impregManualOffset)]);
setLampStatus("lamp-pump-circ", mRead[String(pumpCircOffset)]);
setLampStatus("lamp-pump-drain", mRead[String(pumpDrainOffset)]);
// 순환펌프 재작동 시간 (D0 / D5)
const circTimeVal = data[`d_read_g${0 + dOffset}`] !== undefined ? data[`d_read_g${0 + dOffset}`] : 0;
document.getElementById("val-pump-circ-time").innerHTML = `${circTimeVal} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">초</span>`;
// ----------------------------------------------------
// 3그룹: 건조기 작동 상태 동기화
// ----------------------------------------------------
const dryerAutoOffset = isGroupB ? 56 : 46;
const dryerManualOffset = isGroupB ? 57 : 47;
const fanTripOffset = isGroupB ? 58 : 48;
const heaterReqOffset = isGroupB ? 59 : 49;
const fanActiveOffset = isGroupB ? 60 : 50;
const heaterStartOffset = isGroupB ? 61 : 51;
setLampStatus("lamp-dryer-auto", mRead[String(dryerAutoOffset)]);
setLampStatus("lamp-dryer-manual", mRead[String(dryerManualOffset)]);
setLampStatus("lamp-dryer-fan", mRead[String(fanActiveOffset)]);
// 히터 1~5 (A그룹: 51~55, B그룹: 61~65)
for (let h = 1; h <= 5; h++) {
setLampStatus(`lamp-dryer-h${h}`, mRead[String(heaterStartOffset + (h - 1))]);
}
setLampStatus("lamp-fan-trip", mRead[String(fanTripOffset)], true); // 송풍기 트립 (알람이므로 빨간색)
setLampStatus("lamp-heater-req", mRead[String(heaterReqOffset)]);
// ----------------------------------------------------
// 4그룹: 커팅부 작동 상태 및 수치 동기화
// ----------------------------------------------------
const cutterActiveOffset = isGroupB ? 68 : 66;
const cutterStartOffset = isGroupB ? 69 : 67;
setLampStatus("lamp-cutter-motor-active", mRead[String(cutterActiveOffset)]);
setLampStatus("lamp-cutter-motor-start", mRead[String(cutterStartOffset)]);
// 커팅속도 (D1 / D6) - 소수점 1째자리 포맷팅 및 Hz 단위 적용
const rawCutterSpeed = data[`d_read_g${1 + dOffset}`] !== undefined ? data[`d_read_g${1 + dOffset}`] : 0;
const cutterSpeedVal = Number(rawCutterSpeed).toFixed(1);
document.getElementById("val-cutter-speed").innerHTML = `${cutterSpeedVal} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">Hz</span>`;
// ----------------------------------------------------
// 5그룹: 이송 작동 상태 및 수치 동기화
// ----------------------------------------------------
const transAutoOffset = isGroupB ? 79 : 70;
const transManualOffset = isGroupB ? 80 : 71;
const transAutoStartOffset = isGroupB ? 81 : 72;
const transAutoStopOffset = isGroupB ? 82 : 73;
const transFwdOffset = isGroupB ? 83 : 74;
const transRevOffset = isGroupB ? 84 : 75;
const transStopOffset = isGroupB ? 85 : 76;
const transZeroOffset = isGroupB ? 86 : 77;
const transErrOffset = isGroupB ? 87 : 78;
setLampStatus("lamp-transfer-auto", mRead[String(transAutoOffset)]);
setLampStatus("lamp-transfer-manual", mRead[String(transManualOffset)]);
setLampStatus("lamp-transfer-auto-start", mRead[String(transAutoStartOffset)]);
setLampStatus("lamp-transfer-auto-stop", mRead[String(transAutoStopOffset)], true);
setLampStatus("lamp-transfer-fwd", mRead[String(transFwdOffset)]);
setLampStatus("lamp-transfer-rev", mRead[String(transRevOffset)]);
setLampStatus("lamp-transfer-stop", mRead[String(transStopOffset)], true);
setLampStatus("lamp-transfer-zero", mRead[String(transZeroOffset)]);
setLampStatus("lamp-transfer-err-reset", mRead[String(transErrOffset)]);
// 이송 데이터 (D2, D3, D4 / D7, D8, D9)
const transTargetPos = data[`d_read_g${2 + dOffset}`] !== undefined ? data[`d_read_g${2 + dOffset}`] : 0;
const transCurPos = data[`d_read_g${3 + dOffset}`] !== undefined ? data[`d_read_g${3 + dOffset}`] : 0;
const transTargetSpeed = data[`d_read_g${4 + dOffset}`] !== undefined ? data[`d_read_g${4 + dOffset}`] : 0;
// 자동 목표 이동량(그룹 2/7)은 REAL 소수 1자리, 수동 목표 속도(그룹 4/9)는 REAL 정수 표기
document.getElementById("val-transfer-target-pos").innerText = Number(transTargetPos).toFixed(1);
document.getElementById("val-transfer-cur-pos").innerHTML = `${Number(transCurPos).toFixed(1)} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">mm/s</span>`;
document.getElementById("val-transfer-target-speed").innerText = Number(transTargetSpeed).toFixed(0);
// ----------------------------------------------------
// 6그룹: 행거부 작동 (20개 포트 상태 셀 리프레시)
// ----------------------------------------------------
const startPortNum = isGroupB ? 21 : 1;
const endPortNum = isGroupB ? 40 : 20;
for (let p = startPortNum; p <= endPortNum; p++) {
const cell = document.getElementById(`port-cell-${p}`);
if (!cell) continue;
let autoDownBitId, manualDownBitId, manualUpBitId;
if (!isGroupB) {
// A그룹 포트 읽기 비트 매핑
autoDownBitId = 88 + (p - 1); // 88 ~ 107
manualDownBitId = 128 + (p - 1); // 128 ~ 147
manualUpBitId = 168 + (p - 1); // 168 ~ 187 (168:자동상승, 169~187:수동상승)
} else {
// B그룹 포트 읽기 비트 매핑
autoDownBitId = 108 + (p - 21); // 108 ~ 127
manualDownBitId = 148 + (p - 21); // 148 ~ 167
manualUpBitId = 188 + (p - 21); // 188 ~ 207
}
// CSS 스타일 초기화 후 상태에 따라 네온 효과 부여
cell.className = "port-cell";
if (mRead[String(autoDownBitId)] === 1) {
cell.classList.add("port-active-auto-down");
} else if (mRead[String(manualDownBitId)] === 1) {
cell.classList.add("port-active-manual-down");
} else if (mRead[String(manualUpBitId)] === 1) {
cell.classList.add("port-active-manual-up");
} else {
cell.classList.add("port-idle");
}
}
}
// 램프 인디케이터 상태 변경 유틸리티 함수
function setLampStatus(elementId, value, isRed = false) {
const el = document.getElementById(elementId);
if (!el) return;
const activeClass = isRed ? "active-red" : "active";
// 버튼 바인딩용 클래스 처리 (상위 부모가 btn-hmi인 경우 버튼 테두리 색상 처리 포함)
const btnParent = el.closest(".btn-hmi");
if (value === 1 || value === true || String(value).toLowerCase() === "true" || String(value) === "1") {
el.classList.add("active");
if (btnParent) btnParent.classList.add(activeClass);
} else {
el.classList.remove("active");
if (btnParent) {
btnParent.classList.remove("active");
btnParent.classList.remove("active-red");
}
}
}
// 안전 인터락 전용 상태 갱신 함수 (정상 = 녹색(active), 이상 = 적색(active-red))
function setInterlockLamp(elementId, value, normalValue) {
const el = document.getElementById(elementId);
if (!el) return;
// value와 normalValue 값을 비교 (타입 무관하게 스트링 및 boolean 유연하게 비교)
const isNormal = (String(value) === String(normalValue) ||
(value === true && normalValue === 1) ||
(value === false && normalValue === 0));
if (isNormal) {
el.classList.add("active");
el.classList.remove("active-red");
} else {
el.classList.add("active-red");
el.classList.remove("active");
}
}
// HMI 비트 제어 패킷 전송 (A/B 오프셋 적용하여 자동 제어)
async function sendHmiBit(baseBitId, actionName) {
if (!currentSelectedEquipment) return;
// A/B 그룹별 PLC 쓰기 오프셋 계산
const isGroupB = (currentActiveGroup === 'B');
let actualBitId = baseBitId;
// 그룹별 매핑 규정 오프셋 적용
if (isGroupB) {
if (baseBitId >= 0 && baseBitId <= 11) {
actualBitId = baseBitId + 12; // 1그룹: Offset 12 (12~23)
} else if (baseBitId >= 24 && baseBitId <= 27) {
actualBitId = baseBitId + 4; // 2그룹: Offset 4 (28~31)
} else if (baseBitId >= 32 && baseBitId <= 39) {
actualBitId = baseBitId + 8; // 3그룹: Offset 8 (40~47)
} else if (baseBitId === 48) {
actualBitId = baseBitId + 1; // 4그룹: Offset 1 (49)
} else if (baseBitId >= 50 && baseBitId <= 58) {
actualBitId = baseBitId + 9; // 5그룹: Offset 9 (59~67)
}
}
await sendHmiBitDirect(actualBitId);
}
// 실제 HTTP 통신으로 비트 명령 전송
async function sendHmiBitDirect(bitId) {
if (!currentSelectedEquipment || equipmentControlsLocked) return;
try {
const payload = [[bitId, 1]]; // 제어 패킷 [[ID, 1]] (모멘터리 비트)
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/m`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
console.error(`Control failed for bit ${bitId}`);
} else {
const result = await response.json();
pendingControlCommands.set(result.command_id, `M${bitId} 비트 제어`);
}
} catch (e) {
console.error("비트 전송 실패:", e);
}
}
// HMI 설정 레지스터 전송 (D영역 설정)
async function sendHmiRegister(baseRegId, inputElementId) {
if (!currentSelectedEquipment || equipmentControlsLocked) return;
const inputElement = document.getElementById(inputElementId);
if (!inputElement) return;
const rawVal = parseFloat(inputElement.value);
if (!Number.isFinite(rawVal)) return;
// A/B 그룹에 따른 쓰기 레지스터 ID 오프셋 계산 (A: D0, D1 / B: D2, D3)
const isGroupB = (currentActiveGroup === 'B');
let actualRegId = baseRegId;
if (isGroupB) {
actualRegId = baseRegId + 2; // Offset = 2
}
try {
const payload = [[actualRegId, rawVal]];
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/d`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
pendingControlCommands.set(result.command_id, `D${actualRegId} 설정`);
inputElement.value = ""; // 입력창 초기화 (접수 팝업은 조작 편의를 위해 표시하지 않음)
} else {
alert("전송 실패");
}
} catch (e) {
console.error("수치 전송 실패:", e);
}
}
// 모달 다이얼로그 제어
function openAddModal() {
document.getElementById("add-equipment-modal").style.display = "flex";
}
function closeAddModal() {
document.getElementById("add-equipment-modal").style.display = "none";
document.getElementById("add-equipment-form").reset();
}
// 새 설비 저장
async function saveEquipment(event) {
event.preventDefault();
const eqId = document.getElementById("eq-id").value;
const name = document.getElementById("eq-name").value;
const type = document.getElementById("eq-type").value;
const ip = document.getElementById("eq-ip").value;
const payload = {
equipment_id: eqId,
name: name,
type: type,
ip_address: ip,
line_prefix: "line1" // 기본 기본라인 할당
};
try {
const response = await fetch(`${BACKEND_URL}/api/equipment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
closeAddModal();
loadEquipmentCards();
} else {
alert("중복된 ID이거나 등록 정보가 올바르지 않습니다.");
}
} catch (e) {
console.error("Save equipment error:", e);
}
}
// 수동 Full-Sync 초기화 요청 전송
async function triggerManualSync() {
if (!currentSelectedEquipment) return;
if (!confirm("게이트웨이의 내부 캐시를 초기화하고 PLC로부터 전체 데이터를 강제 수신(Full-Sync)하시겠습니까?")) return;
try {
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/sync`, {
method: 'POST'
});
if (response.ok) {
setEquipmentControlsLocked(true);
alert("Full-Sync 초기화 명령이 전송되었습니다. 게이트웨이가 즉시 전체 상태를 갱신합니다.");
} else {
alert("초기화 명령 전송 실패");
}
} catch (e) {
console.error("Manual sync request failed:", e);
alert("서버 통신 오류가 발생했습니다.");
}
}
+327
View File
@@ -0,0 +1,327 @@
// ChemiFactory MES - Inventory and Material Property Management Logic
const API_INVENTORY = "/inventory";
const API_SUPPLIERS = "/suppliers";
let allInventories = [];
let allMaterials = [];
let allSuppliers = [];
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchInventory();
fetchMaterials();
fetchSuppliers();
});
// Fetch API Data
async function fetchInventory() {
try {
const res = await fetch(`${API_INVENTORY}/`);
if (!res.ok) throw new Error("Failed to fetch inventory");
allInventories = await res.json();
renderInventoryList(allInventories);
} catch (err) {
console.error(err);
document.getElementById("inventory-list-body").innerHTML = `<tr><td colspan="10" style="text-align:center; color:var(--accent-red); padding:20px 0;">재고 데이터를 불러오지 못했습니다.</td></tr>`;
}
}
async function fetchMaterials() {
try {
const res = await fetch(`${API_INVENTORY}/materials`);
if (!res.ok) throw new Error("Failed to fetch materials");
allMaterials = await res.json();
renderMaterialList(allMaterials);
bindMaterialDropdown(allMaterials);
} catch (err) {
console.error(err);
document.getElementById("material-list-body").innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--accent-red); padding:20px 0;">자재 데이터를 불러오지 못했습니다.</td></tr>`;
}
}
async function fetchSuppliers() {
try {
const res = await fetch(`${API_SUPPLIERS}/`);
if (!res.ok) throw new Error("Failed to fetch suppliers");
allSuppliers = await res.json();
bindSupplierDropdown(allSuppliers);
} catch (err) {
console.error(err);
}
}
// Renderers
function renderInventoryList(list) {
const tbody = document.getElementById("inventory-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color:var(--text-muted); padding:30px 0;">재고 입고 원장이 비어있습니다.</td></tr>`;
return;
}
list.forEach(inv => {
const tr = document.createElement("tr");
const statusBadge = inv.is_depleted
? `<span class="depleted-badge">소진 완료</span>`
: `<span class="active-badge">재고 있음</span>`;
tr.innerHTML = `
<td>${inv.receipt_date}</td>
<td><strong>${inv.item_name || "-"}</strong><br><small style="color:var(--text-muted);">${inv.item_number || "-"}</small></td>
<td>${inv.material_name || "-"} (${inv.material_code || "-"})</td>
<td>${inv.receipt_quantity.toLocaleString()}</td>
<td>${(inv.usage_quantity || 0).toLocaleString()}</td>
<td style="font-weight:700; color:${inv.stock <= 0 ? 'var(--accent-red)' : 'var(--text-primary)'}">${(inv.stock || 0).toLocaleString()}</td>
<td>${inv.unit_cost.toLocaleString()} 원</td>
<td>${inv.supplier || "-"}</td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-secondary" style="padding:4px 8px; font-size:11px;" onclick="openEditInventoryModal(${JSON.stringify(inv).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="depleteInventory(${inv.id})">소진</button>
</td>
`;
tbody.appendChild(tr);
});
}
function renderMaterialList(list) {
const tbody = document.getElementById("material-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 자재 기본 물성이 없습니다.</td></tr>`;
return;
}
list.forEach(mat => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${mat.material_code}</code></td>
<td><strong>${mat.material_name}</strong></td>
<td>${mat.material_type === 'Yarn' ? '원사 (Yarn)' : mat.material_type === 'Bond' ? '본드 (Bond)' : '기타'}</td>
<td>${mat.density} g/cm³</td>
<td>${mat.remarks || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding:4px 8px; font-size:11px;" onclick="openEditMaterialModal(${JSON.stringify(mat).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteMaterial(${mat.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function bindMaterialDropdown(materials) {
const select = document.getElementById("inv-material");
select.innerHTML = '<option value="">-- 자재 규격 선택 --</option>';
materials.forEach(m => {
select.innerHTML += `<option value="${m.id}">${m.material_name} (${m.material_code})</option>`;
});
}
function bindSupplierDropdown(suppliers) {
const select = document.getElementById("inv-supplier");
select.innerHTML = '<option value="">-- 공급사 선택 --</option>';
suppliers.forEach(s => {
select.innerHTML += `<option value="${s.id}">${s.name_kr}</option>`;
});
}
// Events
function initEvents() {
// Tab switching
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// Modal windows cancel
const modalIds = ["material-modal", "inventory-modal"];
modalIds.forEach(id => {
const modal = document.getElementById(id);
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
});
// Material logic trigger
document.getElementById("btn-add-material").addEventListener("click", () => {
document.getElementById("material-form").reset();
document.getElementById("material-id").value = "";
document.getElementById("material-modal-title").innerText = "자재 물성 등록";
document.getElementById("material-modal").style.display = "flex";
});
document.getElementById("material-form").addEventListener("submit", handleMaterialSubmit);
// Inventory logic trigger
document.getElementById("btn-add-inventory").addEventListener("click", () => {
document.getElementById("inventory-form").reset();
document.getElementById("inventory-id").value = "";
document.getElementById("usage-group").style.display = "none";
document.getElementById("inv-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("inventory-modal-title").innerText = "자재 신규 입고 등록";
document.getElementById("inventory-modal").style.display = "flex";
});
document.getElementById("inventory-form").addEventListener("submit", handleInventorySubmit);
// Search filters
document.getElementById("search-inventory").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allInventories.filter(i =>
(i.item_name && i.item_name.toLowerCase().includes(query)) ||
(i.item_number && i.item_number.toLowerCase().includes(query)) ||
(i.material_name && i.material_name.toLowerCase().includes(query)) ||
(i.material_code && i.material_code.toLowerCase().includes(query))
);
renderInventoryList(filtered);
});
document.getElementById("search-material").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allMaterials.filter(m =>
m.material_name.toLowerCase().includes(query) ||
m.material_code.toLowerCase().includes(query)
);
renderMaterialList(filtered);
});
}
// Submits Handlers
async function handleMaterialSubmit(e) {
e.preventDefault();
const id = document.getElementById("material-id").value;
const payload = {
material_code: document.getElementById("mat-code").value,
material_name: document.getElementById("mat-name").value,
material_type: document.getElementById("mat-type").value,
density: parseFloat(document.getElementById("mat-density").value),
remarks: document.getElementById("mat-remarks").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_INVENTORY}/materials/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_INVENTORY}/materials`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API failed");
document.getElementById("material-modal").style.display = "none";
fetchMaterials();
} catch (err) {
console.error(err);
alert("자재 규격 저장에 실패했습니다.");
}
}
window.openEditMaterialModal = function(mat) {
document.getElementById("material-id").value = mat.id;
document.getElementById("mat-code").value = mat.material_code;
document.getElementById("mat-name").value = mat.material_name;
document.getElementById("mat-type").value = mat.material_type;
document.getElementById("mat-density").value = mat.density;
document.getElementById("mat-remarks").value = mat.remarks || "";
document.getElementById("material-modal-title").innerText = "자재 물성 수정";
document.getElementById("material-modal").style.display = "flex";
};
window.deleteMaterial = async function(id) {
if (!confirm("이 자재 물성 규격을 정말 삭제하시겠습니까?\n이미 입고된 재고 이력이 존재할 경우 삭제가 거부될 수 있습니다.")) return;
try {
const res = await fetch(`${API_INVENTORY}/materials/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Check references");
}
fetchMaterials();
} catch (err) {
console.error(err);
alert(`자재 삭제 실패: ${err.message}`);
}
};
// Inventory Submits Handlers
async function handleInventorySubmit(e) {
e.preventDefault();
const id = document.getElementById("inventory-id").value;
const payload = {
material_id: parseInt(document.getElementById("inv-material").value),
supplier_id: parseInt(document.getElementById("inv-supplier").value),
receipt_date: document.getElementById("inv-date").value,
item_name: document.getElementById("inv-item-name").value || null,
item_number: document.getElementById("inv-item-num").value || null,
receipt_quantity: parseFloat(document.getElementById("inv-qty").value),
usage_quantity: id ? parseFloat(document.getElementById("inv-usage").value || 0) : 0.0,
unit_cost: parseFloat(document.getElementById("inv-cost").value),
remarks: document.getElementById("inv-remarks").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_INVENTORY}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_INVENTORY}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API failed");
document.getElementById("inventory-modal").style.display = "none";
fetchInventory();
} catch (err) {
console.error(err);
alert("재고 정보를 저장하는 데 실패했습니다.");
}
}
window.openEditInventoryModal = function(inv) {
document.getElementById("inventory-id").value = inv.id;
document.getElementById("inv-material").value = inv.material_id;
document.getElementById("inv-supplier").value = inv.supplier_id;
document.getElementById("inv-date").value = inv.receipt_date;
document.getElementById("inv-item-name").value = inv.item_name || "";
document.getElementById("inv-item-num").value = inv.item_number || "";
document.getElementById("inv-qty").value = inv.receipt_quantity;
document.getElementById("inv-cost").value = inv.unit_cost;
document.getElementById("inv-remarks").value = inv.remarks || "";
// Show usage input when modifying
document.getElementById("usage-group").style.display = "block";
document.getElementById("inv-usage").value = inv.usage_quantity || 0;
document.getElementById("inventory-modal-title").innerText = "재고 정보 수정";
document.getElementById("inventory-modal").style.display = "flex";
};
window.depleteInventory = async function(id) {
if (!confirm("이 입고 건의 잔량 재고를 모두 소진 처리하시겠습니까?")) return;
try {
const res = await fetch(`${API_INVENTORY}/${id}/deplete`, { method: "PUT" });
if (!res.ok) throw new Error("Failed to deplete");
fetchInventory();
} catch (err) {
console.error(err);
alert("재고 소진 처리에 실패했습니다.");
}
};
+138
View File
@@ -0,0 +1,138 @@
// ChemiFactory MES - Material Properties Specifications Management Logic
const API_BASE = "/inventory/materials";
let allMaterials = [];
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchMaterials();
});
async function fetchMaterials() {
try {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error("Failed to fetch material specifications");
allMaterials = await res.json();
renderMaterialList(allMaterials);
} catch (err) {
console.error(err);
document.getElementById("material-list-body").innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--accent-red); padding:20px 0;">자재 규격 데이터를 로드하지 못했습니다.</td></tr>`;
}
}
function renderMaterialList(list) {
const tbody = document.getElementById("material-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 자재 물성 규격이 존재하지 않습니다.</td></tr>`;
return;
}
list.forEach(m => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${m.material_code}</code></td>
<td><strong>${m.material_name}</strong></td>
<td>${m.material_type === 'Yarn' ? '원사 (Yarn)' : m.material_type === 'Bond' ? '본드 (Bond)' : '기타'}</td>
<td>${m.density} g/cm³</td>
<td>${m.remarks || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding: 4px 8px; font-size:11px;" onclick="openEditMaterialModal(${JSON.stringify(m).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 4px 8px; font-size:11px;" onclick="deleteMaterial(${m.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function initEvents() {
// Search filter
document.getElementById("search-material").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allMaterials.filter(m =>
m.material_name.toLowerCase().includes(query) ||
m.material_code.toLowerCase().includes(query)
);
renderMaterialList(filtered);
});
// Close Modals
const modal = document.getElementById("material-modal");
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
// Trigger Form
document.getElementById("btn-add-material").addEventListener("click", () => {
document.getElementById("material-form").reset();
document.getElementById("material-id").value = "";
document.getElementById("material-modal-title").innerText = "자재 규격 정보 등록";
modal.style.display = "flex";
});
document.getElementById("material-form").addEventListener("submit", handleMaterialSubmit);
}
async function handleMaterialSubmit(e) {
e.preventDefault();
const id = document.getElementById("material-id").value;
const payload = {
material_code: document.getElementById("mat-code").value,
material_name: document.getElementById("mat-name").value,
material_type: document.getElementById("mat-type").value,
density: parseFloat(document.getElementById("mat-density").value),
remarks: document.getElementById("mat-remarks").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API operation failed");
modal.style.display = "none";
fetchMaterials();
} catch (err) {
console.error(err);
alert("자재 규격 데이터를 저장하는 데 실패했습니다.");
}
}
window.openEditMaterialModal = function(m) {
document.getElementById("material-id").value = m.id;
document.getElementById("mat-code").value = m.material_code;
document.getElementById("mat-name").value = m.material_name;
document.getElementById("mat-type").value = m.material_type;
document.getElementById("mat-density").value = m.density;
document.getElementById("mat-remarks").value = m.remarks || "";
document.getElementById("material-modal-title").innerText = "자재 규격 정보 수정";
document.getElementById("material-modal").style.display = "flex";
};
window.deleteMaterial = async function(id) {
if (!confirm("이 자재 물성 규격을 삭제하시겠습니까?\n이미 해당 규격으로 입고된 재고 내역이 존재하면 삭제가 불가합니다.")) return;
try {
const res = await fetch(`${API_BASE}/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "오류 발생");
}
fetchMaterials();
} catch (err) {
console.error(err);
alert(`물성 데이터 삭제 실패: ${err.message}`);
}
};
+534
View File
@@ -0,0 +1,534 @@
// ChemiFactory MES - Production Plan Gantt Chart & Simulator Logic
const API_PLANS = "/plans";
const API_CUSTOMERS = "/customers";
const API_INVENTORY = "/inventory";
let allPlans = [];
let allCustomers = [];
let allYarns = [];
let allBonds = [];
// Gantt configuration
const cellWidth = 40; // px
let ganttStartDate = null;
let ganttEndDate = null;
let datesArray = [];
let selectedEquipmentIds = [];
let simResultData = null;
document.addEventListener("DOMContentLoaded", () => {
initEvents();
initGanttDates();
loadAllData();
initScrollSync();
});
function initGanttDates() {
const today = new Date();
// 2 weeks before today
const start = new Date(today);
start.setDate(today.getDate() - 14);
ganttStartDate = start;
// 5 weeks after today
const end = new Date(today);
end.setDate(today.getDate() + 35);
ganttEndDate = end;
datesArray = [];
let curr = new Date(start);
while (curr <= end) {
datesArray.push(new Date(curr));
curr.setDate(curr.getDate() + 1);
}
}
async function loadAllData() {
await fetchCustomers();
await fetchInventoryDropdowns();
await fetchPlans();
}
async function fetchPlans() {
try {
const res = await fetch(`${API_PLANS}/`);
if (!res.ok) throw new Error("Failed to fetch plans");
allPlans = await res.json();
renderGantt();
} catch (err) {
console.error("DB 연결 불가로 인해 간트차트 레이아웃 확인용 데모 데이터를 렌더링합니다:", err);
const todayStr = new Date().toISOString().substring(0, 10);
// 데모용 생산계획 데이터 2건 추가
allPlans = [
{
plan_id: 1,
plan_name: "샘플고객사A - 원사 12K (데모)",
customer_id: 1,
customer_name: "샘플고객사A",
yarn_inventory_id: 1,
yarn_name: "원사 12K",
bond_inventory_id: null,
requested_quantity_kg: 850.5,
start_date: todayStr,
production_days: 14.5,
assigned_equipment: [{equipment_id: 8}],
works_on_saturday: true,
works_on_sunday: false,
yarn_diameter: 7.0,
yarn_k: 12,
ports: 40,
cycle_time: 8.0,
cut_length: 6.0,
work_hours_day: 16,
plan_estimated_sales: 12500000,
plan_company_margin: 4200000,
plan_total_material_cost: 8300000,
plan_net_processing_cost: 3000000,
plan_processing_fee: 7200000,
plan_yarn_cost: 7500000,
plan_bond_cost: 80000
},
{
plan_id: 2,
plan_name: "샘플고객사B - 원사 24K (데모)",
customer_id: 2,
customer_name: "샘플고객사B",
yarn_inventory_id: 2,
yarn_name: "원사 24K",
bond_inventory_id: null,
requested_quantity_kg: 1200.0,
start_date: new Date(Date.now() + 86400000 * 5).toISOString().substring(0, 10), // 5일 뒤 시작
production_days: 20.0,
assigned_equipment: [{equipment_id: 8}],
works_on_saturday: true,
works_on_sunday: true,
yarn_diameter: 7.5,
yarn_k: 24,
ports: 40,
cycle_time: 8.5,
cut_length: 6.5,
work_hours_day: 16,
plan_estimated_sales: 18000000,
plan_company_margin: 5500000,
plan_total_material_cost: 12500000,
plan_net_processing_cost: 4500000,
plan_processing_fee: 10000000,
plan_yarn_cost: 11000000,
plan_bond_cost: 150000
}
];
renderGantt();
}
}
async function fetchCustomers() {
try {
const res = await fetch(`${API_CUSTOMERS}/`);
if (!res.ok) throw new Error("Failed to fetch customers");
allCustomers = await res.json();
const select = document.getElementById("plan-customer");
select.innerHTML = '<option value="">-- 거래처 선택 --</option>';
allCustomers.forEach(c => {
select.innerHTML += `<option value="${c.id}">${c.name_kr}</option>`;
});
} catch (err) {
console.error(err);
}
}
async function fetchInventoryDropdowns() {
try {
const res = await fetch(`${API_INVENTORY}/`);
if (!res.ok) throw new Error("Failed to fetch inventory dropdowns");
const inventories = await res.json();
// 대소문자 및 한글 구분 필터링 및 소진되지 않은(is_depleted !== 1) 재고 추출
allYarns = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1);
allBonds = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1);
const yarnSelect = document.getElementById("plan-yarn");
yarnSelect.innerHTML = '<option value="">-- 입고 원사 선택 --</option>';
allYarns.forEach(y => {
const labelName = y.item_name || y.material_name || "미지정 원사";
yarnSelect.innerHTML += `<option value="${y.id}">${labelName} (재고: ${y.stock || 0}kg)</option>`;
});
const bondSelect = document.getElementById("plan-bond");
bondSelect.innerHTML = '<option value="">-- 입고 본드 선택 (선택사항) --</option>';
allBonds.forEach(b => {
const labelName = b.item_name || b.material_name || "미지정 본드";
bondSelect.innerHTML += `<option value="${b.id}">${labelName} (재고: ${b.stock || 0}kg)</option>`;
});
} catch (err) {
console.error("Dropdown load error: ", err);
}
}
// Gantt Rendering Logic
function renderGantt() {
renderGanttHeaders();
const leftBody = document.getElementById("gantt-left-body");
const rightBody = document.getElementById("gantt-right-body");
leftBody.innerHTML = "";
rightBody.innerHTML = "";
if (allPlans.length === 0) {
leftBody.innerHTML = `<div style="padding: 20px; font-size:12px; color:var(--text-muted);">수립된 계획이 없습니다.</div>`;
return;
}
allPlans.forEach(plan => {
// Left Column Panel Row
const leftRow = document.createElement("div");
leftRow.className = "gantt-row";
leftRow.innerHTML = `
<div class="gantt-left-item" onclick="openEditPlanModal(${JSON.stringify(plan).replace(/"/g, '&quot;')})">
<strong>${plan.plan_name}</strong>
<small>${plan.customer_name} | ${plan.yarn_name}</small>
</div>
`;
leftBody.appendChild(leftRow);
// Right Timeline Row
const rightRow = document.createElement("div");
rightRow.className = "gantt-row";
rightRow.style.width = `${datesArray.length * cellWidth}px`;
let gridHtml = `<div class="gantt-right-grid" style="width: ${datesArray.length * cellWidth}px;">`;
datesArray.forEach(() => {
gridHtml += `<div class="gantt-grid-cell"></div>`;
});
gridHtml += '</div>';
rightRow.innerHTML = gridHtml;
// Render schedule bar overlay
const planStart = new Date(plan.start_date);
const planEnd = new Date(plan.end_date);
// Calculate offsets
const startDiff = Math.ceil((planStart - ganttStartDate) / (1000 * 60 * 60 * 24));
const duration = Math.ceil((planEnd - planStart) / (1000 * 60 * 60 * 24)) + 1;
// Draw only if it overlaps with our Gantt window
if (planEnd >= ganttStartDate && planStart <= ganttEndDate) {
const visibleStart = Math.max(0, startDiff);
const visibleDuration = duration - (visibleStart - startDiff);
const barContainer = document.createElement("div");
barContainer.className = "gantt-bar-container";
barContainer.style.left = `${visibleStart * cellWidth}px`;
barContainer.style.width = `${visibleDuration * cellWidth}px`;
const bar = document.createElement("div");
bar.className = "gantt-bar-item";
bar.innerText = `[${plan.requested_quantity_kg}kg] ${plan.plan_name}`;
bar.addEventListener("click", () => openEditPlanModal(plan));
barContainer.appendChild(bar);
rightRow.appendChild(barContainer);
}
rightBody.appendChild(rightRow);
});
}
function renderGanttHeaders() {
const headerRow = document.getElementById("gantt-date-headers");
headerRow.style.width = `${datesArray.length * cellWidth}px`;
headerRow.innerHTML = "";
const todayStr = new Date().toISOString().substring(0, 10);
datesArray.forEach(d => {
const day = d.getDate();
const dow = d.getDay();
const dateStr = d.toISOString().substring(0, 10);
let cellClass = "gantt-day-header-cell";
if (dow === 6) cellClass += " day-sat";
else if (dow === 0) cellClass += " day-sun";
if (dateStr === todayStr) cellClass += " day-today";
headerRow.innerHTML += `
<div class="${cellClass}">
<span style="font-size:10px; color:var(--text-secondary);">${d.getMonth() + 1}/${day}</span>
<span style="font-weight:700;">${['일', '월', '화', '수', '목', '금', '토'][dow]}</span>
</div>
`;
});
}
// Events Integration
function initEvents() {
const modal = document.getElementById("plan-modal");
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
document.getElementById("btn-add-plan").addEventListener("click", () => {
document.getElementById("plan-form").reset();
document.getElementById("plan-id").value = "";
document.getElementById("plan-modal-title").innerText = "생산 계획 수립";
document.getElementById("plan-start-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("equip-assignment-box").style.display = "none";
document.getElementById("btn-save-plan").disabled = true;
simResultData = null;
selectedEquipmentIds = [];
document.getElementById("sim-days").innerText = "-";
document.getElementById("sim-sales").innerText = "-";
document.getElementById("sim-margin").innerText = "-";
modal.style.display = "flex";
});
document.getElementById("btn-simulate").addEventListener("click", runSimulation);
document.getElementById("plan-form").addEventListener("submit", savePlan);
}
// Simulation & Optimization
async function runSimulation() {
const yarnInvId = document.getElementById("plan-yarn").value;
const qty = document.getElementById("plan-qty").value;
if (!yarnInvId || !qty) {
alert("원사 자재와 목표 생산량을 먼저 입력해 주십시오.");
return;
}
const payload = {
inputs: {
yarn_inventory_id: parseInt(yarnInvId),
bond_inventory_id: parseInt(document.getElementById("plan-bond").value) || null,
yarn_diameter_micron: parseFloat(document.getElementById("plan-yarn-dia").value),
yarn_k: parseInt(document.getElementById("plan-yarn-k").value),
ports: parseInt(document.getElementById("plan-ports").value),
cycle_time_hz: parseFloat(document.getElementById("plan-hz").value),
cut_length_mm: parseFloat(document.getElementById("plan-cut").value),
work_hours_day: parseInt(document.getElementById("plan-hours").value),
work_days_month: 20.0, // Default constraint
num_machines: 2.0, // Baseline machines
bond_percentage: 3.0
},
targetProductionQuantity: parseFloat(qty)
};
try {
const res = await fetch("/inventory/analysis/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Calculation failure");
const data = await res.json();
simResultData = data;
// Render results
document.getElementById("sim-days").innerText = data.required_days_target.toFixed(1);
document.getElementById("sim-sales").innerText = Math.round(data.plan_estimated_sales).toLocaleString();
document.getElementById("sim-margin").innerText = Math.round(data.plan_company_margin).toLocaleString();
// Query Available equipment
const startDate = document.getElementById("plan-start-date").value;
if (startDate) {
await queryAvailableEquipment(startDate, data.required_days_target);
}
} catch (err) {
console.error(err);
alert("시뮬레이션 가동 계산 중 오류가 발생했습니다.");
}
}
async function queryAvailableEquipment(startDateStr, requiredDays) {
// ⚠️ 장비 배정 부분 연동 (간트 시각화 및 배정을 위해 가용 장비 1~4호기를 체크할 수 있도록 칩 형태로 조회)
const startDate = new Date(startDateStr);
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + Math.ceil(requiredDays) - 1);
const sat = document.getElementById("plan-sat").checked;
const sun = document.getElementById("plan-sun").checked;
try {
const url = `${API_PLANS}/find-available-equipment?start_date=${startDateStr}&end_date=${endDate.toISOString().substring(0, 10)}&works_on_saturday=${sat}&works_on_sunday=${sun}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Equipment search failed");
const machines = await res.json();
const container = document.getElementById("available-machines-container");
container.innerHTML = "";
if (machines.length === 0) {
container.innerHTML = "<span style='color:var(--accent-red); font-size:12px;'>해당 일정에 가용한 설비가 없습니다.</span>";
document.getElementById("btn-save-plan").disabled = true;
} else {
machines.forEach(m => {
const chip = document.createElement("div");
chip.className = "machine-chip";
if (selectedEquipmentIds.includes(m.id)) chip.classList.add("selected");
chip.innerText = m.name;
chip.addEventListener("click", () => {
if (selectedEquipmentIds.includes(m.id)) {
selectedEquipmentIds = selectedEquipmentIds.filter(id => id !== m.id);
chip.classList.remove("selected");
} else {
selectedEquipmentIds.push(m.id);
chip.classList.add("selected");
}
document.getElementById("btn-save-plan").disabled = selectedEquipmentIds.length === 0;
});
container.appendChild(chip);
});
}
document.getElementById("equip-assignment-box").style.display = "block";
} catch (err) {
console.error(err);
}
}
async function savePlan(e) {
e.preventDefault();
if (!simResultData || selectedEquipmentIds.length === 0) return;
const id = document.getElementById("plan-id").value;
const custSelect = document.getElementById("plan-customer");
const yarnSelect = document.getElementById("plan-yarn");
const bondSelect = document.getElementById("plan-bond");
const payload = {
plan_name: `${custSelect.options[custSelect.selectedIndex].text} - ${yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0]}`,
customer_id: parseInt(custSelect.value),
customer_name: custSelect.options[custSelect.selectedIndex].text,
yarn_inventory_id: parseInt(yarnSelect.value),
yarn_name: yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0],
bond_inventory_id: parseInt(bondSelect.value) || null,
bond_name: bondSelect.value ? bondSelect.options[bondSelect.selectedIndex].text.split(' ')[0] : "",
requested_quantity_kg: parseFloat(document.getElementById("plan-qty").value),
start_date: document.getElementById("plan-start-date").value,
production_days: simResultData.required_days_target,
// Sim data
hourly_net_cost_per_machine: simResultData.hourly_net_cost_per_machine,
hourly_profit_per_machine: simResultData.hourly_profit_per_machine,
plan_total_material_cost: simResultData.plan_total_material_cost,
yarn_diameter: parseFloat(document.getElementById("plan-yarn-dia").value),
yarn_k: parseFloat(document.getElementById("plan-yarn-k").value),
ports: parseFloat(document.getElementById("plan-ports").value),
cycle_time: parseFloat(document.getElementById("plan-hz").value),
cut_length: parseFloat(document.getElementById("plan-cut").value),
work_hours_day: parseFloat(document.getElementById("plan-hours").value),
work_days_month: 20.0,
num_machines: parseFloat(selectedEquipmentIds.length),
plan_net_processing_cost: simResultData.plan_net_processing_cost,
plan_company_margin: simResultData.plan_company_margin,
plan_processing_fee: simResultData.plan_processing_fee,
plan_yarn_cost: simResultData.plan_yarn_cost,
plan_bond_cost: simResultData.plan_bond_cost,
plan_estimated_sales: simResultData.plan_estimated_sales,
// Allocations
equipment_ids: selectedEquipmentIds,
works_on_saturday: document.getElementById("plan-sat").checked,
works_on_sunday: document.getElementById("plan-sun").checked
};
try {
let res;
if (id) {
res = await fetch(`${API_PLANS}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_PLANS}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Plan saving failed");
document.getElementById("plan-modal").style.display = "none";
fetchPlans();
} catch (err) {
console.error(err);
alert("계획 등록에 실패했습니다.");
}
}
window.openEditPlanModal = function(plan) {
document.getElementById("plan-id").value = plan.plan_id;
document.getElementById("plan-customer").value = plan.customer_id;
document.getElementById("plan-yarn").value = plan.yarn_inventory_id;
document.getElementById("plan-bond").value = plan.bond_inventory_id || "";
document.getElementById("plan-qty").value = plan.requested_quantity_kg;
document.getElementById("plan-start-date").value = plan.start_date;
document.getElementById("plan-sat").checked = plan.works_on_saturday || false;
document.getElementById("plan-sun").checked = plan.works_on_sunday || false;
document.getElementById("plan-yarn-dia").value = plan.yarn_diameter;
document.getElementById("plan-yarn-k").value = plan.yarn_k;
document.getElementById("plan-ports").value = plan.ports;
document.getElementById("plan-hz").value = plan.cycle_time;
document.getElementById("plan-cut").value = plan.cut_length;
document.getElementById("plan-hours").value = plan.work_hours_day;
selectedEquipmentIds = plan.assigned_equipment ? plan.assigned_equipment.map(ae => ae.equipment_id) : [];
simResultData = {
required_days_target: plan.production_days,
plan_estimated_sales: plan.plan_estimated_sales,
plan_company_margin: plan.plan_company_margin,
hourly_net_cost_per_machine: plan.hourly_net_cost_per_machine,
hourly_profit_per_machine: plan.hourly_profit_per_machine,
plan_total_material_cost: plan.plan_total_material_cost,
plan_net_processing_cost: plan.plan_net_processing_cost,
plan_processing_fee: plan.plan_processing_fee,
plan_yarn_cost: plan.plan_yarn_cost,
plan_bond_cost: plan.plan_bond_cost
};
document.getElementById("sim-days").innerText = plan.production_days.toFixed(1);
document.getElementById("sim-sales").innerText = Math.round(plan.plan_estimated_sales).toLocaleString();
document.getElementById("sim-margin").innerText = Math.round(plan.plan_company_margin).toLocaleString();
queryAvailableEquipment(plan.start_date, plan.production_days);
document.getElementById("plan-modal-title").innerText = "생산 계획 정보 조회 및 수정";
document.getElementById("btn-save-plan").disabled = false;
document.getElementById("plan-modal").style.display = "flex";
};
function initScrollSync() {
const leftBody = document.getElementById("gantt-left-body");
const rightBody = document.getElementById("gantt-right-body");
if (!leftBody || !rightBody) return;
let isLeftScrolling = false;
let isRightScrolling = false;
leftBody.addEventListener("scroll", () => {
if (isRightScrolling) {
isRightScrolling = false;
return;
}
isLeftScrolling = true;
rightBody.scrollTop = leftBody.scrollTop;
});
rightBody.addEventListener("scroll", () => {
if (isLeftScrolling) {
isLeftScrolling = false;
return;
}
isRightScrolling = true;
leftBody.scrollTop = rightBody.scrollTop;
});
}
+165
View File
@@ -0,0 +1,165 @@
// ChemiFactory MES - System Settings Frontend Logic
const API_SETTINGS = "/settings";
let allSettings = [];
// DOM 준비 상태와 무관하게 theme.js 로딩 완료 대기
function waitForThemeAndInit() {
// 최대 2초 동안 AppTheme 대기
let attempts = 0;
const maxAttempts = 20; // 100ms * 20 = 2초
const checkReady = () => {
attempts++;
if (window.AppTheme) {
// AppTheme 준비 완료, 테마 선택기 초기화
initThemeSelector();
} else if (attempts < maxAttempts) {
// 아직 준비 안 됨, 100ms 후 재시도
setTimeout(checkReady, 100);
} else {
console.warn("[Setting] AppTheme not loaded after 2 seconds");
}
};
checkReady();
}
document.addEventListener("DOMContentLoaded", () => {
fetchSettings();
document.getElementById("settings-form").addEventListener("submit", saveSettings);
waitForThemeAndInit();
});
// --- 화면 테마 선택기 (theme.js의 전역 AppTheme 사용, 서버 DB와 무관) ---
function initThemeSelector() {
const cards = document.querySelectorAll(".theme-option-card");
if (!cards.length) {
console.warn("[Theme Selector] No theme option cards found");
return;
}
if (!window.AppTheme) {
console.error("[Theme Selector] AppTheme is not available");
return;
}
// 현재 저장된 테마에 active 표시
const applyActiveState = (theme) => {
cards.forEach(card => {
card.classList.toggle("active", card.dataset.themeValue === theme);
});
};
// 초기 테마 상태 적용
const currentTheme = window.AppTheme.get();
applyActiveState(currentTheme);
console.log("[Theme Selector] Initialized with theme:", currentTheme);
cards.forEach(card => {
const selectTheme = () => {
const theme = card.dataset.themeValue;
console.log("[Theme Selector] User selected theme:", theme);
window.AppTheme.set(theme); // localStorage 저장 + 즉시 화면 적용
applyActiveState(theme);
};
card.addEventListener("click", selectTheme);
// 키보드 접근성 (Enter/Space)
card.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectTheme();
}
});
});
}
async function fetchSettings() {
try {
const res = await fetch(`${API_SETTINGS}/`);
if (!res.ok) throw new Error("Failed to fetch settings");
allSettings = await res.json();
renderSettings(allSettings);
} catch (err) {
console.error(err);
document.getElementById("settings-container").innerHTML = `<div style="color:var(--accent-red); text-align:center; padding:20px 0;">설정 정보를 불러오는 도중 오류가 발생했습니다.</div>`;
}
}
function renderSettings(settings) {
const container = document.getElementById("settings-container");
container.innerHTML = "";
// Group settings by category
const grouped = {};
settings.forEach(s => {
if (!grouped[s.setting_group]) {
grouped[s.setting_group] = [];
}
grouped[s.setting_group].push(s);
});
for (const groupName in grouped) {
// Category Header
const categoryDiv = document.createElement("div");
categoryDiv.className = "settings-category";
categoryDiv.innerText = translateGroup(groupName);
container.appendChild(categoryDiv);
// Inputs mapping
grouped[groupName].forEach(s => {
const group = document.createElement("div");
group.className = "form-group";
let inputType = "text";
if (s.setting_type === "integer" || s.setting_type === "float") {
inputType = "number";
}
group.innerHTML = `
<label for="setting-${s.setting_key}">${s.setting_key} (${s.description || "상세 비고 없음"})</label>
<input type="${inputType}" id="setting-${s.setting_key}" data-key="${s.setting_key}" class="form-control" value="${s.setting_value}">
<div class="form-desc">타입: ${s.setting_type}</div>
`;
container.appendChild(group);
});
}
}
function translateGroup(group) {
const dict = {
"mqtt": "MQTT 통신 파라미터 설정",
"gateway": "ESP32 게이트웨이 기기 연동 설정",
"database": "데이터베이스 연동 세부 설정",
"system": "시스템 전역 상용 매개변수"
};
return dict[group] || group;
}
async function saveSettings(e) {
e.preventDefault();
const payload = [];
const inputs = document.querySelectorAll("#settings-container input");
inputs.forEach(input => {
payload.push({
setting_key: input.dataset.key,
setting_value: input.value
});
});
try {
const res = await fetch(`${API_SETTINGS}/`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Saving settings failed");
alert("시스템 설정 정보가 성공적으로 업데이트되었습니다!");
fetchSettings();
} catch (err) {
console.error(err);
alert("설정 적용 도중 오류가 발생했습니다.");
}
}
+409
View File
@@ -0,0 +1,409 @@
// ChemiFactory MES - Supplier Management Frontend Logic
const API_BASE = "/suppliers";
let allSuppliers = [];
let selectedSupplierId = null;
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchSuppliers();
});
// APIs Integration
async function fetchSuppliers() {
try {
const res = await fetch(`${API_BASE}/`);
if (!res.ok) throw new Error("Failed to fetch suppliers");
allSuppliers = await res.json();
renderSupplierList(allSuppliers);
} catch (err) {
console.error(err);
showListError();
}
}
async function showSupplierDetail(id) {
try {
selectedSupplierId = id;
const res = await fetch(`${API_BASE}/${id}`);
if (!res.ok) throw new Error("Failed to fetch supplier details");
const data = await res.json();
renderSupplierDetail(data);
} catch (err) {
console.error(err);
alert("공급사 정보를 상세히 불러오지 못했습니다.");
}
}
// Render Lists
function renderSupplierList(list) {
const tbody = document.getElementById("supplier-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 공급사가 없습니다.</td></tr>`;
return;
}
list.forEach(supp => {
const tr = document.createElement("tr");
if (selectedSupplierId === supp.id) tr.className = "active-row";
tr.innerHTML = `
<td><strong>${supp.name_kr}</strong></td>
<td>${supp.contact_number || "-"}</td>
<td>${supp.business_registration_number || "-"}</td>
`;
tr.addEventListener("click", () => {
document.querySelectorAll("#supplier-list-body tr").forEach(el => el.classList.remove("active-row"));
tr.classList.add("active-row");
showSupplierDetail(supp.id);
});
tbody.appendChild(tr);
});
}
function showListError() {
const tbody = document.getElementById("supplier-list-body");
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--accent-red); padding:30px 0;">데이터 통신에 실패했습니다. DB 작동을 확인하십시오.</td></tr>`;
}
function renderSupplierDetail(data) {
// Show details card, hide placeholder
document.getElementById("supplier-placeholder-card").style.display = "none";
document.getElementById("supplier-detail-card").style.display = "block";
const supp = data.supplier;
document.getElementById("detail-name-kr").innerText = supp.name_kr;
document.getElementById("detail-name-en").innerText = supp.name_en || "";
document.getElementById("detail-brn").innerText = supp.business_registration_number || "-";
document.getElementById("detail-contact").innerText = supp.contact_number || "-";
document.getElementById("detail-address").innerText = supp.address || "-";
// Bind Contacts
renderContactsList(data.contacts || []);
// Bind Purchase Activities
renderActivitiesList(data.purchaseActivities || []);
}
function renderContactsList(contacts) {
const tbody = document.getElementById("contact-list-body");
tbody.innerHTML = "";
if (contacts.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:20px 0;">등록된 담당자가 없습니다.</td></tr>`;
return;
}
contacts.forEach(c => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><strong>${c.name}</strong></td>
<td>${c.position || "-"} ${c.work_location ? `(${c.work_location})` : ""}</td>
<td>${c.phone_number || "-"}</td>
<td>${c.email || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditContactModal(${JSON.stringify(c).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteContact(${c.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function renderActivitiesList(activities) {
const tbody = document.getElementById("activity-list-body");
tbody.innerHTML = "";
if (activities.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:20px 0;">매입 활동 내역이 없습니다.</td></tr>`;
return;
}
// Sort by date descending
activities.sort((a, b) => new Date(b.activity_date) - new Date(a.activity_date));
activities.forEach(act => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${act.activity_date}</td>
<td><span class="badge badge-online">${act.activity_type}</span></td>
<td style="max-width: 250px; white-wrap: wrap;">${act.memo}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditActivityModal(${JSON.stringify(act).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteActivity(${act.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
// Event Bindings
function initEvents() {
// Search Filter
document.getElementById("search-supplier").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allSuppliers.filter(c =>
c.name_kr.toLowerCase().includes(query) ||
(c.name_en && c.name_en.toLowerCase().includes(query)) ||
(c.business_registration_number && c.business_registration_number.includes(query))
);
renderSupplierList(filtered);
});
// Tab Toggle
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// Modals Control
const modals = ["supplier-modal", "contact-modal", "activity-modal"];
modals.forEach(mId => {
const modal = document.getElementById(mId);
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
});
// Add Supplier
document.getElementById("btn-add-supplier").addEventListener("click", () => {
document.getElementById("supplier-form").reset();
document.getElementById("supplier-id").value = "";
document.getElementById("supplier-modal-title").innerText = "공급사 등록";
document.getElementById("supplier-modal").style.display = "flex";
});
document.getElementById("supplier-form").addEventListener("submit", handleSupplierSubmit);
// Edit & Delete Supplier
document.getElementById("btn-edit-supplier").addEventListener("click", () => {
const activeSupp = allSuppliers.find(c => c.id === selectedSupplierId);
if (!activeSupp) return;
document.getElementById("supplier-id").value = activeSupp.id;
document.getElementById("supp-name-kr").value = activeSupp.name_kr;
document.getElementById("supp-name-en").value = activeSupp.name_en || "";
document.getElementById("supp-brn").value = activeSupp.business_registration_number || "";
document.getElementById("supp-contact").value = activeSupp.contact_number || "";
document.getElementById("supp-address").value = activeSupp.address || "";
document.getElementById("supplier-modal-title").innerText = "공급사 정보 수정";
document.getElementById("supplier-modal").style.display = "flex";
});
document.getElementById("btn-delete-supplier").addEventListener("click", handleDeleteSupplier);
// Add Contact
document.getElementById("btn-add-contact").addEventListener("click", () => {
document.getElementById("contact-form").reset();
document.getElementById("contact-id").value = "";
document.getElementById("contact-modal-title").innerText = "담당자 등록";
document.getElementById("contact-modal").style.display = "flex";
});
document.getElementById("contact-form").addEventListener("submit", handleContactSubmit);
// Add Activity
document.getElementById("btn-add-activity").addEventListener("click", () => {
document.getElementById("activity-form").reset();
document.getElementById("activity-id").value = "";
document.getElementById("act-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("activity-modal-title").innerText = "매입 일지 등록";
document.getElementById("activity-modal").style.display = "flex";
});
document.getElementById("activity-form").addEventListener("submit", handleActivitySubmit);
}
// Form Handlers
async function handleSupplierSubmit(e) {
e.preventDefault();
const id = document.getElementById("supplier-id").value;
const payload = {
name_kr: document.getElementById("supp-name-kr").value,
name_en: document.getElementById("supp-name-en").value || null,
business_registration_number: document.getElementById("supp-brn").value || null,
contact_number: document.getElementById("supp-contact").value || null,
address: document.getElementById("supp-address").value || null
};
try {
let res;
if (id) {
// Update
res = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
// Create
res = await fetch(`${API_BASE}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API error occurred");
const result = await res.json();
document.getElementById("supplier-modal").style.display = "none";
await fetchSuppliers();
if (id) {
showSupplierDetail(parseInt(id));
} else {
showSupplierDetail(result.id);
}
} catch (err) {
console.error(err);
alert("공급사 정보를 저장하는 중 오류가 발생했습니다.");
}
}
async function handleDeleteSupplier() {
if (!selectedSupplierId) return;
if (!confirm("이 공급사를 정말 삭제하시겠습니까?\n해당 공급사의 연락처 및 매입 일지 정보가 영구 삭제됩니다.")) return;
try {
const res = await fetch(`${API_BASE}/${selectedSupplierId}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete supplier");
selectedSupplierId = null;
document.getElementById("supplier-detail-card").style.display = "none";
document.getElementById("supplier-placeholder-card").style.display = "flex";
await fetchSuppliers();
} catch (err) {
console.error(err);
alert("공급사 삭제에 실패했습니다.");
}
}
// Contact Handlers
window.openEditContactModal = function(contact) {
document.getElementById("contact-id").value = contact.id;
document.getElementById("cont-name").value = contact.name;
document.getElementById("cont-position").value = contact.position || "";
document.getElementById("cont-phone").value = contact.phone_number || "";
document.getElementById("cont-email").value = contact.email || "";
document.getElementById("cont-location").value = contact.work_location || "";
document.getElementById("contact-modal-title").innerText = "담당자 정보 수정";
document.getElementById("contact-modal").style.display = "flex";
};
async function handleContactSubmit(e) {
e.preventDefault();
const id = document.getElementById("contact-id").value;
const payload = {
supplier_id: selectedSupplierId,
name: document.getElementById("cont-name").value,
position: document.getElementById("cont-position").value || null,
phone_number: document.getElementById("cont-phone").value || null,
email: document.getElementById("cont-email").value || null,
work_location: document.getElementById("cont-location").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/contacts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/contacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Contact save API error");
document.getElementById("contact-modal").style.display = "none";
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("담당자 저장에 실패했습니다.");
}
}
window.deleteContact = async function(contactId) {
if (!confirm("이 담당자 정보를 정말 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/contacts/${contactId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete contact");
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("담당자 삭제에 실패했습니다.");
}
};
// Activity Handlers
window.openEditActivityModal = function(act) {
document.getElementById("activity-id").value = act.id;
document.getElementById("act-date").value = act.activity_date;
document.getElementById("act-type").value = act.activity_type;
document.getElementById("act-memo").value = act.memo || "";
document.getElementById("activity-modal-title").innerText = "매입 일지 수정";
document.getElementById("activity-modal").style.display = "flex";
};
async function handleActivitySubmit(e) {
e.preventDefault();
const id = document.getElementById("activity-id").value;
const payload = {
supplier_id: selectedSupplierId,
contact_id: null,
activity_date: document.getElementById("act-date").value,
activity_type: document.getElementById("act-type").value,
memo: document.getElementById("act-memo").value
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/activities/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/activities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Activity save API error");
document.getElementById("activity-modal").style.display = "none";
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("일지 저장에 실패했습니다.");
}
}
window.deleteActivity = async function(actId) {
if (!confirm("이 매입 일지를 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/activities/${actId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete activity");
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("일지 삭제에 실패했습니다.");
}
};
+59
View File
@@ -0,0 +1,59 @@
/* --- 테마 통합 관리 모듈 ---
* 라이트/다크 테마를 localStorage 기반으로 관리한다. (DB 저장 없음)
* 이 스크립트는 각 페이지 <head> 최상단(base.css 링크보다 먼저)에서 로드되어야
* FOWT(Flash Of Wrong Theme, 잘못된 테마가 순간적으로 보이는 현상)를 방지한다.
*/
(function () {
"use strict";
var THEME_KEY = "app-theme";
var DEFAULT_THEME = "light"; // 기본값: 화이트 계열
var VALID_THEMES = ["light", "dark"];
/** localStorage에서 저장된 테마를 읽는다. 없거나 잘못된 값이면 기본값 반환. */
function getStoredTheme() {
var stored;
try {
stored = window.localStorage.getItem(THEME_KEY);
} catch (e) {
stored = null; // localStorage 접근 불가(사생활 보호 모드 등) 시 안전하게 기본값
}
return VALID_THEMES.indexOf(stored) !== -1 ? stored : DEFAULT_THEME;
}
/** <html> 요소에 data-theme 속성을 적용한다. (실제 화면 반영) */
function applyTheme(theme) {
if (VALID_THEMES.indexOf(theme) === -1) {
theme = DEFAULT_THEME;
}
document.documentElement.setAttribute("data-theme", theme);
}
/** 테마를 저장하고 즉시 화면에 적용한다. */
function setTheme(theme) {
if (VALID_THEMES.indexOf(theme) === -1) {
theme = DEFAULT_THEME;
}
try {
window.localStorage.setItem(THEME_KEY, theme);
} catch (e) {
/* 저장 실패해도 현재 세션 적용은 계속 진행 */
}
applyTheme(theme);
}
// 로드 즉시 동기 실행 → FOWT 방지 (DOMContentLoaded를 기다리지 않음)
applyTheme(getStoredTheme());
// 다른 스크립트(setting.js 등)에서 사용할 수 있도록 전역 노출
try {
window.AppTheme = {
get: getStoredTheme,
set: setTheme,
apply: applyTheme,
THEMES: VALID_THEMES.slice()
};
} catch (e) {
console.warn("[Theme] Failed to expose AppTheme to window:", e);
}
})();
+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 자재 물성 관리</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.search-container {
display: flex;
gap: 12px;
width: 100%;
max-width: 400px;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.list-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.list-table th, .list-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.list-table th {
color: var(--text-secondary);
font-weight: 600;
}
.list-table tr:hover {
background-color: var(--overlay-subtle);
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 30px var(--card-shadow);
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
}
.close-btn {
font-size: 24px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.btn-secondary {
background-color: var(--overlay-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--overlay-strong);
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>자재 물성 규격 관리</h1>
<p>생산 단가 및 소요 기간 시뮬레이션 연산의 기준이 되는 원자재 사양 규격을 정의합니다.</p>
</div>
<button class="btn btn-primary" id="btn-add-material">+ 신규 규격 추가</button>
</header>
<div class="card">
<div class="header-row">
<h2 class="section-title" style="margin:0;">원자재 규격 관리 리스트</h2>
<div class="search-container">
<input type="text" id="search-material" class="form-control" placeholder="자재 코드, 명칭 검색...">
</div>
</div>
<div style="overflow-x: auto;">
<table class="list-table">
<thead>
<tr>
<th>자재 코드</th>
<th>자재 명칭</th>
<th>자재 구분</th>
<th>밀도 (Density, g/cm³)</th>
<th>상세 비고</th>
<th>관리</th>
</tr>
</thead>
<tbody id="material-list-body">
<tr>
<td colspan="6" style="text-align: center; color: var(--text-muted); padding: 30px 0;">자재 데이터를 조회하는 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<!-- 물성 추가/수정 모달 -->
<div id="material-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="material-modal-title">자재 규격 정보 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="material-form">
<input type="hidden" id="material-id">
<div class="form-group">
<label for="mat-code">자재 코드 *</label>
<input type="text" id="mat-code" class="form-control" placeholder="예: YRN-CF-3K" required>
</div>
<div class="form-group">
<label for="mat-name">자재 명칭 *</label>
<input type="text" id="mat-name" class="form-control" placeholder="예: 국산 카본원사 3K" required>
</div>
<div class="form-group">
<label for="mat-type">자재 구분 *</label>
<select id="mat-type" class="form-control" required>
<option value="Yarn">원사 (Yarn)</option>
<option value="Bond">본드 (Bond)</option>
<option value="Etc">기타 자재</option>
</select>
</div>
<div class="form-group">
<label for="mat-density">밀도 (Density, g/cm³) *</label>
<input type="number" id="mat-density" step="0.001" class="form-control" placeholder="수치 입력" required>
</div>
<div class="form-group">
<label for="mat-remarks">상세 비고</label>
<input type="text" id="mat-remarks" class="form-control">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/material.js"></script>
</body>
</html>
+439
View File
@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 생산 계획 관리 (Gantt)</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
/* Gantt Layout */
.gantt-outer-container {
display: flex;
border: 1px solid var(--border-color);
border-radius: 12px;
background-color: var(--bg-card);
overflow: hidden;
height: calc(100vh - 240px);
min-height: 500px;
width: 100%;
max-width: 100%;
}
.gantt-left-panel {
width: 250px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.gantt-right-timeline {
flex-grow: 1;
overflow-x: auto;
display: flex;
flex-direction: column;
max-width: calc(100% - 250px);
min-width: 0;
}
.gantt-right-timeline::-webkit-scrollbar {
height: 10px;
}
.gantt-right-timeline::-webkit-scrollbar-track {
background: var(--overlay-subtle);
}
.gantt-right-timeline::-webkit-scrollbar-thumb {
background: var(--overlay-border);
border-radius: 5px;
}
.gantt-right-timeline::-webkit-scrollbar-thumb:hover {
background: var(--overlay-border);
}
.gantt-header-row {
height: 70px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
background-color: var(--overlay-subtle);
flex-shrink: 0;
}
.gantt-left-header {
padding: 0 16px;
font-weight: 700;
font-size: 14px;
color: var(--text-secondary);
}
/* Gantt Grid Cells */
.gantt-day-header-cell {
width: 40px;
height: 100%;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 12px;
flex-shrink: 0;
}
.day-sat {
color: var(--accent-blue-soft);
background-color: rgba(96, 165, 250, 0.05);
}
.day-sun {
color: var(--accent-red-soft);
background-color: rgba(248, 113, 113, 0.05);
}
.day-today {
border: 2px solid var(--accent-red);
font-weight: 700;
}
.gantt-body {
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
#gantt-right-body {
width: max-content;
min-width: 100%;
}
.gantt-row {
height: 60px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
position: relative;
}
.gantt-row:hover {
background-color: var(--overlay-subtle);
}
.gantt-left-item {
width: 250px;
padding: 0 16px;
font-size: 13px;
display: flex;
flex-direction: column;
justify-content: center;
border-right: 1px solid var(--border-color);
height: 100%;
flex-shrink: 0;
cursor: pointer;
}
.gantt-left-item small {
color: var(--text-secondary);
margin-top: 2px;
}
.gantt-right-grid {
display: flex;
height: 100%;
position: relative;
}
.gantt-grid-cell {
width: 40px;
height: 100%;
border-right: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Timeline bar */
.gantt-bar-container {
position: absolute;
height: 100%;
display: flex;
align-items: center;
pointer-events: none; /* 그리드 클릭이 방해되지 않도록 */
}
.gantt-bar-item {
height: 32px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.65), rgba(37, 99, 235, 0.65));
border: 1px solid var(--accent-blue);
color: var(--text-on-accent);
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 10px;
box-shadow: 0 4px 10px var(--card-shadow);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto; /* 클릭 가능 */
cursor: pointer;
transition: transform 0.2s;
}
.gantt-bar-item:hover {
transform: scale(1.02);
box-shadow: 0 0 10px var(--accent-blue-glow);
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 95%;
max-width: 750px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 30px var(--card-shadow);
}
.close-btn {
font-size: 24px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
}
.modal-body-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.modal-body-grid {
grid-template-columns: 1fr;
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.btn-secondary {
background-color: var(--overlay-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--overlay-strong);
}
.machine-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
}
.machine-chip {
background-color: var(--overlay-subtle);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.machine-chip.selected {
background-color: var(--nav-active-bg);
border-color: var(--accent-blue);
color: var(--accent-blue);
font-weight: 600;
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>생산 계획 관리 (간트차트)</h1>
<p>자재 시뮬레이션을 실행하여 필요 작업 기간을 연산하고, 기기 배정 일정을 수립합니다.</p>
</div>
<button class="btn btn-primary" id="btn-add-plan">+ 생산 계획 수립</button>
</header>
<!-- Gantt 차트 메인 판넬 -->
<div class="gantt-outer-container">
<!-- 좌측 정적 리스트 -->
<div class="gantt-left-panel">
<div class="gantt-header-row gantt-left-header">
생산 계획 / 배정 기기
</div>
<div class="gantt-body" id="gantt-left-body">
<!-- Left rows -->
</div>
</div>
<!-- 우측 타임라인 스크롤 그리드 -->
<div class="gantt-right-timeline" id="gantt-timeline-container">
<div class="gantt-header-row" id="gantt-date-headers">
<!-- Date headers -->
</div>
<div class="gantt-body" id="gantt-right-body">
<!-- Grid rows & bars -->
</div>
</div>
</div>
</main>
</div>
<!-- 생산 계획 수립/수정 다이얼로그 모달 -->
<div id="plan-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="plan-modal-title">생산 계획 및 시뮬레이션 수립</h3>
<button class="close-btn">&times;</button>
</div>
<form id="plan-form">
<input type="hidden" id="plan-id">
<div class="modal-body-grid">
<!-- 왼쪽: 거래처 및 소요 자재 입력 정보 -->
<div>
<h4 style="font-size: 14px; color: var(--accent-blue); margin-bottom: 12px;">1. 파트너사 및 자재 매핑</h4>
<div class="form-group">
<label for="plan-customer">거래처(고객사) 선택 *</label>
<select id="plan-customer" class="form-control" required></select>
</div>
<div class="form-group">
<label for="plan-yarn">입고 원사 자재 *</label>
<select id="plan-yarn" class="form-control" required></select>
</div>
<div class="form-group">
<label for="plan-bond">소요 본드 자재</label>
<select id="plan-bond" class="form-control"></select>
</div>
<div class="form-group">
<label for="plan-qty">목표 생산량 (kg) *</label>
<input type="number" id="plan-qty" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="plan-start-date">생산 가동 예정일 *</label>
<input type="date" id="plan-start-date" class="form-control" required>
</div>
<div style="display: flex; gap: 20px; margin-top: 10px;">
<label style="display:flex; align-items:center; font-size:13px; cursor:pointer;">
<input type="checkbox" id="plan-sat" style="margin-right:6px;"> 토요일 가동
</label>
<label style="display:flex; align-items:center; font-size:13px; cursor:pointer;">
<input type="checkbox" id="plan-sun" style="margin-right:6px;"> 일요일 가동
</label>
</div>
</div>
<!-- 오른쪽: 기계 가동 물성 및 시뮬레이션 계산 결과 -->
<div>
<h4 style="font-size: 14px; color: var(--accent-blue); margin-bottom: 12px;">2. 가동 변수 및 시뮬레이션 연산</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label for="plan-yarn-dia">선경 (Diameter, ㎛) *</label>
<input type="number" id="plan-yarn-dia" value="7.0" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="plan-yarn-k">K수 (K 번수) *</label>
<input type="number" id="plan-yarn-k" value="12" class="form-control" required>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label for="plan-ports">포트 수 *</label>
<input type="number" id="plan-ports" value="40" class="form-control" required>
</div>
<div class="form-group">
<label for="plan-hz">사이클 타임 (Hz) *</label>
<input type="number" id="plan-hz" value="8.0" step="0.1" class="form-control" required>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label for="plan-cut">커팅 길이 (mm) *</label>
<input type="number" id="plan-cut" value="6.0" step="0.1" class="form-control" required>
</div>
<div class="form-group">
<label for="plan-hours">일일 작업시간 (시간) *</label>
<input type="number" id="plan-hours" value="16" class="form-control" required>
</div>
</div>
<div style="margin: 14px 0; padding: 12px; background: var(--overlay-subtle); border: 1px dashed var(--border-color); border-radius: 8px;">
<button type="button" class="btn btn-secondary" id="btn-simulate" style="width: 100%; padding: 8px 0; font-size:12px; font-weight:700;">시뮬레이션 가동 계산 실행</button>
<div style="margin-top: 10px; font-size: 12px; display:flex; flex-direction:column; gap:4px;" id="sim-result-box">
<p style="color:var(--text-secondary);">소요 기간: <span id="sim-days" style="color:var(--text-primary); font-weight:700;">-</span></p>
<p style="color:var(--text-secondary);">예상 매출: <span id="sim-sales" style="color:var(--text-primary); font-weight:700;">-</span></p>
<p style="color:var(--text-secondary);">마진 (공장 이익): <span id="sim-margin" style="color:var(--accent-green); font-weight:700;">-</span></p>
</div>
</div>
<div class="form-group" id="equip-assignment-box" style="display: none;">
<label>3. 가용 배정 설비 선택 *</label>
<div class="machine-checkbox-group" id="available-machines-container">
<!-- 가용 설비 칩 바인딩 -->
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary" id="btn-save-plan" disabled>계획 최종 저장</button>
</div>
</form>
</div>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/plan.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 시스템 설정</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
max-width: 600px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 600;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.form-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.settings-category {
font-size: 16px;
font-weight: 700;
color: var(--accent-blue);
margin-bottom: 16px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
}
.actions-row {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
max-width: 600px;
display: flex;
justify-content: flex-end;
}
/* --- 화면 테마 선택 카드 --- */
.theme-option-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.theme-option-card {
flex: 1;
min-width: 180px;
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 16px;
cursor: pointer;
background-color: var(--overlay-subtle);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: 12px;
}
.theme-option-card:hover {
border-color: var(--text-secondary);
}
.theme-option-card.active {
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px var(--accent-blue-glow);
}
/* 미니 미리보기: 배경/사이드바/카드 3색 블록 */
.theme-preview {
display: flex;
height: 56px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.theme-preview .prev-sidebar {
width: 30%;
}
.theme-preview .prev-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.theme-preview .prev-card {
width: 60%;
height: 40%;
border-radius: 4px;
}
/* 라이트 미리보기 색상(테마와 무관하게 고정) */
.preview-light { background-color: #eef1f6; }
.preview-light .prev-sidebar { background-color: #ffffff; border-right: 1px solid rgba(15,23,42,0.1); }
.preview-light .prev-card { background-color: #ffffff; border: 1px solid rgba(15,23,42,0.12); }
/* 다크 미리보기 색상(테마와 무관하게 고정) */
.preview-dark { background-color: #0b0f19; }
.preview-dark .prev-sidebar { background-color: #151c2c; border-right: 1px solid rgba(255,255,255,0.08); }
.preview-dark .prev-card { background-color: #1e2740; border: 1px solid rgba(255,255,255,0.1); }
.theme-option-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.theme-option-check {
color: var(--accent-blue);
font-weight: 700;
visibility: hidden;
}
.theme-option-card.active .theme-option-check {
visibility: visible;
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>시스템 전역 설정</h1>
<p>FastAPI 서버의 전역 파라미터 및 MQTT 게이트웨이 연동 상수를 설정합니다.</p>
</div>
</header>
<!-- 화면 테마 설정 (localStorage 저장, 서버 DB와 무관) -->
<div class="card" style="margin-bottom: 24px;">
<div class="settings-category">화면 테마</div>
<p class="form-desc" style="margin-bottom: 16px;">밝은 화면(라이트) 또는 어두운 화면(다크) 테마를 선택합니다. 이 설정은 사용 중인 브라우저에만 저장됩니다.</p>
<div class="theme-option-group">
<div class="theme-option-card" data-theme-value="light" role="button" tabindex="0">
<div class="theme-preview preview-light">
<div class="prev-sidebar"></div>
<div class="prev-body"><div class="prev-card"></div></div>
</div>
<div class="theme-option-label">
<span>☀️ 라이트</span>
<span class="theme-option-check">✔ 사용 중</span>
</div>
</div>
<div class="theme-option-card" data-theme-value="dark" role="button" tabindex="0">
<div class="theme-preview preview-dark">
<div class="prev-sidebar"></div>
<div class="prev-body"><div class="prev-card"></div></div>
</div>
<div class="theme-option-label">
<span>🌙 다크</span>
<span class="theme-option-check">✔ 사용 중</span>
</div>
</div>
</div>
</div>
<div class="card">
<form id="settings-form">
<div class="form-grid" id="settings-container">
<div style="text-align: center; color: var(--text-muted); padding: 30px 0;">설정 정보를 조회하는 중...</div>
</div>
<div class="actions-row">
<button type="submit" class="btn btn-primary" style="padding: 12px 24px;">설정 사항 일괄 적용</button>
</div>
</form>
</div>
</main>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/setting.js"></script>
</body>
</html>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChemiFactory 공급사 관리</title>
<!-- Google Fonts Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
<script src="js/theme.js"></script>
<link rel="stylesheet" href="css/base.css">
<style>
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.search-container {
display: flex;
gap: 12px;
width: 100%;
max-width: 400px;
}
.form-control {
background-color: var(--overlay-hover);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
width: 100%;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--border-focus);
background-color: var(--overlay-strong);
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 992px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.list-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.list-table th, .list-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.list-table th {
color: var(--text-secondary);
font-weight: 600;
}
.list-table tr {
cursor: pointer;
transition: background-color 0.2s;
}
.list-table tr:hover {
background-color: var(--overlay-subtle);
}
.list-table tr.active-row {
background-color: var(--nav-active-bg);
border-left: 3px solid var(--accent-blue);
}
/* 탭 구조 */
.tabs-header {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 16px;
}
.tab-btn {
background: none;
border: none;
color: var(--text-secondary);
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--accent-blue);
font-weight: 600;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: var(--accent-blue);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--modal-backdrop);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 30px var(--card-shadow);
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 700;
}
.close-btn {
font-size: 24px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.btn-secondary {
background-color: var(--overlay-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--overlay-strong);
}
.btn-danger {
background-color: var(--accent-red);
color: var(--text-on-accent);
}
.btn-danger:hover {
background-color: var(--danger-solid);
box-shadow: 0 0 12px var(--accent-red-glow);
}
</style>
</head>
<body>
<div class="app-container">
<!-- 공통 사이드바 -->
<aside class="sidebar" id="sidebar"></aside>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<header class="content-header">
<div class="page-title">
<h1>공급사 관리</h1>
<p>원자재 공급처 정보, 공급사 담당자 연락처망 및 자재 매입 활동 내역을 관리합니다.</p>
</div>
<button class="btn btn-primary" id="btn-add-supplier">+ 공급사 추가</button>
</header>
<div class="grid-layout">
<!-- 왼쪽: 공급사 목록 -->
<div class="card">
<div class="header-row">
<h2 class="section-title" style="margin:0;">공급사 목록</h2>
<div class="search-container">
<input type="text" id="search-supplier" class="form-control" placeholder="공급사명 검색...">
</div>
</div>
<div style="overflow-x: auto;">
<table class="list-table">
<thead>
<tr>
<th>공급사명(한글)</th>
<th>대표 번호</th>
<th>등록번호</th>
</tr>
</thead>
<tbody id="supplier-list-body">
<tr>
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 30px 0;">데이터를 불러오는 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 오른쪽: 선택된 공급사 상세정보 -->
<div class="card" id="supplier-detail-card" style="display: none;">
<div class="header-row" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
<div>
<h2 id="detail-name-kr" style="margin: 0; font-size: 20px; font-weight: 700;">공급사명</h2>
<p id="detail-name-en" style="color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0;">English Name</p>
</div>
<div>
<button class="btn btn-secondary" id="btn-edit-supplier" style="padding: 6px 12px; font-size: 13px;">수정</button>
<button class="btn btn-danger" id="btn-delete-supplier" style="padding: 6px 12px; font-size: 13px;">삭제</button>
</div>
</div>
<div style="margin-bottom: 24px; font-size: 14px;">
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">사업자등록번호:</strong> <span id="detail-brn"></span></p>
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">대표 연락처:</strong> <span id="detail-contact"></span></p>
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">주소:</strong> <span id="detail-address"></span></p>
</div>
<!-- 탭메뉴 -->
<div class="tabs-header">
<button class="tab-btn active" data-tab="tab-contacts">담당자 정보</button>
<button class="tab-btn" data-tab="tab-activities">원재료 매입 일지</button>
</div>
<!-- 탭 1: 공급사 담당자 정보 -->
<div id="tab-contacts" class="tab-content active">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="font-size: 15px; font-weight: 600;">공급처 담당자 목록</h3>
<button class="btn btn-primary" id="btn-add-contact" style="padding: 6px 12px; font-size: 12px;">+ 담당자 추가</button>
</div>
<div style="overflow-x: auto;">
<table class="list-table" style="font-size: 13px;">
<thead>
<tr>
<th>이름</th>
<th>직급/소속</th>
<th>연락처</th>
<th>이메일</th>
<th>관리</th>
</tr>
</thead>
<tbody id="contact-list-body">
<!-- 담당자 정보 리스트 -->
</tbody>
</table>
</div>
</div>
<!-- 탭 2: 매입 활동 기록 -->
<div id="tab-activities" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="font-size: 15px; font-weight: 600;">매입 및 거래 내역</h3>
<button class="btn btn-primary" id="btn-add-activity" style="padding: 6px 12px; font-size: 12px;">+ 일지 추가</button>
</div>
<div style="overflow-x: auto;">
<table class="list-table" style="font-size: 13px;">
<thead>
<tr>
<th>날짜</th>
<th>활동 구분</th>
<th>상세 내용 및 메모</th>
<th>관리</th>
</tr>
</thead>
<tbody id="activity-list-body">
<!-- 매입 거래 이력 리스트 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 상세 대기 안내 카드 -->
<div class="card" id="supplier-placeholder-card" style="display: flex; align-items: center; justify-content: center; height: 300px; color: var(--text-secondary);">
<div>왼쪽 목록에서 공급사를 선택하시면 상세 내역을 확인할 수 있습니다.</div>
</div>
</div>
</main>
</div>
<!-- 공급사 추가/수정 모달 -->
<div id="supplier-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="supplier-modal-title">공급사 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="supplier-form">
<input type="hidden" id="supplier-id">
<div class="form-group">
<label for="supp-name-kr">공급사 한글명 *</label>
<input type="text" id="supp-name-kr" class="form-control" required>
</div>
<div class="form-group">
<label for="supp-name-en">공급사 영문명</label>
<input type="text" id="supp-name-en" class="form-control">
</div>
<div class="form-group">
<label for="supp-brn">사업자등록번호</label>
<input type="text" id="supp-brn" class="form-control" placeholder="123-45-67890">
</div>
<div class="form-group">
<label for="supp-contact">대표 연락처</label>
<input type="text" id="supp-contact" class="form-control" placeholder="02-1234-5678">
</div>
<div class="form-group">
<label for="supp-address">회사 주소</label>
<input type="text" id="supp-address" class="form-control">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 담당자 추가/수정 모달 -->
<div id="contact-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="contact-modal-title">담당자 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="contact-form">
<input type="hidden" id="contact-id">
<div class="form-group">
<label for="cont-name">담당자 이름 *</label>
<input type="text" id="cont-name" class="form-control" required>
</div>
<div class="form-group">
<label for="cont-position">직급/부서</label>
<input type="text" id="cont-position" class="form-control" placeholder="예: 영업부 과장">
</div>
<div class="form-group">
<label for="cont-phone">연락처</label>
<input type="text" id="cont-phone" class="form-control" placeholder="010-1234-5678">
</div>
<div class="form-group">
<label for="cont-email">이메일 주소</label>
<input type="email" id="cont-email" class="form-control">
</div>
<div class="form-group">
<label for="cont-location">근무처/사무실 위치</label>
<input type="text" id="cont-location" class="form-control" placeholder="예: 공장 1F">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 매입 일지 추가/수정 모달 -->
<div id="activity-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="activity-modal-title">매입 일지 등록</h3>
<button class="close-btn">&times;</button>
</div>
<form id="activity-form">
<input type="hidden" id="activity-id">
<div class="form-group">
<label for="act-date">매입 날짜 *</label>
<input type="date" id="act-date" class="form-control" required>
</div>
<div class="form-group">
<label for="act-type">활동 구분 *</label>
<select id="act-type" class="form-control" required>
<option value="원재료 입고">원재료 입고</option>
<option value="자재 미팅">자재 상담/미팅</option>
<option value="매입 견적 요청">매입 견적 요청</option>
<option value="발주 협의">발주 및 계약 협의</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label for="act-memo">메모 / 매입 상세 내역 *</label>
<textarea id="act-memo" class="form-control" style="height: 100px; resize: none;" required></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 스크립트 연결 -->
<script src="js/common.js"></script>
<script src="js/supplier.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
# inventory package marker
from .db_manager import (
add_material_property,
update_material_property,
delete_material_property,
get_all_material_properties,
get_material_property_by_id,
add_inventory_to_db,
update_inventory_in_db,
deplete_inventory_in_db,
get_all_inventory_from_db,
get_inventory_details_from_db,
get_total_stock_for_material
)
from .analysis import calculate_cost_analysis
from .router import router
+221
View File
@@ -0,0 +1,221 @@
import math
import logging
from .db_manager import get_inventory_details_from_db
from setting.db_manager import get_all_settings
def _safe_float_conversion(value, default_value=None):
try:
if value is not None and value != '':
return float(value)
except (ValueError, TypeError):
pass
return default_value
def calculate_cost_analysis(inputs: dict, target_production_quantity: float = None) -> dict:
"""
원가 분석 계산 엔진 (순수 연산 비즈니스 로직)
"""
yarn_inventory_id = inputs.get("yarn_inventory_id")
bond_inventory_id = inputs.get("bond_inventory_id")
if not yarn_inventory_id:
raise ValueError("분석할 원사 재고가 선택되지 않았습니다.")
yarn_data_wrapper = get_inventory_details_from_db(yarn_inventory_id)
if not yarn_data_wrapper or "inventory" not in yarn_data_wrapper:
raise ValueError("선택된 원사의 물성 또는 단가 정보를 찾을 수 없습니다.")
yarn_data = yarn_data_wrapper["inventory"]
yarn_density_g_cm3 = _safe_float_conversion(yarn_data.get("density"), 1.0)
yarn_unit_cost = _safe_float_conversion(yarn_data.get("unit_cost"), 0.0)
bond_unit_cost = 0.0
if bond_inventory_id and bond_inventory_id != 'null':
bond_data_wrapper = get_inventory_details_from_db(bond_inventory_id)
if bond_data_wrapper and "inventory" in bond_data_wrapper:
bond_data = bond_data_wrapper["inventory"]
bond_unit_cost = _safe_float_conversion(bond_data.get("unit_cost"), 0.0)
yarn_diameter_micron = _safe_float_conversion(inputs.get("yarn_diameter_micron"), 7)
yarn_k = _safe_float_conversion(inputs.get("yarn_k"), 12) * 1000
ports = _safe_float_conversion(inputs.get("ports"), 40)
cycle_time_hz = _safe_float_conversion(inputs.get("cycle_time_hz"), 8)
cut_length_mm = _safe_float_conversion(inputs.get("cut_length_mm"), 6)
work_hours_day = _safe_float_conversion(inputs.get("work_hours_day"), 16)
work_days_month = _safe_float_conversion(inputs.get("work_days_month"), 20)
num_machines = _safe_float_conversion(inputs.get("num_machines"), 2)
bond_percentage = _safe_float_conversion(inputs.get("bond_percentage"), 3.0) / 100.0
yarn_radius_cm = (yarn_diameter_micron / 2) * 1e-4
yarn_cross_section_cm2 = math.pi * (yarn_radius_cm ** 2)
pure_yarn_weight_kg_per_m = (yarn_cross_section_cm2 * 100 * yarn_density_g_cm3) / 1000
cut_length_m = cut_length_mm / 1000
production_length_m_per_hour_per_mc = cycle_time_hz * 3600 * cut_length_m
pure_yarn_kg_per_hour_per_mc = production_length_m_per_hour_per_mc * pure_yarn_weight_kg_per_m * ports * yarn_k
if 0 < bond_percentage < 1.0:
total_kg_per_hour_per_mc = pure_yarn_kg_per_hour_per_mc / (1.0 - bond_percentage)
else:
total_kg_per_hour_per_mc = pure_yarn_kg_per_hour_per_mc
if total_kg_per_hour_per_mc <= 0:
raise ValueError("시간당 생산량이 0 이하여서 계산할 수 없습니다.")
settings = get_all_settings()
settings_dict = {f"{s['setting_group']}_{s['setting_key']}": s['setting_value'] for s in settings}
def get_s(key, default):
return _safe_float_conversion(settings_dict.get(key, default), default)
avg_power_per_machine_kw = get_s("CostAnalysis_AvgPowerPerMachine", 5000) / 1000
electricity_price_kwh = get_s("CostAnalysis_ElectricityUnitPricePerKWH", 94.4)
hourly_power_cost = avg_power_per_machine_kw * electricity_price_kwh
max_machines_per_person = get_s("CostAnalysis_MaxMachinesPerPerson", 10)
num_people_needed = math.ceil(num_machines / max_machines_per_person)
monthly_labor_cost_person = get_s("CostAnalysis_MonthlyLaborCostPerPerson", 6000000)
meal_cost_day = get_s("CostAnalysis_MealCostPerDay", 15000)
hourly_labor_cost = (num_people_needed * monthly_labor_cost_person + num_people_needed * meal_cost_day * work_days_month) / (work_days_month * work_hours_day * num_machines)
packaging_cost_kg = get_s("CostAnalysis_PackagingCostPerKg", 500)
packaging_weight_kg = get_s("CostAnalysis_PackagingBaseWeightKg", 25)
hourly_packaging_cost = (total_kg_per_hour_per_mc / packaging_weight_kg) * packaging_cost_kg if packaging_weight_kg > 0 else 0
monthly_rent = get_s("CostAnalysis_MonthlyRentCost", 0)
monthly_admin = get_s("CostAnalysis_MonthlyGeneralAdminCost", 50000)
monthly_delivery = get_s("CostAnalysis_MonthlyDeliveryCost", 300000)
hourly_overhead_cost = (monthly_rent + monthly_admin + monthly_delivery) / (work_days_month * work_hours_day * num_machines)
mass_mc_cost = get_s("Investment_MassProductionEquipmentCost", 10000000)
dep_mass_mc = get_s("Investment_DepreciationPeriodMassProduction", 3) * 12
office_cost = get_s("Investment_OfficeSuppliesCost", 5000000)
dep_office = get_s("Investment_DepreciationPeriodOfficeSupplies", 3) * 12
proto_cost = get_s("Investment_ProtoEquipmentCost", 5000000)
monthly_depreciation = (mass_mc_cost / dep_mass_mc if dep_mass_mc > 0 else 0) + \
(office_cost / dep_office if dep_office > 0 else 0) + \
(proto_cost / dep_office if dep_office > 0 else 0)
hourly_depreciation = monthly_depreciation / (work_days_month * work_hours_day)
hourly_net_cost_per_machine = hourly_power_cost + hourly_labor_cost + hourly_packaging_cost + hourly_overhead_cost + hourly_depreciation
company_margin_rate = get_s("CostAnalysis_CompanyMarginRate", 30) / 100.0
standard_processing_cost_kg = get_s("CostAnalysis_StandardProcessingCost", 0)
pure_processing_cost_kg = hourly_net_cost_per_machine / total_kg_per_hour_per_mc if total_kg_per_hour_per_mc > 0 else 0
calculated_profit_margin_kg = 0
if pure_processing_cost_kg > 0:
if pure_processing_cost_kg * (1 + company_margin_rate) < standard_processing_cost_kg:
calculated_profit_margin_kg = standard_processing_cost_kg - pure_processing_cost_kg
else:
calculated_profit_margin_kg = pure_processing_cost_kg * company_margin_rate
hourly_profit_per_machine = calculated_profit_margin_kg * total_kg_per_hour_per_mc
analysis_result = {}
total_kg_hour_all_mc = total_kg_per_hour_per_mc * num_machines
total_kg_day_all_mc = total_kg_hour_all_mc * work_hours_day
total_kg_month = total_kg_day_all_mc * work_days_month
monthly_power_cost = hourly_power_cost * work_hours_day * work_days_month * num_machines
monthly_labor_cost_total = hourly_labor_cost * work_hours_day * work_days_month * num_machines
monthly_packaging_cost = hourly_packaging_cost * work_hours_day * work_days_month * num_machines
monthly_overhead_cost = hourly_overhead_cost * work_hours_day * work_days_month * num_machines
monthly_depreciation_cost_total = hourly_depreciation * work_hours_day * work_days_month * num_machines
monthly_total_expenses = monthly_power_cost + monthly_labor_cost_total + monthly_packaging_cost + monthly_overhead_cost + monthly_depreciation_cost_total
pure_yarn_kg_per_hour_all_mc = pure_yarn_kg_per_hour_per_mc * num_machines
required_yarn_kg_month = (pure_yarn_kg_per_hour_all_mc / total_kg_hour_all_mc) * total_kg_month if total_kg_hour_all_mc > 0 else 0
bond_kg_per_hour_all_mc = (total_kg_hour_all_mc - pure_yarn_kg_per_hour_all_mc)
required_bond_kg_month = (bond_kg_per_hour_all_mc / total_kg_hour_all_mc) * total_kg_month if total_kg_hour_all_mc > 0 else 0
current_yarn_stock = _safe_float_conversion(yarn_data.get("stock"), 0.0)
purchase_order_yarn_kg = max(0, required_yarn_kg_month - current_yarn_stock)
current_bond_stock = 0.0
purchase_order_bond_kg = 0.0
if bond_inventory_id and bond_inventory_id != 'null':
bond_data_wrapper = get_inventory_details_from_db(bond_inventory_id)
if bond_data_wrapper and "inventory" in bond_data_wrapper:
current_bond_stock = _safe_float_conversion(bond_data_wrapper["inventory"].get("stock"), 0.0)
purchase_order_bond_kg = max(0, required_bond_kg_month - current_bond_stock)
total_yarn_purchase_cost = required_yarn_kg_month * yarn_unit_cost
total_bond_purchase_cost = required_bond_kg_month * bond_unit_cost
total_raw_material_purchase_cost = total_yarn_purchase_cost + total_bond_purchase_cost
analysis_result.update({
"production_kg_per_hour": total_kg_hour_all_mc,
"production_kg_per_day": total_kg_day_all_mc,
"production_ton_per_month": total_kg_month / 1000,
"total_product_kg_per_month": total_kg_month,
"pure_processing_cost_per_kg": pure_processing_cost_kg,
"calculated_profit_margin_per_kg": calculated_profit_margin_kg,
"processing_cost_per_kg_total": pure_processing_cost_kg + calculated_profit_margin_kg,
"current_yarn_stock": current_yarn_stock,
"required_yarn_kg": required_yarn_kg_month,
"purchase_order_kg": purchase_order_yarn_kg,
"yarn_unit_cost": yarn_unit_cost,
"current_bond_stock": current_bond_stock,
"required_bond_kg": required_bond_kg_month,
"purchase_order_bond_kg": purchase_order_bond_kg,
"bond_unit_cost": bond_unit_cost,
"total_monthly_expenses": monthly_total_expenses,
"monthly_power_cost": monthly_power_cost,
"total_monthly_kwh": (avg_power_per_machine_kw * num_machines * work_hours_day * work_days_month),
"monthly_labor_cost": monthly_labor_cost_total,
"monthly_meal_cost": (num_people_needed * meal_cost_day * work_days_month),
"monthly_packaging_cost": monthly_packaging_cost,
"monthly_depreciation_cost": monthly_depreciation_cost_total,
"monthly_rent_cost": monthly_rent,
"monthly_general_admin_cost_setting": monthly_admin,
"monthly_delivery_cost": monthly_delivery,
"total_revenue": total_raw_material_purchase_cost + ((pure_processing_cost_kg + calculated_profit_margin_kg) * total_kg_month)
})
# 2. 목표 생산량 분석 (요청 시 추가 수행)
if target_production_quantity and target_production_quantity > 0:
total_work_hours_target = target_production_quantity / (total_kg_hour_all_mc if total_kg_hour_all_mc > 0 else 1)
work_days_target = total_work_hours_target / work_hours_day
required_yarn_kg_target = (pure_yarn_kg_per_hour_all_mc / total_kg_hour_all_mc) * target_production_quantity if total_kg_hour_all_mc > 0 else 0
purchase_order_yarn_kg_target = max(0, required_yarn_kg_target - current_yarn_stock)
required_bond_kg_target = (bond_kg_per_hour_all_mc / total_kg_hour_all_mc) * target_production_quantity if total_kg_hour_all_mc > 0 else 0
purchase_order_bond_kg_target = max(0, required_bond_kg_target - current_bond_stock)
plan_yarn_cost = required_yarn_kg_target * yarn_unit_cost
plan_bond_cost = required_bond_kg_target * bond_unit_cost
plan_total_material_cost = plan_yarn_cost + plan_bond_cost
plan_net_processing_cost = hourly_net_cost_per_machine * num_machines * total_work_hours_target
plan_company_margin = hourly_profit_per_machine * num_machines * total_work_hours_target
plan_processing_fee = plan_net_processing_cost + plan_company_margin
plan_estimated_sales = plan_processing_fee + plan_total_material_cost
analysis_result.update({
"required_days_target": work_days_target,
"current_yarn_stock_target": current_yarn_stock,
"required_yarn_kg_target": required_yarn_kg_target,
"purchase_order_kg_target": purchase_order_yarn_kg_target,
"current_bond_stock_target": current_bond_stock,
"required_bond_kg_target": required_bond_kg_target,
"purchase_order_bond_kg_target": purchase_order_bond_kg_target,
"plan_yarn_cost": plan_yarn_cost,
"plan_bond_cost": plan_bond_cost,
"plan_total_material_cost": plan_total_material_cost,
"plan_net_processing_cost": plan_net_processing_cost,
"plan_company_margin": plan_company_margin,
"plan_processing_fee": plan_processing_fee,
"plan_estimated_sales": plan_estimated_sales
})
analysis_result.update({
"hourly_net_cost_per_machine": hourly_net_cost_per_machine,
"hourly_profit_per_machine": hourly_profit_per_machine
})
return analysis_result
+237
View File
@@ -0,0 +1,237 @@
import mysql.connector
from database.connection import get_db_connection
import logging
# 물성 정보 추가
def add_material_property(data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO material_properties (material_code, material_name, material_type, density, remarks)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
data.get('material_code'),
data.get('material_name'),
data.get('material_type'),
data.get('density'),
data.get('remarks')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding material property: {err}")
return None
# 물성 정보 수정
def update_material_property(data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE material_properties SET
material_code = %s,
material_name = %s,
material_type = %s,
density = %s,
remarks = %s
WHERE id = %s
"""
cursor.execute(sql, (
data.get('material_code'),
data.get('material_name'),
data.get('material_type'),
data.get('density'),
data.get('remarks'),
data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating material property: {err}")
return False
# 물성 정보 삭제
def delete_material_property(material_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT COUNT(*) as count FROM inventory WHERE material_id = %s", (material_id,))
if cursor.fetchone()['count'] > 0:
logging.warning(f"Cannot delete material property {material_id} as it is in use by inventory.")
return False
cursor.execute("DELETE FROM material_properties WHERE id = %s", (material_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting material property: {err}")
return False
# 모든 물성 정보 조회
def get_all_material_properties():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM material_properties")
return cursor.fetchall()
except mysql.connector.Error as err:
logging.error(f"Error getting all material properties: {err}")
return []
# ID로 특정 물성 정보 조회
def get_material_property_by_id(material_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM material_properties WHERE id = %s", (material_id,))
return cursor.fetchone()
except mysql.connector.Error as err:
logging.error(f"Error getting material property by id: {err}")
return None
# 재고 입고 (추가)
def add_inventory_to_db(data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
stock = data.get('receipt_quantity', 0) - data.get('usage_quantity', 0)
sql = """
INSERT INTO inventory (material_id, receipt_date, receipt_quantity, usage_quantity, stock, unit_cost, remarks, is_depleted, supplier_id, item_name, item_number)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
data.get('material_id'),
data.get('receipt_date'),
data.get('receipt_quantity'),
data.get('usage_quantity', 0),
stock,
data.get('unit_cost'),
data.get('remarks'),
1 if stock <= 0 else 0,
data.get('supplier_id'),
data.get('item_name'),
data.get('item_number')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding inventory: {err}")
return None
# 재고 정보 수정
def update_inventory_in_db(data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
stock = data.get('receipt_quantity', 0) - data.get('usage_quantity', 0)
sql = """
UPDATE inventory SET
material_id = %s,
receipt_date = %s,
receipt_quantity = %s,
usage_quantity = %s,
stock = %s,
unit_cost = %s,
remarks = %s,
is_depleted = %s,
supplier_id = %s,
item_name = %s,
item_number = %s
WHERE id = %s
"""
cursor.execute(sql, (
data.get('material_id'),
data.get('receipt_date'),
data.get('receipt_quantity'),
data.get('usage_quantity'),
stock,
data.get('unit_cost'),
data.get('remarks'),
1 if stock <= 0 else 0,
data.get('supplier_id'),
data.get('item_name'),
data.get('item_number'),
data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating inventory: {err}")
return False
# 재고 소진 처리
def deplete_inventory_in_db(inventory_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = "UPDATE inventory SET is_depleted = 1, stock = 0 WHERE id = %s"
cursor.execute(sql, (inventory_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error depleting inventory: {err}")
return False
# 모든 재고 목록 조회
def get_all_inventory_from_db():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
SELECT
i.id, i.material_id, i.receipt_date, i.receipt_quantity, i.usage_quantity,
i.stock, i.unit_cost, i.remarks, i.is_depleted, i.supplier_id,
i.item_name, i.item_number,
mp.material_code, mp.material_name, mp.material_type, mp.density,
s.name_kr as supplier
FROM inventory i
LEFT JOIN material_properties mp ON i.material_id = mp.id
LEFT JOIN suppliers s ON i.supplier_id = s.id
ORDER BY i.receipt_date DESC
"""
cursor.execute(sql)
return cursor.fetchall()
except mysql.connector.Error as err:
logging.error(f"Error getting all inventory: {err}")
return []
# 특정 재고 상세 정보 조회
def get_inventory_details_from_db(inventory_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
SELECT
i.*,
mp.material_code, mp.material_name, mp.material_type, mp.density,
s.name_kr as supplier_name
FROM inventory i
LEFT JOIN material_properties mp ON i.material_id = mp.id
LEFT JOIN suppliers s ON i.supplier_id = s.id
WHERE i.id = %s
"""
cursor.execute(sql, (inventory_id,))
inventory_data = cursor.fetchone()
if not inventory_data:
return None
return {
"inventory": inventory_data
}
except mysql.connector.Error as err:
logging.error(f"Error getting inventory details: {err}")
return None
# 특정 자재의 총 재고량 조회
def get_total_stock_for_material(material_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = "SELECT SUM(stock) as total_stock FROM inventory WHERE material_id = %s AND is_depleted = 0"
cursor.execute(sql, (material_id,))
result = cursor.fetchone()
return result['total_stock'] if result and result['total_stock'] is not None else 0
except mysql.connector.Error as err:
logging.error(f"Error getting total stock for material: {err}")
return 0
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
from .db_manager import (
add_material_property,
update_material_property,
delete_material_property,
get_all_material_properties,
get_material_property_by_id,
add_inventory_to_db,
update_inventory_in_db,
deplete_inventory_in_db,
get_all_inventory_from_db,
get_inventory_details_from_db
)
from .analysis import calculate_cost_analysis
router = APIRouter(prefix="/inventory", tags=["inventory"])
# --- Pydantic Schemas ---
class MaterialPropertySchema(BaseModel):
id: Optional[int] = None
material_code: str
material_name: str
material_type: str
density: float
remarks: Optional[str] = None
class InventorySchema(BaseModel):
id: Optional[int] = None
material_id: int
receipt_date: str # YYYY-MM-DD
receipt_quantity: float
usage_quantity: Optional[float] = 0.0
stock: Optional[float] = None
unit_cost: float
remarks: Optional[str] = None
is_depleted: Optional[int] = 0
supplier_id: Optional[int] = None
item_name: Optional[str] = None
item_number: Optional[str] = None
class AnalysisInputSchema(BaseModel):
yarn_inventory_id: int
bond_inventory_id: Optional[int] = None
yarn_diameter_micron: Optional[float] = 7.0
yarn_k: Optional[float] = 12.0
ports: Optional[float] = 40.0
cycle_time_hz: Optional[float] = 8.0
cut_length_mm: Optional[float] = 6.0
work_hours_day: Optional[float] = 16.0
work_days_month: Optional[float] = 20.0
num_machines: Optional[float] = 2.0
bond_percentage: Optional[float] = 3.0
class CostAnalysisRequestSchema(BaseModel):
inputs: AnalysisInputSchema
targetProductionQuantity: Optional[float] = None
# --- Material Properties APIs ---
@router.post("/materials", response_model=dict)
def create_material_property(material: MaterialPropertySchema):
result = add_material_property(material.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create material property")
return {"id": result, "status": "success"}
@router.put("/materials/{material_id}", response_model=dict)
def update_material(material_id: int, material: MaterialPropertySchema):
data = material.dict()
data['id'] = material_id
success = update_material_property(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update material property")
return {"status": "success"}
@router.delete("/materials/{material_id}", response_model=dict)
def delete_material(material_id: int):
success = delete_material_property(material_id)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete material property. Check if it's in use.")
return {"status": "success"}
@router.get("/materials", response_model=List[MaterialPropertySchema])
def list_material_properties():
return get_all_material_properties()
@router.get("/materials/{material_id}", response_model=MaterialPropertySchema)
def get_material_property(material_id: int):
material = get_material_property_by_id(material_id)
if not material:
raise HTTPException(status_code=404, detail="Material property not found")
return material
# --- Inventory APIs ---
@router.post("/", response_model=dict)
def create_inventory(inventory: InventorySchema):
result = add_inventory_to_db(inventory.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create inventory record")
return {"id": result, "status": "success"}
@router.put("/{inventory_id}", response_model=dict)
def update_inventory(inventory_id: int, inventory: InventorySchema):
data = inventory.dict()
data['id'] = inventory_id
success = update_inventory_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update inventory record")
return {"status": "success"}
@router.put("/{inventory_id}/deplete", response_model=dict)
def deplete_inventory(inventory_id: int):
success = deplete_inventory_in_db(inventory_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to deplete inventory")
return {"status": "success"}
@router.get("/", response_model=List[dict])
def list_inventory():
return get_all_inventory_from_db()
@router.get("/{inventory_id}", response_model=dict)
def get_inventory_details(inventory_id: int):
details = get_inventory_details_from_db(inventory_id)
if not details:
raise HTTPException(status_code=404, detail="Inventory details not found")
return details
# --- Cost Analysis APIs ---
@router.post("/analysis/calculate", response_model=dict)
def cost_analysis(request_data: CostAnalysisRequestSchema):
try:
inputs_dict = request_data.inputs.dict()
# Null 문자열 파라미터 방어용 가공
if not inputs_dict.get("bond_inventory_id"):
inputs_dict["bond_inventory_id"] = "null"
result = calculate_cost_analysis(inputs_dict, request_data.targetProductionQuantity)
return result
except ValueError as val_err:
raise HTTPException(status_code=400, detail=str(val_err))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Internal calculations failed: {exc}")
+288
View File
@@ -0,0 +1,288 @@
import logging
import sys
import os
import asyncio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
# --- Logging Configuration ---
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] (%(name)s) %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger("main")
# --- Import sub-routers ---
from customer import router as customer_router
from supplier import router as supplier_router
from setting import router as setting_router
from inventory import router as inventory_router
from equipment import router as equipment_router
from plan import router as plan_router
# --- Import MQTT lifecycle controller ---
from communication import start_mqtt_client, stop_mqtt_client
async def monitor_timeouts_and_heartbeats():
"""ACCEPTED/RECEIVED 제어 명령 타임아웃(30초) 및 하트비트 타임아웃(15초)을 검사하는 백그라운드 태스크"""
from database.connection import get_db_connection
from communication.mqtt_client import _publish_hmi_update
import logging
loop_logger = logging.getLogger("timeout_monitor")
loop_logger.info("[TIMEOUT MONITOR] Background monitor task started.")
while True:
try:
await asyncio.sleep(5)
with get_db_connection() as conn:
with conn.cursor() as cursor:
# 1. 30초 초과 ACCEPTED or RECEIVED 상태인 명령 검색 및 TIMEOUT 업데이트 & WebSocket 브로드캐스트
cursor.execute("""
SELECT command_id FROM control_commands
WHERE command_status IN ('ACCEPTED', 'RECEIVED')
AND created_at < DATE_SUB(NOW(), INTERVAL 30 SECOND)
""")
timeout_cmds = [row[0] for row in cursor.fetchall()]
if timeout_cmds:
for cmd_id in timeout_cmds:
cursor.execute("""
UPDATE control_commands
SET command_status = 'TIMEOUT', applied_at = CURRENT_TIMESTAMP
WHERE command_id = %s
""", (cmd_id,))
conn.commit()
loop_logger.info(f"[TIMEOUT MONITOR] Marked {len(timeout_cmds)} commands as TIMEOUT.")
# WebSocket으로 프론트엔드에 전달하여 무한 대기 스피너 방지
from communication.websocket_manager import ws_manager
import time
loop = getattr(ws_manager, 'loop', None)
if loop and loop.is_running():
for cmd_id in timeout_cmds:
try:
asyncio.run_coroutine_threadsafe(
ws_manager.broadcast({
"type": "command_ack",
"command_id": cmd_id,
"status": "TIMEOUT",
"applied_value": None,
"timestamp": int(time.time() * 1000)
}),
loop
)
except Exception as ws_err:
loop_logger.error(f"[TIMEOUT WS BROADCAST ERROR] {ws_err}")
# 2. 15초 초과 heartbeat 부재 시 gateway/plc/device_state를 OFFLINE으로 전환
cursor.execute("""
SELECT equipment_id FROM machine_status
WHERE gateway_state = 'ONLINE'
AND timestamp < DATE_SUB(NOW(), INTERVAL 15 SECOND)
""")
offline_ids = [row[0] for row in cursor.fetchall()]
if offline_ids:
for eq_id in offline_ids:
cursor.execute("""
UPDATE machine_status
SET gateway_state = 'OFFLINE', plc_state = 'OFFLINE', device_state = 'OFFLINE', timestamp = CURRENT_TIMESTAMP
WHERE equipment_id = %s
""", (eq_id,))
conn.commit()
loop_logger.warning(f"[TIMEOUT MONITOR] Gateway offline detected for equipment IDs: {offline_ids}. Updated statuses.")
# HMI 웹소켓 브로드캐스트 위임
for eq_id in offline_ids:
try:
_publish_hmi_update(eq_id)
except Exception as ws_err:
loop_logger.error(f"[TIMEOUT MONITOR WS ERROR] {ws_err}")
except asyncio.CancelledError:
loop_logger.info("[TIMEOUT MONITOR] Stopped.")
break
except Exception as e:
loop_logger.error(f"[TIMEOUT MONITOR ERROR] {e}", exc_info=True)
# --- Lifespan Context Manager ---
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup 이벤트: MQTT 백그라운드 구동 및 DB 기초 데이터 시딩
logger.info("Starting up FastAPI application...")
# 필수 설비 데이터(ID=8, 양산 1호기) 자동 생성 (DB가 비어있을 때 FK 에러 방지)
try:
from database.connection import get_db_connection
with get_db_connection() as conn:
with conn.cursor() as cursor:
# control_commands 테이블 자동 생성
logger.info("[SEED] Ensuring control_commands table exists...")
cursor.execute("""
CREATE TABLE IF NOT EXISTS control_commands (
id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT NOT NULL,
command_id VARCHAR(36) NOT NULL UNIQUE,
type VARCHAR(10) NOT NULL,
requested_value JSON NOT NULL,
applied_value JSON NULL,
command_status VARCHAR(15) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
applied_at TIMESTAMP NULL,
FOREIGN KEY (equipment_id) REFERENCES equipment(id)
)
""")
conn.commit()
# machine_status 테이블 컬럼 확인 및 추가 (장애 상태 분리)
cursor.execute("SHOW COLUMNS FROM machine_status")
column_rows = cursor.fetchall()
cols = [col[0] for col in column_rows]
column_types = {col[0]: str(col[1]).lower() for col in column_rows}
if "gateway_state" not in cols:
logger.info("[SEED] Adding gateway_state to machine_status table...")
cursor.execute("ALTER TABLE machine_status ADD COLUMN gateway_state VARCHAR(10) DEFAULT 'OFFLINE'")
if "plc_state" not in cols:
logger.info("[SEED] Adding plc_state to machine_status table...")
cursor.execute("ALTER TABLE machine_status ADD COLUMN plc_state VARCHAR(10) DEFAULT 'OFFLINE'")
if "sync_state" not in cols:
logger.info("[SEED] Adding sync_state to machine_status table...")
cursor.execute("ALTER TABLE machine_status ADD COLUMN sync_state VARCHAR(10) DEFAULT 'IDLE'")
# D read groups 1~4 and 6~9 are PLC REAL values and must retain decimals.
for group_id in (1, 2, 3, 4, 6, 7, 8, 9):
column_name = f"d_read_g{group_id}"
if column_types.get(column_name) != "double":
cursor.execute(
f"ALTER TABLE machine_status MODIFY COLUMN {column_name} DOUBLE DEFAULT 0"
)
for group_id in range(4):
column_name = f"d_write_g{group_id}"
if column_types.get(column_name) != "double":
cursor.execute(
f"ALTER TABLE machine_status MODIFY COLUMN {column_name} DOUBLE DEFAULT 0"
)
conn.commit()
cursor.execute("SELECT id FROM equipment WHERE id = 8")
if not cursor.fetchone():
logger.info("[SEED] Seeding default equipment with ID 8...")
cursor.execute(
"INSERT INTO equipment (id, name, status) VALUES (8, '양산 1호기 (CC_MC_TYPE_A)', 'available')"
)
import json
initial_m_read = json.dumps({str(i): 0 for i in range(208)})
initial_m_write = json.dumps({str(i): 0 for i in range(188)})
cursor.execute(
"INSERT INTO machine_status (equipment_id, device_state, m_read_raw, m_write_raw) VALUES (8, 'OFFLINE', %s, %s)",
(initial_m_read, initial_m_write)
)
conn.commit()
logger.info("[SEED] Seeding complete with explicit 0-initialized M-bit fields.")
except Exception as se:
logger.error(f"[SEED] Database seeding failed (check if database is running): {se}")
# 비동기 이벤트 루프 캡처하여 웹소켓 매니저에 보관 (타 스레드 호출 목적)
import asyncio
from communication.websocket_manager import ws_manager
ws_manager.loop = asyncio.get_event_loop()
start_mqtt_client()
# 타임아웃 및 하트비트 감시 백그라운드 태스크 기동
timeout_task = asyncio.create_task(monitor_timeouts_and_heartbeats())
yield
# Shutdown 이벤트: MQTT 연결 정상 종료 및 백그라운드 태스크 정리
logger.info("Shutting down FastAPI application...")
timeout_task.cancel()
try:
await timeout_task
except asyncio.CancelledError:
pass
stop_mqtt_client()
# --- FastAPI Initialization ---
app = FastAPI(
title="CC MC Carbon Cutting MC MES API Server",
description="카본절단 MC 설비 제어 및 MES 통합 백엔드 REST API & MQTT 서버",
version="1.0.0",
lifespan=lifespan
)
# --- CORS Middleware ---
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 모바일 브라우저 및 웹앱 개발 편의를 위해 모든 오리진 허용 (배포 시 운영 사양에 맞춤)
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Sub-routers Mapping ---
app.include_router(customer_router)
app.include_router(supplier_router)
app.include_router(setting_router)
app.include_router(inventory_router)
app.include_router(equipment_router, prefix="/api")
app.include_router(plan_router)
@app.get("/api/status")
def read_root():
return {
"status": "online",
"service": "ChemiFactory MQTT & REST API server",
"docs_url": "/docs"
}
# --- WebSocket Endpoint for Real-time HMI ---
from fastapi import WebSocket, WebSocketDisconnect
from communication.websocket_manager import ws_manager
@app.websocket("/ws/equipment")
async def websocket_endpoint(websocket: WebSocket):
await ws_manager.connect(websocket)
try:
while True:
# 클라이언트로부터 메시지 수신 대기 (연결 유지 목적)
data = await websocket.receive_text()
# 필요 시 클라이언트 메시지 핸들링 가능 (여기서는 단순 에코 또는 무시)
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
except Exception as e:
logger.error(f"[WS] Connection error: {e}")
ws_manager.disconnect(websocket)
# --- Frontend Static Files Serving ---
# server/frontend 디렉토리가 없으면 자동 생성하여 에러 방지
frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
if not os.path.exists(frontend_dir):
os.makedirs(frontend_dir)
# 캐시 제어 미들웨어: theme.js와 base.css는 항상 최신 버전을 로드하도록 설정
@app.middleware("http")
async def cache_control_middleware(request, call_next):
response = await call_next(request)
# 테마 및 CSS 파일은 캐시하지 않음 (개발/배포 환경에서 변경 즉시 반영)
if request.url.path.endswith((".js", ".css")):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
if __name__ == "__main__":
import uvicorn
# Synology 포트 포워딩 간섭 및 충돌 방지를 위해 미사용 포트인 8999 포트 사용
# NAS 운영에서는 reload=False (Uvicorn 재로더 자식 프로세스가 포트를 점유하는 문제 방지)
uvicorn.run("main:app", host="0.0.0.0", port=8999, reload=False)
#from fastapi import FastAPI
#
#app = FastAPI()
+48
View File
@@ -0,0 +1,48 @@
from dataclasses import dataclass, field
from datetime import datetime
import logging
from typing import List
logger = logging.getLogger("metrics")
@dataclass
class MessageMetrics:
topic: str
processing_time_ms: float
error: bool = False
error_msg: str = ""
timestamp: datetime = field(default_factory=datetime.now)
message_metrics: List[MessageMetrics] = []
def record_metric(topic: str, processing_time_ms: float, error: bool = False, error_msg: str = ""):
metric = MessageMetrics(
topic=topic,
processing_time_ms=processing_time_ms,
error=error,
error_msg=error_msg
)
message_metrics.append(metric)
if len(message_metrics) > 1000:
message_metrics.pop(0)
if error:
logger.error(f"[METRIC ERROR] {topic}: {error_msg} ({processing_time_ms:.1f}ms)")
elif processing_time_ms > 100:
logger.warning(f"[METRIC SLOW] {topic}: {processing_time_ms:.1f}ms (>100ms)")
def get_metrics_summary() -> dict:
if not message_metrics:
return {"message": "No metrics recorded yet"}
total_count = len(message_metrics)
error_count = sum(1 for m in message_metrics if m.error)
avg_time = sum(m.processing_time_ms for m in message_metrics) / total_count
return {
"total_messages": total_count,
"error_count": error_count,
"error_rate": error_count / total_count if total_count > 0 else 0.0,
"avg_processing_time_ms": round(avg_time, 2),
"last_update": message_metrics[-1].timestamp.isoformat()
}
+13
View File
@@ -0,0 +1,13 @@
# plan package marker
from .db_manager import (
add_production_plan,
add_equipment_assignments_to_plan,
get_all_production_plans,
find_available_equipment,
update_plan_assignment,
delete_production_plan,
adjust_and_distribute_assignments,
update_production_plan
)
from .helper import calculate_end_date
from .router import router
+294
View File
@@ -0,0 +1,294 @@
import json
import mysql.connector
from database.connection import get_db_connection
import logging
from datetime import datetime, timedelta, date
# 생산 계획 추가
def add_production_plan(plan_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
logging.info(f"DB에 추가될 최종 plan_data: {json.dumps(plan_data, indent=4, ensure_ascii=False)}")
sql = """
INSERT INTO production_plans (
plan_name, customer_name, customer_id, yarn_name, yarn_inventory_id,
bond_name, bond_inventory_id, requested_quantity_kg, start_date, end_date,
production_days, status, hourly_net_cost_per_machine, hourly_profit_per_machine,
plan_total_material_cost, yarn_diameter, yarn_k, ports, cycle_time,
cut_length, work_hours_day, work_days_month, num_machines,
plan_net_processing_cost, plan_company_margin, plan_processing_fee,
plan_yarn_cost, plan_bond_cost, plan_estimated_sales
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s
)
"""
cursor.execute(sql, (
plan_data.get('plan_name'), plan_data.get('customer_name'), plan_data.get('customer_id'),
plan_data.get('yarn_name'), plan_data.get('yarn_inventory_id'), plan_data.get('bond_name'),
plan_data.get('bond_inventory_id'), plan_data.get('requested_quantity_kg'),
plan_data.get('start_date'), plan_data.get('end_date'), plan_data.get('production_days'),
plan_data.get('status', 'Scheduled'), plan_data.get('hourly_net_cost_per_machine'),
plan_data.get('hourly_profit_per_machine'), plan_data.get('plan_total_material_cost'),
plan_data.get('yarn_diameter'), plan_data.get('yarn_k'), plan_data.get('ports'),
plan_data.get('cycle_time'), plan_data.get('cut_length'), plan_data.get('work_hours_day'),
plan_data.get('work_days_month'), plan_data.get('num_machines'),
plan_data.get('plan_net_processing_cost'), plan_data.get('plan_company_margin'),
plan_data.get('plan_processing_fee'), plan_data.get('plan_yarn_cost'),
plan_data.get('plan_bond_cost'), plan_data.get('plan_estimated_sales')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding production plan: {err}")
return None
# 특정 생산 계획에 설비 할당 정보 추가
def add_equipment_assignments_to_plan(plan_id, assignments):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO plan_assignments (
plan_id, equipment_id, start_date, end_date, works_on_saturday, works_on_sunday
) VALUES (%s, %s, %s, %s, %s, %s)
"""
assignment_data = [
(plan_id, assign['equipment_id'], assign['start_date'], assign['end_date'],
1 if assign.get('works_on_saturday') else 0, 1 if assign.get('works_on_sunday') else 0)
for assign in assignments
]
cursor.executemany(sql, assignment_data)
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error adding equipment assignments: {err}")
return False
# 모든 생산 계획 목록 조회 (설비 할당 정보 포함)
def get_all_production_plans():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM production_plans ORDER BY start_date")
plans = cursor.fetchall()
if not plans:
return []
sql_assignments = """
SELECT pa.*, e.name as equipment_name
FROM plan_assignments pa
JOIN equipment e ON pa.equipment_id = e.id
"""
cursor.execute(sql_assignments)
assignments = cursor.fetchall()
plan_map = {plan['plan_id']: plan for plan in plans}
for plan in plans:
plan['assigned_equipment'] = []
for assign in assignments:
if assign['plan_id'] in plan_map:
assign['works_on_saturday'] = bool(assign['works_on_saturday'])
assign['works_on_sunday'] = bool(assign['works_on_sunday'])
plan_map[assign['plan_id']]['assigned_equipment'].append(assign)
return list(plan_map.values())
except mysql.connector.Error as err:
logging.error(f"Error getting all production plans: {err}")
return []
# 특정 기간에 사용 가능한 설비 목록 검색
def find_available_equipment(start_date_str, end_date_str, works_on_saturday, works_on_sunday):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT id, name FROM equipment")
all_equipment = cursor.fetchall()
return all_equipment
except mysql.connector.Error as err:
logging.error(f"Error finding all equipment: {err}")
return []
# 개별 설비 할당 정보 수정
def update_plan_assignment(plan_id, assignment_data):
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
conn.start_transaction()
sql_update_assignment = """
UPDATE plan_assignments SET
start_date = %s,
end_date = %s,
works_on_saturday = %s,
works_on_sunday = %s
WHERE assignment_id = %s
"""
cursor.execute(sql_update_assignment, (
assignment_data.get('start_date'),
assignment_data.get('end_date'),
1 if assignment_data.get('works_on_saturday') else 0,
1 if assignment_data.get('works_on_sunday') else 0,
assignment_data.get('assignment_id')
))
cursor.execute("SELECT MIN(start_date) as min_start, MAX(end_date) as max_end FROM plan_assignments WHERE plan_id = %s", (plan_id,))
plan_dates = cursor.fetchone()
if plan_dates and plan_dates['min_start'] and plan_dates['max_end']:
cursor.execute("UPDATE production_plans SET start_date = %s, end_date = %s WHERE plan_id = %s",
(plan_dates['min_start'], plan_dates['max_end'], plan_id))
conn.commit()
return True
except mysql.connector.Error as err:
if conn:
conn.rollback()
logging.error(f"Error updating plan assignment for plan_id {plan_id}: {err}")
return False
finally:
if conn and conn.is_connected():
cursor.close()
conn.close()
# 생산 계획 삭제
def delete_production_plan(plan_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM plan_assignments WHERE plan_id = %s", (plan_id,))
cursor.execute("DELETE FROM production_plans WHERE plan_id = %s", (plan_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting production plan: {err}")
return False
# 가동일 분배 조정
def adjust_and_distribute_assignments(plan_id, source_assignment, distribution):
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
conn.start_transaction()
update_source_sql = """
UPDATE plan_assignments SET
start_date=%s, end_date=%s, works_on_saturday=%s, works_on_sunday=%s
WHERE assignment_id=%s
"""
cursor.execute(update_source_sql, (
source_assignment['start_date'], source_assignment['end_date'],
1 if source_assignment['works_on_saturday'] else 0,
1 if source_assignment['works_on_sunday'] else 0,
source_assignment['assignment_id']
))
for assign_id_str, days_to_add in distribution.items():
days_to_add = int(days_to_add)
if days_to_add <= 0:
continue
assign_id = int(assign_id_str)
cursor.execute("SELECT * FROM plan_assignments WHERE assignment_id = %s", (assign_id,))
target = cursor.fetchone()
if not target:
raise Exception(f"Target assignment with ID {assign_id} not found.")
current_end_date = target['end_date']
works_sat = bool(target['works_on_saturday'])
works_sun = bool(target['works_on_sunday'])
new_end_date = current_end_date
days_added_count = 0
while days_added_count < days_to_add:
new_end_date += timedelta(days=1)
weekday = new_end_date.weekday()
if (weekday < 5) or (weekday == 5 and works_sat) or (weekday == 6 and works_sun):
days_added_count += 1
cursor.execute("UPDATE plan_assignments SET end_date = %s WHERE assignment_id = %s", (new_end_date.isoformat(), assign_id))
cursor.execute("SELECT MIN(start_date) as min_start, MAX(end_date) as max_end FROM plan_assignments WHERE plan_id = %s", (plan_id,))
plan_dates = cursor.fetchone()
if plan_dates and plan_dates['min_start'] and plan_dates['max_end']:
cursor.execute("UPDATE production_plans SET start_date = %s, end_date = %s WHERE plan_id = %s",
(plan_dates['min_start'], plan_dates['max_end'], plan_id))
conn.commit()
return True
except Exception as err:
if conn:
conn.rollback()
logging.error(f"Error during assignment redistribution for plan_id {plan_id}: {err}")
return False
finally:
if conn and conn.is_connected():
cursor.close()
conn.close()
# 생산 계획 수정
def update_production_plan(plan_id, plan_data, assigned_machine_ids):
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
conn.start_transaction()
cursor.execute("DELETE FROM plan_assignments WHERE plan_id = %s", (plan_id,))
update_sql = """
UPDATE production_plans SET
plan_name = %s, customer_name = %s, customer_id = %s, yarn_name = %s, yarn_inventory_id = %s,
bond_name = %s, bond_inventory_id = %s, requested_quantity_kg = %s, start_date = %s, end_date = %s,
production_days = %s, status = %s, hourly_net_cost_per_machine = %s, hourly_profit_per_machine = %s,
plan_total_material_cost = %s, yarn_diameter = %s, yarn_k = %s, ports = %s, cycle_time = %s,
cut_length = %s, work_hours_day = %s, work_days_month = %s, num_machines = %s,
plan_net_processing_cost = %s, plan_company_margin = %s, plan_processing_fee = %s,
plan_yarn_cost = %s, plan_bond_cost = %s, plan_estimated_sales = %s
WHERE plan_id = %s
"""
cursor.execute(update_sql, (
plan_data.get('plan_name'), plan_data.get('customer_name'), plan_data.get('customer_id'),
plan_data.get('yarn_name'), plan_data.get('yarn_inventory_id'), plan_data.get('bond_name'),
plan_data.get('bond_inventory_id'), plan_data.get('requested_quantity_kg'),
plan_data.get('start_date'), plan_data.get('end_date'), plan_data.get('production_days'),
plan_data.get('status', 'Scheduled'), plan_data.get('hourly_net_cost_per_machine'),
plan_data.get('hourly_profit_per_machine'), plan_data.get('plan_total_material_cost'),
plan_data.get('yarn_diameter'), plan_data.get('yarn_k'), plan_data.get('ports'),
plan_data.get('cycle_time'), plan_data.get('cut_length'), plan_data.get('work_hours_day'),
plan_data.get('work_days_month'), plan_data.get('num_machines'),
plan_data.get('plan_net_processing_cost'), plan_data.get('plan_company_margin'),
plan_data.get('plan_processing_fee'), plan_data.get('plan_yarn_cost'),
plan_data.get('plan_bond_cost'), plan_data.get('plan_estimated_sales'),
plan_id
))
if assigned_machine_ids:
assign_sql = """
INSERT INTO plan_assignments (
plan_id, equipment_id, start_date, end_date, works_on_saturday, works_on_sunday
) VALUES (%s, %s, %s, %s, %s, %s)
"""
assignment_data = [
(
plan_id, eq_id, plan_data.get('start_date'), plan_data.get('end_date'),
1 if plan_data.get('works_on_saturday') else 0,
1 if plan_data.get('works_on_sunday') else 0
) for eq_id in assigned_machine_ids
]
cursor.executemany(assign_sql, assignment_data)
conn.commit()
return True
except Exception as err:
if conn:
conn.rollback()
logging.error(f"Error updating production plan for plan_id {plan_id}: {err}")
return False
finally:
if conn and conn.is_connected():
cursor.close()
conn.close()
+31
View File
@@ -0,0 +1,31 @@
import math
from datetime import date, timedelta
def calculate_end_date(start_date_str: str, work_days: float, works_on_saturday: bool, works_on_sunday: bool) -> str:
"""주말(토/일) 근무 여부 플래그를 고려하여 가동 후 실제 종료 날짜(YYYY-MM-DD)를 계산합니다."""
start_date = date.fromisoformat(start_date_str)
work_days_required = math.ceil(work_days)
current_date = start_date
days_counted = 0
if work_days_required <= 0:
return start_date.isoformat()
while days_counted < work_days_required:
weekday = current_date.weekday() # 월요일 0, 일요일 6
is_working_day = True
if weekday == 5: # 토요일
if not works_on_saturday:
is_working_day = False
elif weekday == 6: # 일요일
if not works_on_sunday:
is_working_day = False
if is_working_day:
days_counted += 1
if days_counted < work_days_required:
current_date += timedelta(days=1)
return current_date.isoformat()
+162
View File
@@ -0,0 +1,162 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
from .db_manager import (
add_production_plan,
add_equipment_assignments_to_plan,
get_all_production_plans,
find_available_equipment,
update_plan_assignment,
delete_production_plan,
adjust_and_distribute_assignments,
update_production_plan
)
from .helper import calculate_end_date
router = APIRouter(prefix="/plans", tags=["plans"])
# --- Pydantic Schemas ---
class PlanAssignmentSchema(BaseModel):
assignment_id: Optional[int] = None
plan_id: Optional[int] = None
equipment_id: int
start_date: str
end_date: str
works_on_saturday: bool
works_on_sunday: bool
class PlanSchema(BaseModel):
plan_id: Optional[int] = None
plan_name: str
customer_name: Optional[str] = None
customer_id: Optional[int] = None
yarn_name: Optional[str] = None
yarn_inventory_id: int
bond_name: Optional[str] = None
bond_inventory_id: Optional[int] = None
requested_quantity_kg: float
start_date: str
end_date: Optional[str] = None
production_days: float
status: Optional[str] = "Scheduled"
# Cost & Margin properties
hourly_net_cost_per_machine: float
hourly_profit_per_machine: float
plan_total_material_cost: float
yarn_diameter: float
yarn_k: float
ports: float
cycle_time: float
cut_length: float
work_hours_day: float
work_days_month: float
num_machines: float
plan_net_processing_cost: float
plan_company_margin: float
plan_processing_fee: float
plan_yarn_cost: float
plan_bond_cost: float
plan_estimated_sales: float
# Machine allocations
equipment_ids: List[int]
works_on_saturday: Optional[bool] = False
works_on_sunday: Optional[bool] = False
class AssignmentUpdatePayload(BaseModel):
assignment_id: int
start_date: str
end_date: str
works_on_saturday: bool
works_on_sunday: bool
class RedistributionPayload(BaseModel):
source_assignment: Dict[str, Any]
distribution: Dict[str, int]
# --- Plan APIs ---
@router.post("/", response_model=dict)
def create_production_plan(plan: PlanSchema):
plan_data = plan.dict()
assigned_machine_ids = plan.equipment_ids
start_date_str = plan.start_date
required_days = plan.production_days
works_on_saturday = plan.works_on_saturday
works_on_sunday = plan.works_on_sunday
if not assigned_machine_ids or not start_date_str or required_days is None:
raise HTTPException(status_code=400, detail="Missing required parameters: equipment_ids, start_date, production_days")
# 주말 가동 플래그를 고려하여 가용 완료 날짜 계산
end_date_str = calculate_end_date(start_date_str, required_days, works_on_saturday, works_on_sunday)
plan_data["end_date"] = end_date_str
plan_id = add_production_plan(plan_data)
if not plan_id:
raise HTTPException(status_code=500, detail="Failed to add production plan to DB")
# 설비 할당 정보 생성
assignments = [
{
'equipment_id': eq_id,
'start_date': start_date_str,
'end_date': end_date_str,
'works_on_saturday': works_on_saturday,
'works_on_sunday': works_on_sunday
} for eq_id in assigned_machine_ids
]
assign_success = add_equipment_assignments_to_plan(plan_id, assignments)
if not assign_success:
raise HTTPException(status_code=500, detail="Plan was created but equipment assignments failed.")
return {"plan_id": plan_id, "status": "success"}
@router.put("/{plan_id}", response_model=dict)
def edit_production_plan(plan_id: int, plan: PlanSchema):
plan_data = plan.dict()
assigned_machine_ids = plan.equipment_ids
start_date_str = plan.start_date
required_days = plan.production_days
works_on_saturday = plan.works_on_saturday
works_on_sunday = plan.works_on_sunday
end_date_str = calculate_end_date(start_date_str, required_days, works_on_saturday, works_on_sunday)
plan_data["end_date"] = end_date_str
success = update_production_plan(plan_id, plan_data, assigned_machine_ids)
if not success:
raise HTTPException(status_code=500, detail="Failed to update production plan")
return {"status": "success"}
@router.delete("/{plan_id}", response_model=dict)
def delete_plan(plan_id: int):
success = delete_production_plan(plan_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete production plan")
return {"status": "success"}
@router.get("/", response_model=List[dict])
def list_production_plans():
return get_all_production_plans()
@router.get("/find-available-equipment", response_model=List[dict])
def get_available_equipment(start_date: str, end_date: str, works_on_saturday: bool = False, works_on_sunday: bool = False):
return find_available_equipment(start_date, end_date, works_on_saturday, works_on_sunday)
@router.put("/{plan_id}/assignments", response_model=dict)
def update_assignment(plan_id: int, payload: AssignmentUpdatePayload):
success = update_plan_assignment(plan_id, payload.dict())
if not success:
raise HTTPException(status_code=500, detail="Failed to update assignment")
return {"status": "success"}
@router.post("/{plan_id}/assignments/redistribute", response_model=dict)
def redistribute_assignments(plan_id: int, payload: RedistributionPayload):
success = adjust_and_distribute_assignments(plan_id, payload.source_assignment, payload.distribution)
if not success:
raise HTTPException(status_code=500, detail="Failed to redistribute assignments")
return {"status": "success"}
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.95.0
uvicorn[standard]>=0.20.0
paho-mqtt>=1.6.1
mysql-connector-python>=8.0.32
+553
View File
@@ -0,0 +1,553 @@
-- phpMyAdmin SQL Dump
-- version 5.2.2
-- https://www.phpmyadmin.net/
--
-- 호스트: localhost
-- 생성 시간: 26-06-22 18:35
-- 서버 버전: 10.11.11-MariaDB
-- PHP 버전: 8.2.28
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- 데이터베이스: `carbon_cutting_mc_db`
--
-- --------------------------------------------------------
--
-- 테이블 구조 `contacts`
--
CREATE TABLE `contacts` (
`id` int(11) NOT NULL,
`customer_id` int(11) NOT NULL,
`name` varchar(100) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`phone_number` varchar(50) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`position` varchar(255) DEFAULT '',
`work_location` varchar(255) DEFAULT '',
`is_deleted` tinyint(1) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `control_target`
--
CREATE TABLE `control_target` (
`equipment_id` int(11) NOT NULL,
`operation_mode_target` varchar(20) DEFAULT NULL COMMENT 'Target mode (auto, manual)',
`operation_command_target` varchar(20) DEFAULT NULL COMMENT 'Target command (start, stop, reset)',
`line_heaters_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for 20 heaters. e.g., {"heater_1": 1, "heater_2": 0}' CHECK (json_valid(`line_heaters_target`)),
`line_feeders_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for 10 feeder motors. e.g., {"line_1": 1} (0:STOP, 1:FWD,2:REV)' CHECK (json_valid(`line_feeders_target`)),
`utility_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for pumps and fans. e.g., {"pumps": {"pump_1": 1}, "fans":{"fan_1": 1}}' CHECK (json_valid(`utility_target`)),
`transfer_roller_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for transfer roller. e.g., {"speed": 120.5, "direction":1, "enabled": 1}' CHECK (json_valid(`transfer_roller_target`)),
`cutting_motor_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for cutting motor. e.g., {"speed": 300.0, "direction": 0,"enabled": 1}' CHECK (json_valid(`cutting_motor_target`)),
`system_outputs_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for buzzer, tower light. e.g., {"buzzer": 0, "tower_red":1}' CHECK (json_valid(`system_outputs_target`)),
`spare_digital_outputs_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for spare digital outputs. e.g., {"R21": 1, "R24":0}' CHECK (json_valid(`spare_digital_outputs_target`)),
`spare_pwm_outputs_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for spare PWM outputs. e.g., {"D10": 128, "D11": 255}' CHECK (json_valid(`spare_pwm_outputs_target`)),
`last_updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Target control states set by users or apps.';
-- --------------------------------------------------------
--
-- 테이블 구조 `customers`
--
CREATE TABLE `customers` (
`id` int(11) NOT NULL,
`name_kr` varchar(255) NOT NULL,
`name_en` varchar(255) DEFAULT NULL,
`business_registration_number` varchar(50) DEFAULT NULL,
`contact_number` varchar(50) DEFAULT NULL,
`address` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `equipment`
--
CREATE TABLE `equipment` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`yarn_bobbin_count` int(11) DEFAULT NULL,
`dryer_type` varchar(255) DEFAULT NULL,
`power_consumption` double DEFAULT NULL,
`max_cutting_speed` int(11) DEFAULT NULL,
`max_feed_speed` int(11) DEFAULT NULL,
`status` varchar(50) NOT NULL DEFAULT 'available' COMMENT '장비의 현재 상태 (available, maintenance, broken)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `inventory`
--
CREATE TABLE `inventory` (
`id` int(11) NOT NULL,
`material_id` int(11) DEFAULT NULL COMMENT 'material_properties 테이블 ID',
`item_name` varchar(255) NOT NULL,
`item_number` varchar(255) NOT NULL,
`supplier` varchar(255) NOT NULL,
`supplier_id` int(11) DEFAULT NULL,
`receipt_date` date NOT NULL,
`receipt_quantity` int(11) NOT NULL,
`usage_quantity` int(11) NOT NULL,
`stock` int(11) NOT NULL,
`unit_cost` decimal(10,2) DEFAULT NULL COMMENT '입고 시점의 단위당 원가 (원/kg)',
`remarks` text DEFAULT NULL,
`is_depleted` tinyint(1) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `machine_status`
--
CREATE TABLE `machine_status` (
`equipment_id` int(11) NOT NULL COMMENT '설비 마스터 ID',
`device_state` varchar(20) DEFAULT 'OFFLINE' COMMENT 'ONLINE / OFFLINE 상태',
`m_read_raw` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'M영역 읽기비트 전체 상태 JSON {bit_id: value}',
`m_write_raw` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'M영역 쓰기제어비트 전체 상태 JSON {bit_id: value}',
`d_read_g0` bigint(20) DEFAULT 0 COMMENT 'D영역 읽기 그룹0: 컷팅속도',
`d_read_g1` double DEFAULT 0 COMMENT 'D영역 읽기 그룹1: 이송속도',
`d_read_g2` double DEFAULT 0 COMMENT 'D영역 읽기 그룹2: 챔버온도',
`d_read_g3` double DEFAULT 0 COMMENT 'D영역 읽기 그룹3',
`d_read_g4` double DEFAULT 0 COMMENT 'D영역 읽기 그룹4',
`d_read_g5` bigint(20) DEFAULT 0 COMMENT 'D영역 읽기 그룹5',
`d_read_g6` double DEFAULT 0 COMMENT 'D영역 읽기 그룹6',
`d_read_g7` double DEFAULT 0 COMMENT 'D영역 읽기 그룹7',
`d_read_g8` double DEFAULT 0 COMMENT 'D영역 읽기 그룹8',
`d_read_g9` double DEFAULT 0 COMMENT 'D영역 읽기 그룹9',
`d_write_g0` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹0: 컷팅 타겟 속도',
`d_write_g1` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹1: 이송 타겟 속도',
`d_write_g2` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹2',
`d_write_g3` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹3',
`timestamp` bigint(20) NOT NULL DEFAULT 0 COMMENT '게이트웨이 전송 타임스탬프 (ms)',
`last_updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT '서버 최종 수신 시간'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='각 설비의 14개 워드 및 M분리 최신 상태 저장 테이블';
-- --------------------------------------------------------
--
-- 테이블 구조 `material_properties`
--
CREATE TABLE `material_properties` (
`id` int(11) NOT NULL,
`material_code` varchar(100) DEFAULT NULL COMMENT '자재 관리 코드',
`material_name` varchar(255) NOT NULL COMMENT '자재명',
`material_type` varchar(50) NOT NULL COMMENT '자재 타입 (Yarn, Bond 등)',
`density` decimal(10,5) DEFAULT NULL COMMENT '비중 또는 밀도 (g/cm³)',
`remarks` text DEFAULT NULL COMMENT '비고',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='원자재 물성 마스터 테이블';
-- --------------------------------------------------------
--
-- 테이블 구조 `plan_assignments`
--
CREATE TABLE `plan_assignments` (
`assignment_id` int(11) NOT NULL,
`plan_id` int(11) NOT NULL,
`equipment_id` int(11) NOT NULL,
`start_date` date NOT NULL,
`end_date` date NOT NULL,
`works_on_saturday` tinyint(1) NOT NULL DEFAULT 1 COMMENT '토요일 작업 여부 (1: 작업, 0: 휴무)',
`works_on_sunday` tinyint(1) NOT NULL DEFAULT 0 COMMENT '일요일 작업 여부 (1: 작업, 0: 휴무)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `production_plans`
--
CREATE TABLE `production_plans` (
`plan_id` int(11) NOT NULL,
`plan_name` varchar(255) NOT NULL COMMENT 'Plan name (Customer + Yarn + Bond + Quantity)',
`customer_name` varchar(255) NOT NULL COMMENT 'Snapshot of customer name',
`customer_id` int(11) DEFAULT NULL COMMENT 'FK to customers table',
`yarn_name` varchar(255) NOT NULL COMMENT 'Snapshot of yarn name',
`yarn_inventory_id` int(11) DEFAULT NULL COMMENT 'FK to inventory table for yarn',
`bond_name` varchar(255) NOT NULL COMMENT 'Snapshot of bond name',
`bond_inventory_id` int(11) DEFAULT NULL COMMENT 'FK to inventory table for bond',
`requested_quantity_kg` decimal(10,2) NOT NULL COMMENT 'Requested production quantity in kg',
`start_date` date NOT NULL COMMENT 'Planned start date',
`end_date` date NOT NULL COMMENT 'Calculated end date',
`production_days` int(11) NOT NULL COMMENT 'Calculated number of days for production',
`status` varchar(50) NOT NULL DEFAULT 'Planned' COMMENT 'Status of the plan (e.g., Planned, In Progress, Completed, Cancelled)',
`yarn_diameter` float DEFAULT NULL COMMENT 'Yarn Diameter (μm)',
`yarn_k` int(11) DEFAULT NULL COMMENT 'Yarn K (number of strands)',
`ports` int(11) DEFAULT NULL COMMENT 'Number of ports used',
`cycle_time` float DEFAULT NULL COMMENT 'Cycle Time (Hz)',
`cut_length` float DEFAULT NULL COMMENT 'Cut Length (mm)',
`work_hours_day` int(11) DEFAULT NULL COMMENT 'Work hours per day',
`num_machines` int(11) DEFAULT NULL COMMENT 'Number of machines assigned',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`hourly_net_cost_per_machine` decimal(15,2) DEFAULT NULL COMMENT '설비 1대당 시간당 순가공비',
`hourly_profit_per_machine` decimal(15,2) DEFAULT NULL COMMENT '설비 1대당 시간당 이윤',
`plan_total_material_cost` decimal(15,2) DEFAULT NULL COMMENT '계획에 필요한 총 원자재 비용',
`plan_net_processing_cost` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 총 순가공비',
`plan_company_margin` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 총 기업 마진',
`plan_processing_fee` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 총 가공비',
`plan_yarn_cost` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 원사 매입비',
`plan_bond_cost` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 본드 매입비',
`plan_estimated_sales` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 예상 매출액',
`work_days_month` int(11) DEFAULT NULL COMMENT '분석에 사용된 월 작업일'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `purchase_activities`
--
CREATE TABLE `purchase_activities` (
`id` int(11) NOT NULL,
`supplier_id` int(11) NOT NULL,
`contact_id` int(11) DEFAULT NULL,
`activity_type` varchar(100) NOT NULL,
`activity_date` date NOT NULL,
`memo` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `sales_activities`
--
CREATE TABLE `sales_activities` (
`id` int(11) NOT NULL,
`customer_id` int(11) NOT NULL,
`contact_id` int(11) DEFAULT NULL,
`activity_date` date NOT NULL,
`activity_type` varchar(100) DEFAULT NULL,
`memo` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `settings`
--
CREATE TABLE `settings` (
`id` int(11) NOT NULL,
`setting_group` varchar(100) NOT NULL COMMENT '설정 그룹 (예: analysis, general)',
`setting_key` varchar(100) NOT NULL COMMENT '설정 키 (예: labor_cost)',
`setting_value` text DEFAULT NULL COMMENT '설정 값',
`setting_type` varchar(50) DEFAULT 'string' COMMENT '값의 타입 힌트 (string, integer, decimal, json)',
`description` text DEFAULT NULL COMMENT '설정에 대한 설명',
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='앱 전체 설정 관리 테이블';
-- --------------------------------------------------------
--
-- 테이블 구조 `suppliers`
--
CREATE TABLE `suppliers` (
`id` int(11) NOT NULL,
`name_kr` varchar(255) NOT NULL,
`name_en` varchar(255) DEFAULT NULL,
`business_registration_number` varchar(20) DEFAULT NULL,
`contact_number` varchar(20) DEFAULT NULL,
`address` varchar(255) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- 테이블 구조 `supplier_contacts`
--
CREATE TABLE `supplier_contacts` (
`id` int(11) NOT NULL,
`supplier_id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`phone_number` varchar(20) DEFAULT NULL,
`position` varchar(100) DEFAULT NULL,
`work_location` varchar(255) DEFAULT NULL,
`is_deleted` tinyint(1) DEFAULT 0,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- 덤프된 테이블의 인덱스
--
--
-- 테이블의 인덱스 `contacts`
--
ALTER TABLE `contacts`
ADD PRIMARY KEY (`id`),
ADD KEY `company_id` (`customer_id`);
--
-- 테이블의 인덱스 `control_target`
--
ALTER TABLE `control_target`
ADD PRIMARY KEY (`equipment_id`);
--
-- 테이블의 인덱스 `customers`
--
ALTER TABLE `customers`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `business_registration_number` (`business_registration_number`);
--
-- 테이블의 인덱스 `equipment`
--
ALTER TABLE `equipment`
ADD PRIMARY KEY (`id`);
--
-- 테이블의 인덱스 `inventory`
--
ALTER TABLE `inventory`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `item_number` (`item_number`),
ADD KEY `fk_inventory_material_properties` (`material_id`),
ADD KEY `fk_inventory_supplier` (`supplier_id`);
--
-- 테이블의 인덱스 `machine_status`
--
ALTER TABLE `machine_status`
ADD PRIMARY KEY (`equipment_id`);
--
-- 테이블의 인덱스 `material_properties`
--
ALTER TABLE `material_properties`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `material_name` (`material_name`);
--
-- 테이블의 인덱스 `plan_assignments`
--
ALTER TABLE `plan_assignments`
ADD PRIMARY KEY (`assignment_id`),
ADD KEY `plan_id` (`plan_id`),
ADD KEY `equipment_id` (`equipment_id`);
--
-- 테이블의 인덱스 `production_plans`
--
ALTER TABLE `production_plans`
ADD PRIMARY KEY (`plan_id`),
ADD KEY `fk_plan_customer` (`customer_id`),
ADD KEY `fk_plan_yarn_inventory` (`yarn_inventory_id`),
ADD KEY `fk_plan_bond_inventory` (`bond_inventory_id`);
--
-- 테이블의 인덱스 `purchase_activities`
--
ALTER TABLE `purchase_activities`
ADD PRIMARY KEY (`id`),
ADD KEY `supplier_id` (`supplier_id`),
ADD KEY `contact_id` (`contact_id`);
--
-- 테이블의 인덱스 `sales_activities`
--
ALTER TABLE `sales_activities`
ADD PRIMARY KEY (`id`),
ADD KEY `company_id` (`customer_id`),
ADD KEY `contact_id` (`contact_id`);
--
-- 테이블의 인덱스 `settings`
--
ALTER TABLE `settings`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `unique_group_key` (`setting_group`,`setting_key`);
--
-- 테이블의 인덱스 `suppliers`
--
ALTER TABLE `suppliers`
ADD PRIMARY KEY (`id`);
--
-- 테이블의 인덱스 `supplier_contacts`
--
ALTER TABLE `supplier_contacts`
ADD PRIMARY KEY (`id`),
ADD KEY `supplier_id` (`supplier_id`);
--
-- 덤프된 테이블의 AUTO_INCREMENT
--
--
-- 테이블의 AUTO_INCREMENT `contacts`
--
ALTER TABLE `contacts`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `customers`
--
ALTER TABLE `customers`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `equipment`
--
ALTER TABLE `equipment`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `inventory`
--
ALTER TABLE `inventory`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `material_properties`
--
ALTER TABLE `material_properties`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `plan_assignments`
--
ALTER TABLE `plan_assignments`
MODIFY `assignment_id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `production_plans`
--
ALTER TABLE `production_plans`
MODIFY `plan_id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `purchase_activities`
--
ALTER TABLE `purchase_activities`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `sales_activities`
--
ALTER TABLE `sales_activities`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `settings`
--
ALTER TABLE `settings`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `suppliers`
--
ALTER TABLE `suppliers`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 테이블의 AUTO_INCREMENT `supplier_contacts`
--
ALTER TABLE `supplier_contacts`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
--
-- 덤프된 테이블의 제약사항
--
--
-- 테이블의 제약사항 `contacts`
--
ALTER TABLE `contacts`
ADD CONSTRAINT `contacts_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE;
--
-- 테이블의 제약사항 `control_target`
--
ALTER TABLE `control_target`
ADD CONSTRAINT `fk_control_target_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE;
--
-- 테이블의 제약사항 `inventory`
--
ALTER TABLE `inventory`
ADD CONSTRAINT `fk_inventory_material_properties` FOREIGN KEY (`material_id`) REFERENCES `material_properties` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT `fk_inventory_supplier` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`);
--
-- 테이블의 제약사항 `machine_status`
--
ALTER TABLE `machine_status`
ADD CONSTRAINT `fk_machine_status_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE;
--
-- 테이블의 제약사항 `plan_assignments`
--
ALTER TABLE `plan_assignments`
ADD CONSTRAINT `fk_assignment_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `fk_assignment_plan` FOREIGN KEY (`plan_id`) REFERENCES `production_plans` (`plan_id`) ON DELETE CASCADE;
--
-- 테이블의 제약사항 `production_plans`
--
ALTER TABLE `production_plans`
ADD CONSTRAINT `fk_plan_bond_inventory` FOREIGN KEY (`bond_inventory_id`) REFERENCES `inventory` (`id`) ON DELETE SET NULL,
ADD CONSTRAINT `fk_plan_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE SET NULL,
ADD CONSTRAINT `fk_plan_yarn_inventory` FOREIGN KEY (`yarn_inventory_id`) REFERENCES `inventory` (`id`) ON DELETE SET NULL;
--
-- 테이블의 제약사항 `purchase_activities`
--
ALTER TABLE `purchase_activities`
ADD CONSTRAINT `purchase_activities_ibfk_1` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `purchase_activities_ibfk_2` FOREIGN KEY (`contact_id`) REFERENCES `supplier_contacts` (`id`) ON DELETE SET NULL;
--
-- 테이블의 제약사항 `sales_activities`
--
ALTER TABLE `sales_activities`
ADD CONSTRAINT `sa_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `sa_ibfk_2` FOREIGN KEY (`contact_id`) REFERENCES `contacts` (`id`) ON DELETE SET NULL;
--
-- 테이블의 제약사항 `supplier_contacts`
--
ALTER TABLE `supplier_contacts`
ADD CONSTRAINT `supplier_contacts_ibfk_1` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`) ON DELETE CASCADE;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+46
View File
@@ -0,0 +1,46 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.connection import get_db_connection
def check():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
# Check tables
cursor.execute("SHOW TABLES")
tables = cursor.fetchall()
print("Tables in database:", [list(t.values())[0] for t in tables])
# Check equipment table structure and data
cursor.execute("DESCRIBE equipment")
cols = cursor.fetchall()
print("\nEquipment columns:")
for c in cols:
print(f" {c['Field']}: {c['Type']}")
cursor.execute("SELECT * FROM equipment")
eqs = cursor.fetchall()
print(f"\nEquipment rows ({len(eqs)}):")
for eq in eqs:
print(" ", eq)
# Check machine_status table structure
cursor.execute("DESCRIBE machine_status")
cols = cursor.fetchall()
print("\nmachine_status columns:")
for c in cols:
print(f" {c['Field']}: {c['Type']}")
cursor.execute("SELECT * FROM machine_status")
statuses = cursor.fetchall()
print(f"\nmachine_status rows ({len(statuses)}):")
for s in statuses:
print(" ", s)
except Exception as e:
print("Error during check:", e)
if __name__ == "__main__":
check()
+49
View File
@@ -0,0 +1,49 @@
import mysql.connector
import json
from datetime import datetime
DB_HOST = "localhost"
DB_PORT = 3306
DB_USER = "ctnt_root"
DB_PASSWORD = "Umsang6595!!"
DB_NAME = "carbon_cutting_mc_db"
def main():
try:
conn = mysql.connector.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME
)
cur = conn.cursor(dictionary=True)
cur.execute("SELECT m_read_raw, device_state, timestamp FROM machine_status WHERE equipment_id = 8")
row = cur.fetchone()
print("\n================ [DB REAL-TIME STATUS] ================")
if row:
print(f"Device State : {row['device_state']}")
print(f"Last Update : {row['timestamp']}")
m_read_raw_str = row['m_read_raw']
print(f"Raw String : {m_read_raw_str}")
if m_read_raw_str:
m_read = json.loads(m_read_raw_str)
active_bits = [int(k) for k, v in m_read.items() if int(v) == 1]
active_bits.sort()
print(f"Active Bits (val=1): {active_bits}")
else:
print("Active Bits : No Data")
else:
print("No status row found for equipment_id = 8")
print("========================================================\n")
cur.close()
conn.close()
except Exception as e:
print(f"DB Connection or Query failed: {e}")
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
import os
def search_files(directory):
for root, dirs, files in os.walk(directory):
if "venv" in root or "__pycache__" in root or ".git" in root or ".gemini" in root:
continue
for file in files:
if file.endswith((".py", ".json", ".ini", ".conf", ".cfg", ".yml", ".yaml")):
path = os.path.join(root, file)
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
if "mysql" in content.lower() or "db_host" in content.lower() or "host =" in content.lower():
print(f"Found in {path}:")
for line in content.splitlines():
if any(x in line.lower() for x in ["host", "port", "user", "password", "db"]):
print(f" {line.strip()}")
except Exception as e:
pass
search_files("D:\\04_CTNT\\01_카본절단MC(CC)\\CC0324_양산_V0\\02_전장제어\\PLC_Communication_MQTT\\step5_modify")
+46
View File
@@ -0,0 +1,46 @@
import sys
import os
# server 디렉토리를 path에 추가하여 database 패키지 임포트 허용
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.connection import get_db_connection
def run_migration():
sql_script = """
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `machine_status`;
CREATE TABLE `machine_status` (
`equipment_id` int(11) NOT NULL,
`device_state` varchar(20) DEFAULT 'OFFLINE',
`m_area_raw` longtext DEFAULT NULL,
`cutting_motor_value` float DEFAULT 0.0,
`transfer_motor_value` float DEFAULT 0.0,
`chamber_temperature` float DEFAULT 0.0,
`d_area_raw` longtext DEFAULT NULL,
`timestamp` bigint(20) NOT NULL DEFAULT 0,
`last_updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`equipment_id`),
CONSTRAINT `fk_machine_status_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET FOREIGN_KEY_CHECKS = 1;
"""
print("[MIGRATION] Connecting to MariaDB...")
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
# 다중 쿼리 실행을 위해 세미콜론 기준으로 분할하여 실행
queries = sql_script.split(';')
for query in queries:
clean_query = query.strip()
if clean_query:
print(f"[SQL] Executing: {clean_query[:60]}...")
cursor.execute(clean_query)
conn.commit()
print("[MIGRATION] Successfully updated machine_status schema in MariaDB!")
except Exception as e:
print(f"[ERROR] Migration failed: {e}")
if __name__ == "__main__":
run_migration()
+21
View File
@@ -0,0 +1,21 @@
import mysql.connector
hosts = ["localhost", "dsm.chemifactory.com"]
ports = [3306, 3307]
for host in hosts:
for port in ports:
print(f"Trying {host}:{port}...")
try:
conn = mysql.connector.connect(
host=host,
port=port,
user="ctnt_root",
password="Umsang6595!!",
database="carbon_cutting_mc_db",
connection_timeout=3
)
print(f"SUCCESS: Connected to {host}:{port}!")
conn.close()
except Exception as e:
print(f"FAILED {host}:{port}: {e}")
+7
View File
@@ -0,0 +1,7 @@
# setting package marker
from .db_manager import (
get_all_settings,
get_setting,
update_settings
)
from .router import router
+75
View File
@@ -0,0 +1,75 @@
import mysql.connector
from database.connection import get_db_connection
import logging
# 모든 설정 조회
def get_all_settings():
settings = []
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT `id`, `setting_group`, `setting_key`, `setting_value`, `setting_type`, `description` FROM settings")
results = cursor.fetchall()
for row in results:
settings.append({
"id": row['id'],
"setting_group": row['setting_group'],
"setting_key": row['setting_key'],
"setting_value": row['setting_value'],
"setting_type": row['setting_type'],
"description": row['description']
})
return settings
except mysql.connector.Error as err:
logging.error(f"Error getting all settings: {err}")
return []
# 특정 설정 조회
def get_setting(key):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT `setting_value`, `setting_type` FROM settings WHERE `setting_key` = %s", (key,))
result = cursor.fetchone()
if result:
value = result['setting_value']
value_type = result['setting_type']
if value_type == 'integer':
return int(value)
elif value_type == 'decimal':
return float(value)
elif value_type == 'boolean':
return value.lower() in ('true', '1', 't')
else:
return value
return None
except mysql.connector.Error as err:
logging.error(f"Error getting setting for key {key}: {err}")
return None
# 설정 업데이트
def update_settings(settings_data):
try:
with get_db_connection() as conn:
with conn.cursor() as cursor:
sql = """
UPDATE settings
SET `setting_value` = %s
WHERE `setting_key` = %s
"""
values_to_update = []
for setting in settings_data:
key = setting.get('setting_key')
value = setting.get('setting_value')
if key is not None and value is not None:
values_to_update.append((str(value), key))
if values_to_update:
cursor.executemany(sql, values_to_update)
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating settings: {err}")
return False
+36
View File
@@ -0,0 +1,36 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from .db_manager import (
get_all_settings,
update_settings
)
router = APIRouter(prefix="/settings", tags=["settings"])
# --- Pydantic Schemas ---
class SettingSchema(BaseModel):
id: Optional[int] = None
setting_group: str
setting_key: str
setting_value: str
setting_type: str
description: Optional[str] = None
class SettingUpdateSchema(BaseModel):
setting_key: str
setting_value: str
# --- Settings APIs ---
@router.get("/", response_model=List[SettingSchema])
def list_settings():
return get_all_settings()
@router.put("/", response_model=dict)
def update_multiple_settings(settings: List[SettingUpdateSchema]):
# Pydantic 모델 리스트를 일반 dict 리스트로 변환하여 전달
data = [s.dict() for s in settings]
success = update_settings(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update settings")
return {"status": "success"}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
APP_DIR="/volume1/web/ChemiFactoy_MQTT_DB_Logger"
PYTHON="/volume1/web/venv/bin/python3"
PID_FILE="$APP_DIR/server.pid"
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE")
if kill -0 "$OLD_PID" 2>/dev/null; then
kill "$OLD_PID"
sleep 5
fi
fi
# 로그를 server.log 파일에 기록하여 장애 발생 시 확인 가능하도록 함
nohup "$PYTHON" "$APP_DIR/main.py" \
> "$APP_DIR/server.log" 2>&1 &
NEW_PID=$!
echo "$NEW_PID" > "$PID_FILE"
sleep 2
if kill -0 "$NEW_PID" 2>/dev/null; then
echo "Server started. PID=$NEW_PID"
else
echo "Server startup failed."
exit 1
fi
+17
View File
@@ -0,0 +1,17 @@
# supplier package marker
from .db_manager import (
add_supplier_to_db,
update_supplier_in_db,
delete_supplier_from_db,
get_all_suppliers_from_db,
get_supplier_details_from_db,
add_supplier_contact_to_db,
update_supplier_contact_in_db,
delete_supplier_contact_from_db,
add_purchase_activity_to_db,
update_purchase_activity_in_db,
delete_purchase_activity_from_db,
get_supplier_id_from_contact_id,
get_supplier_id_from_purchase_activity_id
)
from .router import router
+243
View File
@@ -0,0 +1,243 @@
import mysql.connector
from database.connection import get_db_connection
import logging
# 공급사 추가
def add_supplier_to_db(supplier_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO suppliers (name_kr, name_en, business_registration_number, contact_number, address)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
supplier_data.get('name_kr'),
supplier_data.get('name_en'),
supplier_data.get('business_registration_number'),
supplier_data.get('contact_number'),
supplier_data.get('address')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding supplier: {err}")
return None
# 공급사 수정
def update_supplier_in_db(supplier_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE suppliers SET
name_kr = %s,
name_en = %s,
business_registration_number = %s,
contact_number = %s,
address = %s
WHERE id = %s
"""
cursor.execute(sql, (
supplier_data.get('name_kr'),
supplier_data.get('name_en'),
supplier_data.get('business_registration_number'),
supplier_data.get('contact_number'),
supplier_data.get('address'),
supplier_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating supplier: {err}")
return False
# 공급사 삭제
def delete_supplier_from_db(supplier_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM purchase_activities WHERE supplier_id = %s", (supplier_id,))
cursor.execute("DELETE FROM supplier_contacts WHERE supplier_id = %s", (supplier_id,))
cursor.execute("DELETE FROM suppliers WHERE id = %s", (supplier_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting supplier: {err}")
return False
# 모든 공급사 목록 조회
def get_all_suppliers_from_db():
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT id, name_kr, name_en, business_registration_number, contact_number, address FROM suppliers")
return cursor.fetchall()
except mysql.connector.Error as err:
logging.error(f"Error getting all suppliers: {err}")
return []
# 특정 공급사 상세 정보 조회 (담당자, 구매활동 포함)
def get_supplier_details_from_db(supplier_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT * FROM suppliers WHERE id = %s", (supplier_id,))
supplier_data = cursor.fetchone()
if not supplier_data:
return None
cursor.execute("SELECT * FROM supplier_contacts WHERE supplier_id = %s", (supplier_id,))
contacts_data = cursor.fetchall()
cursor.execute("SELECT * FROM purchase_activities WHERE supplier_id = %s", (supplier_id,))
purchase_activities_data = cursor.fetchall()
return {
"supplier": supplier_data,
"contacts": contacts_data,
"purchaseActivities": purchase_activities_data
}
except mysql.connector.Error as err:
logging.error(f"Error getting supplier details: {err}")
return None
# 공급사 담당자 추가
def add_supplier_contact_to_db(contact_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO supplier_contacts (supplier_id, name, email, phone_number, position, work_location)
VALUES (%s, %s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
contact_data.get('supplier_id'),
contact_data.get('name'),
contact_data.get('email'),
contact_data.get('phone_number'),
contact_data.get('position'),
contact_data.get('work_location')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding supplier contact: {err}")
return None
# 공급사 담당자 수정
def update_supplier_contact_in_db(contact_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE supplier_contacts SET
name = %s, email = %s, phone_number = %s, position = %s, work_location = %s
WHERE id = %s
"""
cursor.execute(sql, (
contact_data.get('name'),
contact_data.get('email'),
contact_data.get('phone_number'),
contact_data.get('position'),
contact_data.get('work_location'),
contact_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating supplier contact: {err}")
return False
# 공급사 담당자 삭제
def delete_supplier_contact_from_db(contact_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM supplier_contacts WHERE id = %s", (contact_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting supplier contact: {err}")
return False
# 공급사 담당자 ID로 공급사 ID 조회
def get_supplier_id_from_contact_id(contact_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT supplier_id FROM supplier_contacts WHERE id = %s", (contact_id,))
result = cursor.fetchone()
return result['supplier_id'] if result else None
except mysql.connector.Error as err:
logging.error(f"Error getting supplier ID from contact: {err}")
return None
# 구매 활동 추가
def add_purchase_activity_to_db(activity_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
INSERT INTO purchase_activities (supplier_id, contact_id, activity_date, activity_type, memo)
VALUES (%s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
activity_data.get('supplier_id'),
activity_data.get('contact_id'),
activity_data.get('activity_date'),
activity_data.get('activity_type'),
activity_data.get('memo')
))
conn.commit()
return cursor.lastrowid
except mysql.connector.Error as err:
logging.error(f"Error adding purchase activity: {err}")
return None
# 구매 활동 수정
def update_purchase_activity_in_db(activity_data):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
sql = """
UPDATE purchase_activities SET
contact_id = %s, activity_date = %s, activity_type = %s, memo = %s
WHERE id = %s
"""
cursor.execute(sql, (
activity_data.get('contact_id'),
activity_data.get('activity_date'),
activity_data.get('activity_type'),
activity_data.get('memo'),
activity_data.get('id')
))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error updating purchase activity: {err}")
return False
# 구매 활동 삭제
def delete_purchase_activity_from_db(activity_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("DELETE FROM purchase_activities WHERE id = %s", (activity_id,))
conn.commit()
return True
except mysql.connector.Error as err:
logging.error(f"Error deleting purchase activity: {err}")
return False
# 구매 활동 ID로 공급사 ID 조회
def get_supplier_id_from_purchase_activity_id(activity_id):
try:
with get_db_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute("SELECT supplier_id FROM purchase_activities WHERE id = %s", (activity_id,))
result = cursor.fetchone()
return result['supplier_id'] if result else None
except mysql.connector.Error as err:
logging.error(f"Error getting supplier ID from purchase activity: {err}")
return None
+127
View File
@@ -0,0 +1,127 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from .db_manager import (
add_supplier_to_db,
update_supplier_in_db,
delete_supplier_from_db,
get_all_suppliers_from_db,
get_supplier_details_from_db,
add_supplier_contact_to_db,
update_supplier_contact_in_db,
delete_supplier_contact_from_db,
add_purchase_activity_to_db,
update_purchase_activity_in_db,
delete_purchase_activity_from_db
)
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
# --- Pydantic Schemas ---
class SupplierSchema(BaseModel):
id: Optional[int] = None
name_kr: str
name_en: Optional[str] = None
business_registration_number: Optional[str] = None
contact_number: Optional[str] = None
address: Optional[str] = None
class SupplierContactSchema(BaseModel):
id: Optional[int] = None
supplier_id: int
name: str
email: Optional[str] = None
phone_number: Optional[str] = None
position: Optional[str] = None
work_location: Optional[str] = None
class PurchaseActivitySchema(BaseModel):
id: Optional[int] = None
supplier_id: int
contact_id: Optional[int] = None
activity_date: str # YYYY-MM-DD
activity_type: str
memo: Optional[str] = None
# --- Suppliers APIs ---
@router.post("/", response_model=dict)
def create_supplier(supplier: SupplierSchema):
result = add_supplier_to_db(supplier.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create supplier")
return {"id": result, "status": "success"}
@router.put("/{supplier_id}", response_model=dict)
def update_supplier(supplier_id: int, supplier: SupplierSchema):
data = supplier.dict()
data['id'] = supplier_id
success = update_supplier_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update supplier")
return {"status": "success"}
@router.delete("/{supplier_id}", response_model=dict)
def delete_supplier(supplier_id: int):
success = delete_supplier_from_db(supplier_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete supplier")
return {"status": "success"}
@router.get("/", response_model=List[SupplierSchema])
def list_suppliers():
return get_all_suppliers_from_db()
@router.get("/{supplier_id}", response_model=dict)
def get_supplier_details(supplier_id: int):
details = get_supplier_details_from_db(supplier_id)
if not details:
raise HTTPException(status_code=404, detail="Supplier not found")
return details
# --- Contacts APIs ---
@router.post("/contacts", response_model=dict)
def create_contact(contact: SupplierContactSchema):
result = add_supplier_contact_to_db(contact.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create contact")
return {"id": result, "status": "success"}
@router.put("/contacts/{contact_id}", response_model=dict)
def update_contact(contact_id: int, contact: SupplierContactSchema):
data = contact.dict()
data['id'] = contact_id
success = update_supplier_contact_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update contact")
return {"status": "success"}
@router.delete("/contacts/{contact_id}", response_model=dict)
def delete_contact(contact_id: int):
success = delete_supplier_contact_from_db(contact_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete contact")
return {"status": "success"}
# --- Purchase Activities APIs ---
@router.post("/activities", response_model=dict)
def create_purchase_activity(activity: PurchaseActivitySchema):
result = add_purchase_activity_to_db(activity.dict())
if result is None:
raise HTTPException(status_code=500, detail="Failed to create purchase activity")
return {"id": result, "status": "success"}
@router.put("/activities/{activity_id}", response_model=dict)
def update_purchase_activity(activity_id: int, activity: PurchaseActivitySchema):
data = activity.dict()
data['id'] = activity_id
success = update_purchase_activity_in_db(data)
if not success:
raise HTTPException(status_code=500, detail="Failed to update purchase activity")
return {"status": "success"}
@router.delete("/activities/{activity_id}", response_model=dict)
def delete_purchase_activity(activity_id: int):
success = delete_purchase_activity_from_db(activity_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete purchase activity")
return {"status": "success"}
@@ -0,0 +1,33 @@
# This is a stub package designed to roughly emulate the _yaml
# extension module, which previously existed as a standalone module
# and has been moved into the `yaml` package namespace.
# It does not perfectly mimic its old counterpart, but should get
# close enough for anyone who's relying on it even when they shouldn't.
import yaml
# in some circumstances, the yaml module we imoprted may be from a different version, so we need
# to tread carefully when poking at it here (it may not have the attributes we expect)
if not getattr(yaml, '__with_libyaml__', False):
from sys import version_info
exc = ModuleNotFoundError if version_info >= (3, 6) else ImportError
raise exc("No module named '_yaml'")
else:
from yaml._yaml import *
import warnings
warnings.warn(
'The _yaml extension module is now located at yaml._yaml'
' and its location is subject to change. To use the'
' LibYAML-based parser and emitter, import from `yaml`:'
' `from yaml import CLoader as Loader, CDumper as Dumper`.',
DeprecationWarning
)
del warnings
# Don't `del yaml` here because yaml is actually an existing
# namespace member of _yaml.
__name__ = '_yaml'
# If the module is top-level (i.e. not a part of any specific package)
# then the attribute should be set to ''.
# https://docs.python.org/3.8/library/types.html
__package__ = ''
@@ -0,0 +1,145 @@
Metadata-Version: 2.4
Name: annotated-doc
Version: 0.0.4
Summary: Document parameters, class attributes, return types, and variables inline, with Annotated.
Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= <tiangolo@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Intended Audience :: Information Technology
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python
Classifier: Topic :: Internet
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development
Classifier: Typing :: Typed
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Project-URL: Homepage, https://github.com/fastapi/annotated-doc
Project-URL: Documentation, https://github.com/fastapi/annotated-doc
Project-URL: Repository, https://github.com/fastapi/annotated-doc
Project-URL: Issues, https://github.com/fastapi/annotated-doc/issues
Project-URL: Changelog, https://github.com/fastapi/annotated-doc/release-notes.md
Requires-Python: >=3.8
Description-Content-Type: text/markdown
# Annotated Doc
Document parameters, class attributes, return types, and variables inline, with `Annotated`.
<a href="https://github.com/fastapi/annotated-doc/actions?query=workflow%3ATest+event%3Apush+branch%3Amain" target="_blank">
<img src="https://github.com/fastapi/annotated-doc/actions/workflows/test.yml/badge.svg?event=push&branch=main" alt="Test">
</a>
<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/annotated-doc" target="_blank">
<img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/annotated-doc.svg" alt="Coverage">
</a>
<a href="https://pypi.org/project/annotated-doc" target="_blank">
<img src="https://img.shields.io/pypi/v/annotated-doc?color=%2334D058&label=pypi%20package" alt="Package version">
</a>
<a href="https://pypi.org/project/annotated-doc" target="_blank">
<img src="https://img.shields.io/pypi/pyversions/annotated-doc.svg?color=%2334D058" alt="Supported Python versions">
</a>
## Installation
```bash
pip install annotated-doc
```
Or with `uv`:
```Python
uv add annotated-doc
```
## Usage
Import `Doc` and pass a single literal string with the documentation for the specific parameter, class attribute, return type, or variable.
For example, to document a parameter `name` in a function `hi` you could do:
```Python
from typing import Annotated
from annotated_doc import Doc
def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None:
print(f"Hi, {name}!")
```
You can also use it to document class attributes:
```Python
from typing import Annotated
from annotated_doc import Doc
class User:
name: Annotated[str, Doc("The user's name")]
age: Annotated[int, Doc("The user's age")]
```
The same way, you could document return types and variables, or anything that could have a type annotation with `Annotated`.
## Who Uses This
`annotated-doc` was made for:
* [FastAPI](https://fastapi.tiangolo.com/)
* [Typer](https://typer.tiangolo.com/)
* [SQLModel](https://sqlmodel.tiangolo.com/)
* [Asyncer](https://asyncer.tiangolo.com/)
`annotated-doc` is supported by [griffe-typingdoc](https://github.com/mkdocstrings/griffe-typingdoc), which powers reference documentation like the one in the [FastAPI Reference](https://fastapi.tiangolo.com/reference/).
## Reasons not to use `annotated-doc`
You are already comfortable with one of the existing docstring formats, like:
* Sphinx
* numpydoc
* Google
* Keras
Your team is already comfortable using them.
You prefer having the documentation about parameters all together in a docstring, separated from the code defining them.
You care about a specific set of users, using one specific editor, and that editor already has support for the specific docstring format you use.
## Reasons to use `annotated-doc`
* No micro-syntax to learn for newcomers, its **just Python** syntax.
* **Editing** would be already fully supported by default by any editor (current or future) supporting Python syntax, including syntax errors, syntax highlighting, etc.
* **Rendering** would be relatively straightforward to implement by static tools (tools that don't need runtime execution), as the information can be extracted from the AST they normally already create.
* **Deduplication of information**: the name of a parameter would be defined in a single place, not duplicated inside of a docstring.
* **Elimination** of the possibility of having **inconsistencies** when removing a parameter or class variable and **forgetting to remove** its documentation.
* **Minimization** of the probability of adding a new parameter or class variable and **forgetting to add its documentation**.
* **Elimination** of the possibility of having **inconsistencies** between the **name** of a parameter in the **signature** and the name in the docstring when it is renamed.
* **Access** to the documentation string for each symbol at **runtime**, including existing (older) Python versions.
* A more formalized way to document other symbols, like type aliases, that could use Annotated.
* **Support** for apps using FastAPI, Typer and others.
* **AI Accessibility**: AI tools will have an easier way understanding each parameter as the distance from documentation to parameter is much closer.
## History
I ([@tiangolo](https://github.com/tiangolo)) originally wanted for this to be part of the Python standard library (in [PEP 727](https://peps.python.org/pep-0727/)), but the proposal was withdrawn as there was a fair amount of negative feedback and opposition.
The conclusion was that this was better done as an external effort, in a third-party library.
So, here it is, with a simpler approach, as a third-party library, in a way that can be used by others, starting with FastAPI and friends.
## License
This project is licensed under the terms of the MIT license.
@@ -0,0 +1,11 @@
annotated_doc-0.0.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
annotated_doc-0.0.4.dist-info/METADATA,sha256=Irm5KJua33dY2qKKAjJ-OhKaVBVIfwFGej_dSe3Z1TU,6566
annotated_doc-0.0.4.dist-info/RECORD,,
annotated_doc-0.0.4.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
annotated_doc-0.0.4.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
annotated_doc-0.0.4.dist-info/licenses/LICENSE,sha256=__Fwd5pqy_ZavbQFwIfxzuF4ZpHkqWpANFF-SlBKDN8,1086
annotated_doc/__init__.py,sha256=VuyxxUe80kfEyWnOrCx_Bk8hybo3aKo6RYBlkBBYW8k,52
annotated_doc/__pycache__/__init__.cpython-313.pyc,,
annotated_doc/__pycache__/main.cpython-313.pyc,,
annotated_doc/main.py,sha256=5Zfvxv80SwwLqpRW73AZyZyiM4bWma9QWRbp_cgD20s,1075
annotated_doc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: pdm-backend (2.4.5)
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,4 @@
[console_scripts]
[gui_scripts]
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2025 Sebastián Ramírez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,3 @@
from .main import Doc as Doc
__version__ = "0.0.4"
@@ -0,0 +1,36 @@
class Doc:
"""Define the documentation of a type annotation using `Annotated`, to be
used in class attributes, function and method parameters, return values,
and variables.
The value should be a positional-only string literal to allow static tools
like editors and documentation generators to use it.
This complements docstrings.
The string value passed is available in the attribute `documentation`.
Example:
```Python
from typing import Annotated
from annotated_doc import Doc
def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None:
print(f"Hi, {name}!")
```
"""
def __init__(self, documentation: str, /) -> None:
self.documentation = documentation
def __repr__(self) -> str:
return f"Doc({self.documentation!r})"
def __hash__(self) -> int:
return hash(self.documentation)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Doc):
return NotImplemented
return self.documentation == other.documentation
@@ -0,0 +1,295 @@
Metadata-Version: 2.3
Name: annotated-types
Version: 0.7.0
Summary: Reusable constraint types to use with typing.Annotated
Project-URL: Homepage, https://github.com/annotated-types/annotated-types
Project-URL: Source, https://github.com/annotated-types/annotated-types
Project-URL: Changelog, https://github.com/annotated-types/annotated-types/releases
Author-email: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Samuel Colvin <s@muelcolvin.com>, Zac Hatfield-Dodds <zac@zhd.dev>
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Unix
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.8
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.9'
Description-Content-Type: text/markdown
# annotated-types
[![CI](https://github.com/annotated-types/annotated-types/workflows/CI/badge.svg?event=push)](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
[![pypi](https://img.shields.io/pypi/v/annotated-types.svg)](https://pypi.python.org/pypi/annotated-types)
[![versions](https://img.shields.io/pypi/pyversions/annotated-types.svg)](https://github.com/annotated-types/annotated-types)
[![license](https://img.shields.io/github/license/annotated-types/annotated-types.svg)](https://github.com/annotated-types/annotated-types/blob/main/LICENSE)
[PEP-593](https://peps.python.org/pep-0593/) added `typing.Annotated` as a way of
adding context-specific metadata to existing types, and specifies that
`Annotated[T, x]` _should_ be treated as `T` by any tool or library without special
logic for `x`.
This package provides metadata objects which can be used to represent common
constraints such as upper and lower bounds on scalar values and collection sizes,
a `Predicate` marker for runtime checks, and
descriptions of how we intend these metadata to be interpreted. In some cases,
we also note alternative representations which do not require this package.
## Install
```bash
pip install annotated-types
```
## Examples
```python
from typing import Annotated
from annotated_types import Gt, Len, Predicate
class MyClass:
age: Annotated[int, Gt(18)] # Valid: 19, 20, ...
# Invalid: 17, 18, "19", 19.0, ...
factors: list[Annotated[int, Predicate(is_prime)]] # Valid: 2, 3, 5, 7, 11, ...
# Invalid: 4, 8, -2, 5.0, "prime", ...
my_list: Annotated[list[int], Len(0, 10)] # Valid: [], [10, 20, 30, 40, 50]
# Invalid: (1, 2), ["abc"], [0] * 20
```
## Documentation
_While `annotated-types` avoids runtime checks for performance, users should not
construct invalid combinations such as `MultipleOf("non-numeric")` or `Annotated[int, Len(3)]`.
Downstream implementors may choose to raise an error, emit a warning, silently ignore
a metadata item, etc., if the metadata objects described below are used with an
incompatible type - or for any other reason!_
### Gt, Ge, Lt, Le
Express inclusive and/or exclusive bounds on orderable values - which may be numbers,
dates, times, strings, sets, etc. Note that the boundary value need not be of the
same type that was annotated, so long as they can be compared: `Annotated[int, Gt(1.5)]`
is fine, for example, and implies that the value is an integer x such that `x > 1.5`.
We suggest that implementors may also interpret `functools.partial(operator.le, 1.5)`
as being equivalent to `Gt(1.5)`, for users who wish to avoid a runtime dependency on
the `annotated-types` package.
To be explicit, these types have the following meanings:
* `Gt(x)` - value must be "Greater Than" `x` - equivalent to exclusive minimum
* `Ge(x)` - value must be "Greater than or Equal" to `x` - equivalent to inclusive minimum
* `Lt(x)` - value must be "Less Than" `x` - equivalent to exclusive maximum
* `Le(x)` - value must be "Less than or Equal" to `x` - equivalent to inclusive maximum
### Interval
`Interval(gt, ge, lt, le)` allows you to specify an upper and lower bound with a single
metadata object. `None` attributes should be ignored, and non-`None` attributes
treated as per the single bounds above.
### MultipleOf
`MultipleOf(multiple_of=x)` might be interpreted in two ways:
1. Python semantics, implying `value % multiple_of == 0`, or
2. [JSONschema semantics](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.1),
where `int(value / multiple_of) == value / multiple_of`.
We encourage users to be aware of these two common interpretations and their
distinct behaviours, especially since very large or non-integer numbers make
it easy to cause silent data corruption due to floating-point imprecision.
We encourage libraries to carefully document which interpretation they implement.
### MinLen, MaxLen, Len
`Len()` implies that `min_length <= len(value) <= max_length` - lower and upper bounds are inclusive.
As well as `Len()` which can optionally include upper and lower bounds, we also
provide `MinLen(x)` and `MaxLen(y)` which are equivalent to `Len(min_length=x)`
and `Len(max_length=y)` respectively.
`Len`, `MinLen`, and `MaxLen` may be used with any type which supports `len(value)`.
Examples of usage:
* `Annotated[list, MaxLen(10)]` (or `Annotated[list, Len(max_length=10))`) - list must have a length of 10 or less
* `Annotated[str, MaxLen(10)]` - string must have a length of 10 or less
* `Annotated[list, MinLen(3))` (or `Annotated[list, Len(min_length=3))`) - list must have a length of 3 or more
* `Annotated[list, Len(4, 6)]` - list must have a length of 4, 5, or 6
* `Annotated[list, Len(8, 8)]` - list must have a length of exactly 8
#### Changed in v0.4.0
* `min_inclusive` has been renamed to `min_length`, no change in meaning
* `max_exclusive` has been renamed to `max_length`, upper bound is now **inclusive** instead of **exclusive**
* The recommendation that slices are interpreted as `Len` has been removed due to ambiguity and different semantic
meaning of the upper bound in slices vs. `Len`
See [issue #23](https://github.com/annotated-types/annotated-types/issues/23) for discussion.
### Timezone
`Timezone` can be used with a `datetime` or a `time` to express which timezones
are allowed. `Annotated[datetime, Timezone(None)]` must be a naive datetime.
`Timezone[...]` ([literal ellipsis](https://docs.python.org/3/library/constants.html#Ellipsis))
expresses that any timezone-aware datetime is allowed. You may also pass a specific
timezone string or [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects)
object such as `Timezone(timezone.utc)` or `Timezone("Africa/Abidjan")` to express that you only
allow a specific timezone, though we note that this is often a symptom of fragile design.
#### Changed in v0.x.x
* `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of
`timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries.
### Unit
`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of
a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]`
would be a float representing a velocity in meters per second.
Please note that `annotated_types` itself makes no attempt to parse or validate
the unit string in any way. That is left entirely to downstream libraries,
such as [`pint`](https://pint.readthedocs.io) or
[`astropy.units`](https://docs.astropy.org/en/stable/units/).
An example of how a library might use this metadata:
```python
from annotated_types import Unit
from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args
# given a type annotated with a unit:
Meters = Annotated[float, Unit("m")]
# you can cast the annotation to a specific unit type with any
# callable that accepts a string and returns the desired type
T = TypeVar("T")
def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None:
if get_origin(tp) is Annotated:
for arg in get_args(tp):
if isinstance(arg, Unit):
return unit_cls(arg.unit)
return None
# using `pint`
import pint
pint_unit = cast_unit(Meters, pint.Unit)
# using `astropy.units`
import astropy.units as u
astropy_unit = cast_unit(Meters, u.Unit)
```
### Predicate
`Predicate(func: Callable)` expresses that `func(value)` is truthy for valid values.
Users should prefer the statically inspectable metadata above, but if you need
the full power and flexibility of arbitrary runtime predicates... here it is.
For some common constraints, we provide generic types:
* `IsLower = Annotated[T, Predicate(str.islower)]`
* `IsUpper = Annotated[T, Predicate(str.isupper)]`
* `IsDigit = Annotated[T, Predicate(str.isdigit)]`
* `IsFinite = Annotated[T, Predicate(math.isfinite)]`
* `IsNotFinite = Annotated[T, Predicate(Not(math.isfinite))]`
* `IsNan = Annotated[T, Predicate(math.isnan)]`
* `IsNotNan = Annotated[T, Predicate(Not(math.isnan))]`
* `IsInfinite = Annotated[T, Predicate(math.isinf)]`
* `IsNotInfinite = Annotated[T, Predicate(Not(math.isinf))]`
so that you can write e.g. `x: IsFinite[float] = 2.0` instead of the longer
(but exactly equivalent) `x: Annotated[float, Predicate(math.isfinite)] = 2.0`.
Some libraries might have special logic to handle known or understandable predicates,
for example by checking for `str.isdigit` and using its presence to both call custom
logic to enforce digit-only strings, and customise some generated external schema.
Users are therefore encouraged to avoid indirection like `lambda s: s.lower()`, in
favor of introspectable methods such as `str.lower` or `re.compile("pattern").search`.
To enable basic negation of commonly used predicates like `math.isnan` without introducing introspection that makes it impossible for implementers to introspect the predicate we provide a `Not` wrapper that simply negates the predicate in an introspectable manner. Several of the predicates listed above are created in this manner.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting
`TypeError: descriptor 'isdigit' for 'str' objects doesn't apply to a 'int' object`
exception. We encourage libraries to document the behaviour they choose.
### Doc
`doc()` can be used to add documentation information in `Annotated`, for function and method parameters, variables, class attributes, return types, and any place where `Annotated` can be used.
It expects a value that can be statically analyzed, as the main use case is for static analysis, editors, documentation generators, and similar tools.
It returns a `DocInfo` class with a single attribute `documentation` containing the value passed to `doc()`.
This is the early adopter's alternative form of the [`typing-doc` proposal](https://github.com/tiangolo/fastapi/blob/typing-doc/typing_doc.md).
### Integrating downstream types with `GroupedMetadata`
Implementers may choose to provide a convenience wrapper that groups multiple pieces of metadata.
This can help reduce verbosity and cognitive overhead for users.
For example, an implementer like Pydantic might provide a `Field` or `Meta` type that accepts keyword arguments and transforms these into low-level metadata:
```python
from dataclasses import dataclass
from typing import Iterator
from annotated_types import GroupedMetadata, Ge
@dataclass
class Field(GroupedMetadata):
ge: int | None = None
description: str | None = None
def __iter__(self) -> Iterator[object]:
# Iterating over a GroupedMetadata object should yield annotated-types
# constraint metadata objects which describe it as fully as possible,
# and may include other unknown objects too.
if self.ge is not None:
yield Ge(self.ge)
if self.description is not None:
yield Description(self.description)
```
Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently.
Libraries consuming annotated-types should also ignore any metadata they do not recongize that came from unpacking a `GroupedMetadata`, just like they ignore unrecognized metadata in `Annotated` itself.
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. Similarly, `annotated_types.Len` is a `GroupedMetadata` which unpacks itself into `MinLen` (optionally) and `MaxLen`.
### Consuming metadata
We intend to not be prescriptive as to _how_ the metadata and constraints are used, but as an example of how one might parse constraints from types annotations see our [implementation in `test_main.py`](https://github.com/annotated-types/annotated-types/blob/f59cf6d1b5255a0fe359b93896759a180bec30ae/tests/test_main.py#L94-L103).
It is up to the implementer to determine how this metadata is used.
You could use the metadata for runtime type checking, for generating schemas or to generate example data, amongst other use cases.
## Design & History
This package was designed at the PyCon 2022 sprints by the maintainers of Pydantic
and Hypothesis, with the goal of making it as easy as possible for end-users to
provide more informative annotations for use by runtime libraries.
It is deliberately minimal, and following PEP-593 allows considerable downstream
discretion in what (if anything!) they choose to support. Nonetheless, we expect
that staying simple and covering _only_ the most common use-cases will give users
and maintainers the best experience we can. If you'd like more constraints for your
types - follow our lead, by defining them and documenting them downstream!
@@ -0,0 +1,10 @@
annotated_types-0.7.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
annotated_types-0.7.0.dist-info/METADATA,sha256=7ltqxksJJ0wCYFGBNIQCWTlWQGeAH0hRFdnK3CB895E,15046
annotated_types-0.7.0.dist-info/RECORD,,
annotated_types-0.7.0.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
annotated_types-0.7.0.dist-info/licenses/LICENSE,sha256=_hBJiEsaDZNCkB6I4H8ykl0ksxIdmXK2poBfuYJLCV0,1083
annotated_types/__init__.py,sha256=RynLsRKUEGI0KimXydlD1fZEfEzWwDo0Uon3zOKhG1Q,13819
annotated_types/__pycache__/__init__.cpython-313.pyc,,
annotated_types/__pycache__/test_cases.cpython-313.pyc,,
annotated_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
annotated_types/test_cases.py,sha256=zHFX6EpcMbGJ8FzBYDbO56bPwx_DYIVSKbZM-4B3_lg,6421
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: hatchling 1.24.2
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 the contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,432 @@
import math
import sys
import types
from dataclasses import dataclass
from datetime import tzinfo
from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, SupportsFloat, SupportsIndex, TypeVar, Union
if sys.version_info < (3, 8):
from typing_extensions import Protocol, runtime_checkable
else:
from typing import Protocol, runtime_checkable
if sys.version_info < (3, 9):
from typing_extensions import Annotated, Literal
else:
from typing import Annotated, Literal
if sys.version_info < (3, 10):
EllipsisType = type(Ellipsis)
KW_ONLY = {}
SLOTS = {}
else:
from types import EllipsisType
KW_ONLY = {"kw_only": True}
SLOTS = {"slots": True}
__all__ = (
'BaseMetadata',
'GroupedMetadata',
'Gt',
'Ge',
'Lt',
'Le',
'Interval',
'MultipleOf',
'MinLen',
'MaxLen',
'Len',
'Timezone',
'Predicate',
'LowerCase',
'UpperCase',
'IsDigits',
'IsFinite',
'IsNotFinite',
'IsNan',
'IsNotNan',
'IsInfinite',
'IsNotInfinite',
'doc',
'DocInfo',
'__version__',
)
__version__ = '0.7.0'
T = TypeVar('T')
# arguments that start with __ are considered
# positional only
# see https://peps.python.org/pep-0484/#positional-only-arguments
class SupportsGt(Protocol):
def __gt__(self: T, __other: T) -> bool:
...
class SupportsGe(Protocol):
def __ge__(self: T, __other: T) -> bool:
...
class SupportsLt(Protocol):
def __lt__(self: T, __other: T) -> bool:
...
class SupportsLe(Protocol):
def __le__(self: T, __other: T) -> bool:
...
class SupportsMod(Protocol):
def __mod__(self: T, __other: T) -> T:
...
class SupportsDiv(Protocol):
def __div__(self: T, __other: T) -> T:
...
class BaseMetadata:
"""Base class for all metadata.
This exists mainly so that implementers
can do `isinstance(..., BaseMetadata)` while traversing field annotations.
"""
__slots__ = ()
@dataclass(frozen=True, **SLOTS)
class Gt(BaseMetadata):
"""Gt(gt=x) implies that the value must be greater than x.
It can be used with any type that supports the ``>`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
gt: SupportsGt
@dataclass(frozen=True, **SLOTS)
class Ge(BaseMetadata):
"""Ge(ge=x) implies that the value must be greater than or equal to x.
It can be used with any type that supports the ``>=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
ge: SupportsGe
@dataclass(frozen=True, **SLOTS)
class Lt(BaseMetadata):
"""Lt(lt=x) implies that the value must be less than x.
It can be used with any type that supports the ``<`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
lt: SupportsLt
@dataclass(frozen=True, **SLOTS)
class Le(BaseMetadata):
"""Le(le=x) implies that the value must be less than or equal to x.
It can be used with any type that supports the ``<=`` operator,
including numbers, dates and times, strings, sets, and so on.
"""
le: SupportsLe
@runtime_checkable
class GroupedMetadata(Protocol):
"""A grouping of multiple objects, like typing.Unpack.
`GroupedMetadata` on its own is not metadata and has no meaning.
All of the constraints and metadata should be fully expressable
in terms of the `BaseMetadata`'s returned by `GroupedMetadata.__iter__()`.
Concrete implementations should override `GroupedMetadata.__iter__()`
to add their own metadata.
For example:
>>> @dataclass
>>> class Field(GroupedMetadata):
>>> gt: float | None = None
>>> description: str | None = None
...
>>> def __iter__(self) -> Iterable[object]:
>>> if self.gt is not None:
>>> yield Gt(self.gt)
>>> if self.description is not None:
>>> yield Description(self.gt)
Also see the implementation of `Interval` below for an example.
Parsers should recognize this and unpack it so that it can be used
both with and without unpacking:
- `Annotated[int, Field(...)]` (parser must unpack Field)
- `Annotated[int, *Field(...)]` (PEP-646)
""" # noqa: trailing-whitespace
@property
def __is_annotated_types_grouped_metadata__(self) -> Literal[True]:
return True
def __iter__(self) -> Iterator[object]:
...
if not TYPE_CHECKING:
__slots__ = () # allow subclasses to use slots
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
# Basic ABC like functionality without the complexity of an ABC
super().__init_subclass__(*args, **kwargs)
if cls.__iter__ is GroupedMetadata.__iter__:
raise TypeError("Can't subclass GroupedMetadata without implementing __iter__")
def __iter__(self) -> Iterator[object]: # noqa: F811
raise NotImplementedError # more helpful than "None has no attribute..." type errors
@dataclass(frozen=True, **KW_ONLY, **SLOTS)
class Interval(GroupedMetadata):
"""Interval can express inclusive or exclusive bounds with a single object.
It accepts keyword arguments ``gt``, ``ge``, ``lt``, and/or ``le``, which
are interpreted the same way as the single-bound constraints.
"""
gt: Union[SupportsGt, None] = None
ge: Union[SupportsGe, None] = None
lt: Union[SupportsLt, None] = None
le: Union[SupportsLe, None] = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack an Interval into zero or more single-bounds."""
if self.gt is not None:
yield Gt(self.gt)
if self.ge is not None:
yield Ge(self.ge)
if self.lt is not None:
yield Lt(self.lt)
if self.le is not None:
yield Le(self.le)
@dataclass(frozen=True, **SLOTS)
class MultipleOf(BaseMetadata):
"""MultipleOf(multiple_of=x) might be interpreted in two ways:
1. Python semantics, implying ``value % multiple_of == 0``, or
2. JSONschema semantics, where ``int(value / multiple_of) == value / multiple_of``
We encourage users to be aware of these two common interpretations,
and libraries to carefully document which they implement.
"""
multiple_of: Union[SupportsDiv, SupportsMod]
@dataclass(frozen=True, **SLOTS)
class MinLen(BaseMetadata):
"""
MinLen() implies minimum inclusive length,
e.g. ``len(value) >= min_length``.
"""
min_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, **SLOTS)
class MaxLen(BaseMetadata):
"""
MaxLen() implies maximum inclusive length,
e.g. ``len(value) <= max_length``.
"""
max_length: Annotated[int, Ge(0)]
@dataclass(frozen=True, **SLOTS)
class Len(GroupedMetadata):
"""
Len() implies that ``min_length <= len(value) <= max_length``.
Upper bound may be omitted or ``None`` to indicate no upper length bound.
"""
min_length: Annotated[int, Ge(0)] = 0
max_length: Optional[Annotated[int, Ge(0)]] = None
def __iter__(self) -> Iterator[BaseMetadata]:
"""Unpack a Len into zone or more single-bounds."""
if self.min_length > 0:
yield MinLen(self.min_length)
if self.max_length is not None:
yield MaxLen(self.max_length)
@dataclass(frozen=True, **SLOTS)
class Timezone(BaseMetadata):
"""Timezone(tz=...) requires a datetime to be aware (or ``tz=None``, naive).
``Annotated[datetime, Timezone(None)]`` must be a naive datetime.
``Timezone[...]`` (the ellipsis literal) expresses that the datetime must be
tz-aware but any timezone is allowed.
You may also pass a specific timezone string or tzinfo object such as
``Timezone(timezone.utc)`` or ``Timezone("Africa/Abidjan")`` to express that
you only allow a specific timezone, though we note that this is often
a symptom of poor design.
"""
tz: Union[str, tzinfo, EllipsisType, None]
@dataclass(frozen=True, **SLOTS)
class Unit(BaseMetadata):
"""Indicates that the value is a physical quantity with the specified unit.
It is intended for usage with numeric types, where the value represents the
magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]``
or ``speed: Annotated[float, Unit('m/s')]``.
Interpretation of the unit string is left to the discretion of the consumer.
It is suggested to follow conventions established by python libraries that work
with physical quantities, such as
- ``pint`` : <https://pint.readthedocs.io/en/stable/>
- ``astropy.units``: <https://docs.astropy.org/en/stable/units/>
For indicating a quantity with a certain dimensionality but without a specific unit
it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`.
Note, however, ``annotated_types`` itself makes no use of the unit string.
"""
unit: str
@dataclass(frozen=True, **SLOTS)
class Predicate(BaseMetadata):
"""``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.
Users should prefer statically inspectable metadata, but if you need the full
power and flexibility of arbitrary runtime predicates... here it is.
We provide a few predefined predicates for common string constraints:
``IsLower = Predicate(str.islower)``, ``IsUpper = Predicate(str.isupper)``, and
``IsDigits = Predicate(str.isdigit)``. Users are encouraged to use methods which
can be given special handling, and avoid indirection like ``lambda s: s.lower()``.
Some libraries might have special logic to handle certain predicates, e.g. by
checking for `str.isdigit` and using its presence to both call custom logic to
enforce digit-only strings, and customise some generated external schema.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting exception.
"""
func: Callable[[Any], bool]
def __repr__(self) -> str:
if getattr(self.func, "__name__", "<lambda>") == "<lambda>":
return f"{self.__class__.__name__}({self.func!r})"
if isinstance(self.func, (types.MethodType, types.BuiltinMethodType)) and (
namespace := getattr(self.func.__self__, "__name__", None)
):
return f"{self.__class__.__name__}({namespace}.{self.func.__name__})"
if isinstance(self.func, type(str.isascii)): # method descriptor
return f"{self.__class__.__name__}({self.func.__qualname__})"
return f"{self.__class__.__name__}({self.func.__name__})"
@dataclass
class Not:
func: Callable[[Any], bool]
def __call__(self, __v: Any) -> bool:
return not self.func(__v)
_StrType = TypeVar("_StrType", bound=str)
LowerCase = Annotated[_StrType, Predicate(str.islower)]
"""
Return True if the string is a lowercase string, False otherwise.
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
""" # noqa: E501
UpperCase = Annotated[_StrType, Predicate(str.isupper)]
"""
Return True if the string is an uppercase string, False otherwise.
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
""" # noqa: E501
IsDigit = Annotated[_StrType, Predicate(str.isdigit)]
IsDigits = IsDigit # type: ignore # plural for backwards compatibility, see #63
"""
Return True if the string is a digit string, False otherwise.
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
""" # noqa: E501
IsAscii = Annotated[_StrType, Predicate(str.isascii)]
"""
Return True if all characters in the string are ASCII, False otherwise.
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
"""
_NumericType = TypeVar('_NumericType', bound=Union[SupportsFloat, SupportsIndex])
IsFinite = Annotated[_NumericType, Predicate(math.isfinite)]
"""Return True if x is neither an infinity nor a NaN, and False otherwise."""
IsNotFinite = Annotated[_NumericType, Predicate(Not(math.isfinite))]
"""Return True if x is one of infinity or NaN, and False otherwise"""
IsNan = Annotated[_NumericType, Predicate(math.isnan)]
"""Return True if x is a NaN (not a number), and False otherwise."""
IsNotNan = Annotated[_NumericType, Predicate(Not(math.isnan))]
"""Return True if x is anything but NaN (not a number), and False otherwise."""
IsInfinite = Annotated[_NumericType, Predicate(math.isinf)]
"""Return True if x is a positive or negative infinity, and False otherwise."""
IsNotInfinite = Annotated[_NumericType, Predicate(Not(math.isinf))]
"""Return True if x is neither a positive or negative infinity, and False otherwise."""
try:
from typing_extensions import DocInfo, doc # type: ignore [attr-defined]
except ImportError:
@dataclass(frozen=True, **SLOTS)
class DocInfo: # type: ignore [no-redef]
""" "
The return value of doc(), mainly to be used by tools that want to extract the
Annotated documentation at runtime.
"""
documentation: str
"""The documentation string passed to doc()."""
def doc(
documentation: str,
) -> DocInfo:
"""
Add documentation to a type annotation inside of Annotated.
For example:
>>> def hi(name: Annotated[int, doc("The name of the user")]) -> None: ...
"""
return DocInfo(documentation)
@@ -0,0 +1,151 @@
import math
import sys
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Set, Tuple
if sys.version_info < (3, 9):
from typing_extensions import Annotated
else:
from typing import Annotated
import annotated_types as at
class Case(NamedTuple):
"""
A test case for `annotated_types`.
"""
annotation: Any
valid_cases: Iterable[Any]
invalid_cases: Iterable[Any]
def cases() -> Iterable[Case]:
# Gt, Ge, Lt, Le
yield Case(Annotated[int, at.Gt(4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[float, at.Gt(0.5)], (0.6, 0.7, 0.8, 0.9), (0.5, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Gt(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(date(2000, 1, 1))],
[date(2000, 1, 2), date(2000, 1, 3)],
[date(2000, 1, 1), date(1999, 12, 31)],
)
yield Case(
Annotated[datetime, at.Gt(Decimal('1.123'))],
[Decimal('1.1231'), Decimal('123')],
[Decimal('1.123'), Decimal('0')],
)
yield Case(Annotated[int, at.Ge(4)], (4, 5, 6, 1000, 4), (0, -1))
yield Case(Annotated[float, at.Ge(0.5)], (0.5, 0.6, 0.7, 0.8, 0.9), (0.4, 0.0, -0.1))
yield Case(
Annotated[datetime, at.Ge(datetime(2000, 1, 1))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(1998, 1, 1), datetime(1999, 12, 31)],
)
yield Case(Annotated[int, at.Lt(4)], (0, -1), (4, 5, 6, 1000, 4))
yield Case(Annotated[float, at.Lt(0.5)], (0.4, 0.0, -0.1), (0.5, 0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Lt(datetime(2000, 1, 1))],
[datetime(1999, 12, 31), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
yield Case(Annotated[int, at.Le(4)], (4, 0, -1), (5, 6, 1000))
yield Case(Annotated[float, at.Le(0.5)], (0.5, 0.0, -0.1), (0.6, 0.7, 0.8, 0.9))
yield Case(
Annotated[datetime, at.Le(datetime(2000, 1, 1))],
[datetime(2000, 1, 1), datetime(1999, 12, 31)],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
)
# Interval
yield Case(Annotated[int, at.Interval(gt=4)], (5, 6, 1000), (4, 0, -1))
yield Case(Annotated[int, at.Interval(gt=4, lt=10)], (5, 6), (4, 10, 1000, 0, -1))
yield Case(Annotated[float, at.Interval(ge=0.5, le=1)], (0.5, 0.9, 1), (0.49, 1.1))
yield Case(
Annotated[datetime, at.Interval(gt=datetime(2000, 1, 1), le=datetime(2000, 1, 3))],
[datetime(2000, 1, 2), datetime(2000, 1, 3)],
[datetime(2000, 1, 1), datetime(2000, 1, 4)],
)
yield Case(Annotated[int, at.MultipleOf(multiple_of=3)], (0, 3, 9), (1, 2, 4))
yield Case(Annotated[float, at.MultipleOf(multiple_of=0.5)], (0, 0.5, 1, 1.5), (0.4, 1.1))
# lengths
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
yield Case(Annotated[List[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[List[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
yield Case(Annotated[str, at.MaxLen(4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[str, at.Len(0, 4)], ('', '1234'), ('12345', 'x' * 10))
yield Case(Annotated[List[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[List[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 5, ['b'] * 10))
yield Case(Annotated[str, at.Len(3, 5)], ('123', '12345'), ('', '1', '12', '123456', 'x' * 10))
yield Case(Annotated[str, at.Len(3, 3)], ('123',), ('12', '1234'))
yield Case(Annotated[Dict[int, int], at.Len(2, 3)], [{1: 1, 2: 2}], [{}, {1: 1}, {1: 1, 2: 2, 3: 3, 4: 4}])
yield Case(Annotated[Set[int], at.Len(2, 3)], ({1, 2}, {1, 2, 3}), (set(), {1}, {1, 2, 3, 4}))
yield Case(Annotated[Tuple[int, ...], at.Len(2, 3)], ((1, 2), (1, 2, 3)), ((), (1,), (1, 2, 3, 4)))
# Timezone
yield Case(
Annotated[datetime, at.Timezone(None)], [datetime(2000, 1, 1)], [datetime(2000, 1, 1, tzinfo=timezone.utc)]
)
yield Case(
Annotated[datetime, at.Timezone(...)], [datetime(2000, 1, 1, tzinfo=timezone.utc)], [datetime(2000, 1, 1)]
)
yield Case(
Annotated[datetime, at.Timezone(timezone.utc)],
[datetime(2000, 1, 1, tzinfo=timezone.utc)],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
yield Case(
Annotated[datetime, at.Timezone('Europe/London')],
[datetime(2000, 1, 1, tzinfo=timezone(timedelta(0), name='Europe/London'))],
[datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
)
# Quantity
yield Case(Annotated[float, at.Unit(unit='m')], (5, 4.2), ('5m', '4.2m'))
# predicate types
yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
yield Case(at.UpperCase[str], ['ABC', 'DEFO'], ['', 'a', 'abc', 'AbC'])
yield Case(at.IsDigit[str], ['123'], ['', 'ab', 'a1b2'])
yield Case(at.IsAscii[str], ['123', 'foo bar'], ['£100', '😊', 'whatever 👀'])
yield Case(Annotated[int, at.Predicate(lambda x: x % 2 == 0)], [0, 2, 4], [1, 3, 5])
yield Case(at.IsFinite[float], [1.23], [math.nan, math.inf, -math.inf])
yield Case(at.IsNotFinite[float], [math.nan, math.inf], [1.23])
yield Case(at.IsNan[float], [math.nan], [1.23, math.inf])
yield Case(at.IsNotNan[float], [1.23, math.inf], [math.nan])
yield Case(at.IsInfinite[float], [math.inf], [math.nan, 1.23])
yield Case(at.IsNotInfinite[float], [math.nan, 1.23], [math.inf])
# check stacked predicates
yield Case(at.IsInfinite[Annotated[float, at.Predicate(lambda x: x > 0)]], [math.inf], [-math.inf, 1.23, math.nan])
# doc
yield Case(Annotated[int, at.doc("A number")], [1, 2], [])
# custom GroupedMetadata
class MyCustomGroupedMetadata(at.GroupedMetadata):
def __iter__(self) -> Iterator[at.Predicate]:
yield at.Predicate(lambda x: float(x).is_integer())
yield Case(Annotated[float, MyCustomGroupedMetadata()], [0, 2.0], [0.01, 1.5])
@@ -0,0 +1 @@
pip
@@ -0,0 +1,107 @@
Metadata-Version: 2.4
Name: anyio
Version: 4.14.0
Summary: High-level concurrency and networking framework on top of asyncio or Trio
Author-email: Alex Grönholm <alex.gronholm@nextday.fi>
License-Expression: MIT
Project-URL: Documentation, https://anyio.readthedocs.io/en/latest/
Project-URL: Changelog, https://anyio.readthedocs.io/en/stable/versionhistory.html
Project-URL: Source code, https://github.com/agronholm/anyio
Project-URL: Issue tracker, https://github.com/agronholm/anyio/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Framework :: AnyIO
Classifier: Typing :: Typed
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: exceptiongroup>=1.0.2; python_version < "3.11"
Requires-Dist: idna>=2.8
Requires-Dist: typing_extensions>=4.5; python_version < "3.13"
Provides-Extra: trio
Requires-Dist: trio>=0.32.0; extra == "trio"
Dynamic: license-file
.. image:: https://github.com/agronholm/anyio/actions/workflows/test.yml/badge.svg
:target: https://github.com/agronholm/anyio/actions/workflows/test.yml
:alt: Build Status
.. image:: https://coveralls.io/repos/github/agronholm/anyio/badge.svg?branch=master
:target: https://coveralls.io/github/agronholm/anyio?branch=master
:alt: Code Coverage
.. image:: https://readthedocs.org/projects/anyio/badge/?version=latest
:target: https://anyio.readthedocs.io/en/latest/?badge=latest
:alt: Documentation
.. image:: https://badges.gitter.im/gitterHQ/gitter.svg
:target: https://gitter.im/python-trio/AnyIO
:alt: Gitter chat
.. image:: https://tidelift.com/badges/package/pypi/anyio
:target: https://tidelift.com/subscription/pkg/pypi-anyio
:alt: Tidelift
AnyIO is an asynchronous networking and concurrency library that works on top of either asyncio_ or
Trio_. It implements Trio-like `structured concurrency`_ (SC) on top of asyncio and works in harmony
with the native SC of Trio itself.
Applications and libraries written against AnyIO's API will run unmodified on either asyncio_ or
Trio_. AnyIO can also be adopted into a library or application incrementally bit by bit, no full
refactoring necessary. It will blend in with the native libraries of your chosen backend.
To find out why you might want to use AnyIO's APIs instead of asyncio's, you can read about it
`here <https://anyio.readthedocs.io/en/stable/why.html>`_.
Documentation
-------------
View full documentation at: https://anyio.readthedocs.io/
Features
--------
AnyIO offers the following functionality:
* Task groups (nurseries_ in trio terminology)
* High-level networking (TCP, UDP and UNIX sockets)
* `Happy eyeballs`_ algorithm for TCP connections (more robust than that of asyncio on Python
3.8)
* async/await style UDP sockets (unlike asyncio where you still have to use Transports and
Protocols)
* A versatile API for byte streams and object streams
* Inter-task synchronization and communication (locks, conditions, events, semaphores, object
streams)
* Worker threads
* Subprocesses
* Subinterpreter support for code parallelization (on Python 3.13 and later)
* Asynchronous file I/O (using worker threads)
* Signal handling
* Asynchronous versions of the functools_ and itertools_ modules
AnyIO also comes with its own pytest_ plugin which also supports asynchronous fixtures.
It even works with the popular Hypothesis_ library.
.. _asyncio: https://docs.python.org/3/library/asyncio.html
.. _Trio: https://github.com/python-trio/trio
.. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency
.. _nurseries: https://trio.readthedocs.io/en/stable/reference-core.html#nurseries-and-spawning
.. _Happy eyeballs: https://en.wikipedia.org/wiki/Happy_Eyeballs
.. _pytest: https://docs.pytest.org/en/latest/
.. _functools: https://docs.python.org/3/library/functools.html
.. _itertools: https://docs.python.org/3/library/itertools.html
.. _Hypothesis: https://hypothesis.works/
Security contact information
----------------------------
To report a security vulnerability, please use the `Tidelift security contact`_.
Tidelift will coordinate the fix and disclosure.
.. _Tidelift security contact: https://tidelift.com/security
@@ -0,0 +1,94 @@
anyio-4.14.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
anyio-4.14.0.dist-info/METADATA,sha256=ZozV8u8z7Ya7tmsk9Uetxz7-QFNpHH2nVBLy041h6xo,4645
anyio-4.14.0.dist-info/RECORD,,
anyio-4.14.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
anyio-4.14.0.dist-info/entry_points.txt,sha256=_d6Yu6uiaZmNe0CydowirE9Cmg7zUL2g08tQpoS3Qvc,39
anyio-4.14.0.dist-info/licenses/LICENSE,sha256=U2GsncWPLvX9LpsJxoKXwX8ElQkJu8gCO9uC6s8iwrA,1081
anyio-4.14.0.dist-info/top_level.txt,sha256=QglSMiWX8_5dpoVAEIHdEYzvqFMdSYWmCj6tYw2ITkQ,6
anyio/__init__.py,sha256=HitUIfzvAojSeaHVmJ9rFn8k_yI63G6s_jUL2QChf4U,6405
anyio/__pycache__/__init__.cpython-313.pyc,,
anyio/__pycache__/from_thread.cpython-313.pyc,,
anyio/__pycache__/functools.cpython-313.pyc,,
anyio/__pycache__/itertools.cpython-313.pyc,,
anyio/__pycache__/lowlevel.cpython-313.pyc,,
anyio/__pycache__/pytest_plugin.cpython-313.pyc,,
anyio/__pycache__/to_interpreter.cpython-313.pyc,,
anyio/__pycache__/to_process.cpython-313.pyc,,
anyio/__pycache__/to_thread.cpython-313.pyc,,
anyio/_backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/_backends/__pycache__/__init__.cpython-313.pyc,,
anyio/_backends/__pycache__/_asyncio.cpython-313.pyc,,
anyio/_backends/__pycache__/_trio.cpython-313.pyc,,
anyio/_backends/_asyncio.py,sha256=kX_Zv3F_SnWqWqSLmfPsDFxvsJLaATSeivzg6Gho3Rs,101971
anyio/_backends/_trio.py,sha256=vR0ZgxVnOo4AHhHcHVG0worMc-3ZNpAZ6Vxh0m0ZZC0,45189
anyio/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/_core/__pycache__/__init__.cpython-313.pyc,,
anyio/_core/__pycache__/_asyncio_selector_thread.cpython-313.pyc,,
anyio/_core/__pycache__/_contextmanagers.cpython-313.pyc,,
anyio/_core/__pycache__/_eventloop.cpython-313.pyc,,
anyio/_core/__pycache__/_exceptions.cpython-313.pyc,,
anyio/_core/__pycache__/_fileio.cpython-313.pyc,,
anyio/_core/__pycache__/_resources.cpython-313.pyc,,
anyio/_core/__pycache__/_signals.cpython-313.pyc,,
anyio/_core/__pycache__/_sockets.cpython-313.pyc,,
anyio/_core/__pycache__/_streams.cpython-313.pyc,,
anyio/_core/__pycache__/_subprocesses.cpython-313.pyc,,
anyio/_core/__pycache__/_synchronization.cpython-313.pyc,,
anyio/_core/__pycache__/_tasks.cpython-313.pyc,,
anyio/_core/__pycache__/_tempfile.cpython-313.pyc,,
anyio/_core/__pycache__/_testing.cpython-313.pyc,,
anyio/_core/__pycache__/_typedattr.cpython-313.pyc,,
anyio/_core/_asyncio_selector_thread.py,sha256=2PdxFM3cs02Kp6BSppbvmRT7q7asreTW5FgBxEsflBo,5626
anyio/_core/_contextmanagers.py,sha256=YInBCabiEeS-UaP_Jdxa1CaFC71ETPW8HZTHIM8Rsc8,7215
anyio/_core/_eventloop.py,sha256=ByZUeJD9alMfcyTseRo5IzTO0IltEul_Gyq9iqSjqDk,6658
anyio/_core/_exceptions.py,sha256=OfzLO4Z3Hog1TnipbIn72YNtkoYxS4lHW9MqKDeGc88,4936
anyio/_core/_fileio.py,sha256=hHfyV0bXDL-R2ZNnInwse3nmTAd36AIz1cBxgmAwzAQ,31358
anyio/_core/_resources.py,sha256=NbmU5O5UX3xEyACnkmYX28Fmwdl-f-ny0tHym26e0w0,435
anyio/_core/_signals.py,sha256=mjTBB2hTKNPRlU0IhnijeQedpWOGERDiMjSlJQsFrug,1016
anyio/_core/_sockets.py,sha256=9FU423j52XBBfGVr6MdzPTdyw8bGrzApZ5m338-AtsY,35286
anyio/_core/_streams.py,sha256=FczFwIgDpnkK0bODWJXMpsUJYdvAD04kaUaGzJU8DK0,1806
anyio/_core/_subprocesses.py,sha256=tkmkPKEkEaiMD8C9WRZBlmgjOYRDRbZdte6e-unay2E,7916
anyio/_core/_synchronization.py,sha256=U14eUp9tqcP95OcCG7s3A1MZU9WLw84lOS4YB-H795s,21591
anyio/_core/_tasks.py,sha256=ELL2jscaSW0Jw_xA6MtQlm3xwvFEzjTbc1u9Tteyt0I,13244
anyio/_core/_tempfile.py,sha256=jE2w59FRF3yRo4vjkjfZF2YcqsBZvc66VWRwrJGDYGk,19624
anyio/_core/_testing.py,sha256=u7MPqGXwpTxqI7hclSdNA30z2GH1Nw258uwKvy_RfBg,2340
anyio/_core/_typedattr.py,sha256=P4ozZikn3-DbpoYcvyghS_FOYAgbmUxeoU8-L_07pZM,2508
anyio/abc/__init__.py,sha256=6mWhcl_pGXhrgZVHP_TCfMvIXIOp9mroEFM90fYCU_U,2869
anyio/abc/__pycache__/__init__.cpython-313.pyc,,
anyio/abc/__pycache__/_eventloop.cpython-313.pyc,,
anyio/abc/__pycache__/_resources.cpython-313.pyc,,
anyio/abc/__pycache__/_sockets.cpython-313.pyc,,
anyio/abc/__pycache__/_streams.cpython-313.pyc,,
anyio/abc/__pycache__/_subprocesses.cpython-313.pyc,,
anyio/abc/__pycache__/_tasks.cpython-313.pyc,,
anyio/abc/__pycache__/_testing.cpython-313.pyc,,
anyio/abc/_eventloop.py,sha256=OqWYSEj0TmwL_xniCJt3_jHFWsuMk9THk8tCTGsKapI,10681
anyio/abc/_resources.py,sha256=DrYvkNN1hH6Uvv5_5uKySvDsnknGVDe8FCKfko0VtN8,783
anyio/abc/_sockets.py,sha256=OmVDrfemVvF9c5K1tpBgQyV6fn5v0XyCExLAqBOGz9o,13124
anyio/abc/_streams.py,sha256=HYvna1iZbWcwLROTO6IhLX79RTRLPShZMWe0sG1q54I,7481
anyio/abc/_subprocesses.py,sha256=cumAPJTktOQtw63IqG0lDpyZqu_l1EElvQHMiwJgL08,2067
anyio/abc/_tasks.py,sha256=m-FtE4phxeNIELSG7A3H7VUz3jA2Ib5J2JIew8-PS6o,6642
anyio/abc/_testing.py,sha256=9YYM2AXsYFvf4PLjUEr6yRxDiUeB5QbY_gOg0X_C6lY,2034
anyio/from_thread.py,sha256=JYsbaCaIB_Iit6kNhtXSteJGt4PcQ7ncq0nIpcelIrg,19265
anyio/functools.py,sha256=T4JS8IXq-x1S0Lbo2owF8l9fza2KypO147QLeyz4cjs,11797
anyio/itertools.py,sha256=QV-9mnRCr2yBph8g01QFvN-bQ_Yle-8Sl13YSydBlMI,16168
anyio/lowlevel.py,sha256=WPtppHfI2qs1nokzjn8elL8LvyqI05AK5Zslhlo71A4,6242
anyio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/pytest_plugin.py,sha256=paMpI_VMNQf2bir0LfvgMpXSiYJoHDzWdKUVTyoHmvQ,13609
anyio/streams/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
anyio/streams/__pycache__/__init__.cpython-313.pyc,,
anyio/streams/__pycache__/buffered.cpython-313.pyc,,
anyio/streams/__pycache__/file.cpython-313.pyc,,
anyio/streams/__pycache__/memory.cpython-313.pyc,,
anyio/streams/__pycache__/stapled.cpython-313.pyc,,
anyio/streams/__pycache__/text.cpython-313.pyc,,
anyio/streams/__pycache__/tls.cpython-313.pyc,,
anyio/streams/buffered.py,sha256=v3xKtjFHgNV41g2SvMAkA_qd2t9WYlCI1_lNGCAatw0,6650
anyio/streams/file.py,sha256=msnrotVKGMQomUu_Rj2qz9MvIdUp6d3JGr7MOEO8kV4,4428
anyio/streams/memory.py,sha256=F0zwzvFJKAhX_LRZGoKzzqDC2oMM-f-yyTBrEYEGOaU,10740
anyio/streams/stapled.py,sha256=T8Xqwf8K6EgURPxbt1N4i7A8BAk-gScv-GRhjLXIf_o,4390
anyio/streams/text.py,sha256=BcVAGJw1VRvtIqnv-o0Rb0pwH7p8vwlvl21xHq522ag,5765
anyio/streams/tls.py,sha256=DQVkXUvsTEYKkBO8dlVU7j_5H8QOtLy4sGi1Wrjqevo,15303
anyio/to_interpreter.py,sha256=_mLngrMy97TMR6VbW4Y6YzDUk9ZuPcQMPlkuyRh3C9k,7100
anyio/to_process.py,sha256=68qhLfce7MeXysid4fOpmhfWkgdo7Z7-9BC0VyUciIE,9809
anyio/to_thread.py,sha256=f6h_k2d743GBv9FhAnhM_YpTvWgIrzBy9cOE0eJ1UJw,2693
@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,2 @@
[pytest11]
anyio = anyio.pytest_plugin
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2018 Alex Grönholm
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
anyio

Some files were not shown because too many files have changed in this diff Show More