Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3c751bbc | ||
|
|
94abc5461d |
@@ -0,0 +1,7 @@
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
backup/
|
||||
.agents/
|
||||
.claude/
|
||||
.codex/
|
||||
**/.vscode/
|
||||
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "config.h"
|
||||
|
||||
// --- Wi-Fi 및 MQTT 설정 값 정의 ---
|
||||
const char* ssid = "KT_GiGA_B517"; //KT_GiGA_B517 | esd
|
||||
const char* password = "axf29xc666"; //axf29xc666 | usdd6595
|
||||
const char* mqtt_server = "218.155.105.34";
|
||||
const char* mqtt_user = "ctnt_root";
|
||||
const char* mqtt_password = "Umsang6595!!";
|
||||
const char* clientName = "ESP32_PLC_Gateway";
|
||||
|
||||
// --- 통신 버퍼 정의 ---
|
||||
uint16_t M_In_New[M_IN_WORDS];
|
||||
uint16_t M_In_Old[M_IN_WORDS];
|
||||
|
||||
uint16_t M_Out_New[M_OUT_WORDS];
|
||||
uint16_t M_Out_Old[M_OUT_WORDS];
|
||||
uint16_t M_Out_Pending_Write[M_OUT_WORDS];
|
||||
uint16_t M_Out_Pending_Clear[M_OUT_WORDS];
|
||||
|
||||
uint16_t D_In_New[D_IN_WORDS];
|
||||
uint16_t D_In_Old[D_IN_WORDS];
|
||||
|
||||
uint16_t D_Out_New[D_OUT_WORDS];
|
||||
uint16_t D_Out_Old[D_OUT_WORDS];
|
||||
uint8_t D_Out_Pending_Groups = 0;
|
||||
|
||||
// 제어 명령 적용 직전 상태. 실패/타임아웃 시 Old가 아닌 이 스냅샷으로 복원한다.
|
||||
static uint16_t M_Out_Command_Backup[M_OUT_WORDS];
|
||||
static uint16_t M_Out_Pending_Write_Backup[M_OUT_WORDS];
|
||||
static uint16_t M_Out_Pending_Clear_Backup[M_OUT_WORDS];
|
||||
static uint16_t D_Out_Command_Backup[D_OUT_WORDS];
|
||||
static uint8_t D_Out_Pending_Groups_Backup = 0;
|
||||
|
||||
// --- 시스템 상태 변수 정의 ---
|
||||
SystemState currentState = STATE_INIT;
|
||||
unsigned long lastStateChange = 0;
|
||||
const unsigned long SLOT_DURATION = 200; // 0.2초 (200ms)
|
||||
const unsigned long INIT_DURATION = 5000; // 5초 (5000ms)
|
||||
|
||||
bool readExecuted = false;
|
||||
bool writeExecuted = false;
|
||||
unsigned long lastInitReadTime = 0;
|
||||
|
||||
int modbusErrorCount = 0;
|
||||
const int MODBUS_MAX_RETRY = 10; // PLC 재부팅 감지 임계값 (10회)
|
||||
bool plcOnline = true;
|
||||
|
||||
unsigned long lastMqttRetry = 0;
|
||||
bool initSequenceStarted = false;
|
||||
bool force_write_M_once = false;
|
||||
bool force_write_D_once = false;
|
||||
bool full_sync_pending = false;
|
||||
char pending_command_id[40] = "";
|
||||
bool pending_command_active = false;
|
||||
bool pending_command_type_is_m = false;
|
||||
unsigned long pending_command_time = 0;
|
||||
|
||||
// --- D Apply 펄스 상태 정의 ---
|
||||
bool d_apply_pending = false;
|
||||
uint16_t d_apply_word_mask = 0;
|
||||
int d_apply_word_idx = -1;
|
||||
|
||||
// --- [G1] 버퍼 초기화 ---
|
||||
void setup_buffers() {
|
||||
memset(M_In_New, 0, sizeof(M_In_New));
|
||||
memset(M_In_Old, 0, sizeof(M_In_Old));
|
||||
memset(M_Out_New, 0, sizeof(M_Out_New));
|
||||
memset(M_Out_Old, 0, sizeof(M_Out_Old));
|
||||
memset(M_Out_Pending_Write, 0, sizeof(M_Out_Pending_Write));
|
||||
memset(M_Out_Pending_Clear, 0, sizeof(M_Out_Pending_Clear));
|
||||
memset(D_In_New, 0, sizeof(D_In_New));
|
||||
memset(D_In_Old, 0, sizeof(D_In_Old));
|
||||
memset(D_Out_New, 0, sizeof(D_Out_New));
|
||||
memset(D_Out_Old, 0, sizeof(D_Out_Old));
|
||||
D_Out_Pending_Groups = 0;
|
||||
memset(M_Out_Command_Backup, 0, sizeof(M_Out_Command_Backup));
|
||||
memset(M_Out_Pending_Write_Backup, 0, sizeof(M_Out_Pending_Write_Backup));
|
||||
memset(M_Out_Pending_Clear_Backup, 0, sizeof(M_Out_Pending_Clear_Backup));
|
||||
memset(D_Out_Command_Backup, 0, sizeof(D_Out_Command_Backup));
|
||||
|
||||
force_write_M_once = false;
|
||||
force_write_D_once = false;
|
||||
full_sync_pending = true; // Full-Sync 개시 플래그 활성화
|
||||
|
||||
// 대기 중인 명령 상태 명시적 초기화
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
|
||||
// D Apply 펄스 상태 초기화
|
||||
d_apply_pending = false;
|
||||
d_apply_word_mask = 0;
|
||||
d_apply_word_idx = -1;
|
||||
|
||||
Serial.println("[INIT] 버퍼 전체 0 초기화 및 강제 쓰기 플래그, Full-Sync 대기 플래그 활성화 완료.");
|
||||
}
|
||||
|
||||
void snapshot_pending_command_buffers(bool is_m_command) {
|
||||
if (is_m_command) {
|
||||
memcpy(M_Out_Command_Backup, M_Out_New, sizeof(M_Out_New));
|
||||
memcpy(M_Out_Pending_Write_Backup, M_Out_Pending_Write, sizeof(M_Out_Pending_Write));
|
||||
memcpy(M_Out_Pending_Clear_Backup, M_Out_Pending_Clear, sizeof(M_Out_Pending_Clear));
|
||||
} else {
|
||||
memcpy(D_Out_Command_Backup, D_Out_New, sizeof(D_Out_New));
|
||||
D_Out_Pending_Groups_Backup = D_Out_Pending_Groups;
|
||||
}
|
||||
}
|
||||
|
||||
void rollback_pending_command_buffers() {
|
||||
if (pending_command_type_is_m) {
|
||||
memcpy(M_Out_New, M_Out_Command_Backup, sizeof(M_Out_New));
|
||||
memcpy(M_Out_Pending_Write, M_Out_Pending_Write_Backup, sizeof(M_Out_Pending_Write));
|
||||
memcpy(M_Out_Pending_Clear, M_Out_Pending_Clear_Backup, sizeof(M_Out_Pending_Clear));
|
||||
} else {
|
||||
memcpy(D_Out_New, D_Out_Command_Backup, sizeof(D_Out_New));
|
||||
D_Out_Pending_Groups = D_Out_Pending_Groups_Backup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <PubSubClient.h>
|
||||
|
||||
// --- 펌웨어 버전 식별 ---
|
||||
#define FIRMWARE_VERSION "d-apply-v2"
|
||||
|
||||
// --- Wi-Fi 및 MQTT 설정 ---
|
||||
extern const char* ssid;
|
||||
extern const char* password;
|
||||
extern const char* mqtt_server;
|
||||
extern const char* mqtt_user;
|
||||
extern const char* mqtt_password;
|
||||
extern const char* clientName;
|
||||
|
||||
// --- PLC Modbus 주소 및 크기 설정 ---
|
||||
#define PLC_SLAVE_ID 1 // PLC 국번 (Modbus ID)
|
||||
|
||||
// 임시 비트(Coil) 방식 상수
|
||||
#define M_IN_START_ADDR 1600 // M01000 시작 비트 주소 (100 * 16)
|
||||
#define M_IN_COILS 208 // M01000 ~ M0112F (208개 비트)
|
||||
#define M_IN_WORDS 13 // 내부 버퍼 호환용 워드 크기 유지
|
||||
#define M_OUT_START_ADDR 3200 // M02000 시작 비트 주소 (200 * 16)
|
||||
#define M_OUT_COILS 192 // M02000 ~ M0211F (192개 비트, D Apply M211C~F 포함하도록 확장)
|
||||
#define M_OUT_WORDS 12 // 내부 버퍼 호환용 워드 크기 유지
|
||||
|
||||
// --- D 워드 원격 제어 Apply 비트 (M211C~M211F, 그룹 0~3 대응) ---
|
||||
// PLC 래더는 이 A접이 ON인 동안에만 D203x→실제 설정값 DMOV를 수행한다.
|
||||
// 모멘터리(P접)는 PLC에서 어려워 게이트웨이가 기존 M 모멘터리 엔진으로 ON→OFF 펄스를 생성한다.
|
||||
#define D_APPLY_BIT_BASE 188 // M211C = M2000 기준 offset 188 (그룹 0), 189/190/191 = 그룹 1/2/3
|
||||
|
||||
#define D_IN_START_ADDR 2000 // D2000 시작 주소
|
||||
#define D_IN_WORDS 20 // D2000 ~ D2018 (20 워드, 10 그룹)
|
||||
#define D_OUT_START_ADDR 2030 // D2030 시작 주소 (PLC 메모리 맵 매핑 수정)
|
||||
#define D_OUT_WORDS 8 // D2030 ~ D2036 (8 워드, 4 그룹)
|
||||
|
||||
// --- 통신 버퍼 (Old/New Double Buffering) ---
|
||||
extern uint16_t M_In_New[M_IN_WORDS];
|
||||
extern uint16_t M_In_Old[M_IN_WORDS];
|
||||
|
||||
extern uint16_t M_Out_New[M_OUT_WORDS];
|
||||
extern uint16_t M_Out_Old[M_OUT_WORDS];
|
||||
extern uint16_t M_Out_Pending_Write[M_OUT_WORDS];
|
||||
extern uint16_t M_Out_Pending_Clear[M_OUT_WORDS];
|
||||
|
||||
extern uint16_t D_In_New[D_IN_WORDS];
|
||||
extern uint16_t D_In_Old[D_IN_WORDS];
|
||||
|
||||
extern uint16_t D_Out_New[D_OUT_WORDS];
|
||||
extern uint16_t D_Out_Old[D_OUT_WORDS];
|
||||
extern uint8_t D_Out_Pending_Groups;
|
||||
|
||||
// --- 시스템 상태 및 오류 통계 제어 변수 ---
|
||||
enum SystemState {
|
||||
STATE_INIT,
|
||||
STATE_READ,
|
||||
STATE_WRITE
|
||||
};
|
||||
|
||||
extern SystemState currentState;
|
||||
extern unsigned long lastStateChange;
|
||||
extern const unsigned long SLOT_DURATION; // 0.5초 (500ms)
|
||||
extern const unsigned long INIT_DURATION; // 5초 (5000ms)
|
||||
|
||||
extern bool readExecuted;
|
||||
extern bool writeExecuted;
|
||||
extern unsigned long lastInitReadTime;
|
||||
|
||||
extern int modbusErrorCount;
|
||||
extern const int MODBUS_MAX_RETRY; // PLC 재부팅 감지 임계값 (10회)
|
||||
extern bool plcOnline;
|
||||
|
||||
extern unsigned long lastMqttRetry;
|
||||
extern bool initSequenceStarted;
|
||||
extern bool force_write_M_once;
|
||||
extern bool force_write_D_once;
|
||||
extern bool full_sync_pending;
|
||||
extern char pending_command_id[40];
|
||||
extern bool pending_command_active;
|
||||
extern bool pending_command_type_is_m;
|
||||
extern unsigned long pending_command_time; // 명령 수신 시점 저장용 타임스탬프
|
||||
|
||||
// --- D Apply 펄스 상태 (D 쓰기 성공 후 M211C~F ON→OFF 완료 시점에 PLC_APPLIED 발행) ---
|
||||
// 기존 M 모멘터리 엔진을 재사용하되, D 명령의 ACK를 Apply OFF 완료까지 지연시키기 위한 최소 상태.
|
||||
extern bool d_apply_pending; // D 쓰기는 끝났고 Apply 펄스 완료를 기다리는 중
|
||||
extern uint16_t d_apply_word_mask; // Apply 대상 M211C~F 비트가 속한 워드의 비트 마스크
|
||||
extern int d_apply_word_idx; // Apply 비트가 속한 워드 인덱스 (M211C~F는 모두 word 11)
|
||||
|
||||
// 함수 선언
|
||||
void setup_buffers();
|
||||
void snapshot_pending_command_buffers(bool is_m_command);
|
||||
void rollback_pending_command_buffers();
|
||||
|
||||
#endif // CONFIG_H
|
||||
@@ -0,0 +1,168 @@
|
||||
#include <Arduino.h>
|
||||
#include <esp_wifi.h>
|
||||
#include "config.h"
|
||||
#include "modbus.h"
|
||||
#include "mqtt_handler.h"
|
||||
|
||||
// --- [G1] Wi-Fi 연결 설정 ---
|
||||
void setup_wifi() {
|
||||
delay(10);
|
||||
Serial.println();
|
||||
Serial.print("[WIFI] Connecting to ");
|
||||
Serial.println(ssid);
|
||||
|
||||
WiFi.disconnect(true, true);
|
||||
delay(500);
|
||||
WiFi.mode(WIFI_STA);
|
||||
delay(500);
|
||||
|
||||
esp_wifi_set_ps(WIFI_PS_NONE);
|
||||
|
||||
WiFi.begin(ssid, password);
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
attempts++;
|
||||
|
||||
if (attempts >= 20) {
|
||||
Serial.println("\n[WIFI] 연결 지연 발생. 무선 재시작 중...");
|
||||
WiFi.disconnect();
|
||||
delay(500);
|
||||
WiFi.begin(ssid, password);
|
||||
attempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println("");
|
||||
Serial.println("[WIFI] connected");
|
||||
Serial.print("[WIFI] IP address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
}
|
||||
|
||||
// --- [G7] 부팅 초기화 루틴 제어 함수 ---
|
||||
void handle_init_sequence() {
|
||||
if (!initSequenceStarted) {
|
||||
initSequenceStarted = true;
|
||||
setup_buffers();
|
||||
|
||||
Serial.println("[EVENT] id=999 Full-Sync 시작");
|
||||
|
||||
if (client.connected()) {
|
||||
client.publish("/line1/event", "{\"id\":999,\"val\":1}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- [G8] PLC 재부팅 감지 이벤트 처리 함수 ---
|
||||
void trigger_plc_reboot_event() {
|
||||
Serial.println("[EVENT] id=998 PLC_REBOOT 감지");
|
||||
if (client.connected()) {
|
||||
client.publish("/line1/event", "{\"id\":998,\"val\":1}");
|
||||
}
|
||||
|
||||
currentState = STATE_INIT;
|
||||
lastStateChange = millis();
|
||||
initSequenceStarted = false;
|
||||
}
|
||||
|
||||
// --- [G8] PLC 통신 복구 감지 처리 함수 ---
|
||||
void trigger_plc_recovery() {
|
||||
Serial.println("[EVENT] PLC 통신 복구 감지 -> Full-Sync 재수행 준비");
|
||||
currentState = STATE_INIT;
|
||||
lastStateChange = millis();
|
||||
initSequenceStarted = false;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.printf("[SYS] Firmware version: %s\n", FIRMWARE_VERSION);
|
||||
setup_buffers();
|
||||
setup_wifi();
|
||||
setup_mqtt();
|
||||
setup_modbus();
|
||||
lastStateChange = millis();
|
||||
Serial.println("[SYS] Setup 완료.");
|
||||
}
|
||||
|
||||
// --- [G2] 타이밍 스케줄러 메인 루프 ---
|
||||
void loop() {
|
||||
maintain_mqtt();
|
||||
|
||||
unsigned long now = millis();
|
||||
|
||||
switch (currentState) {
|
||||
case STATE_INIT:
|
||||
// 초기화 시퀀스 핸들링 (버퍼 클리어 및 999 이벤트 송신)
|
||||
handle_init_sequence();
|
||||
|
||||
// 정상 주기적 PLC 읽기를 진행하지 않고 대기 (정상 로직 일시 중단)
|
||||
if (now - lastStateChange >= INIT_DURATION) {
|
||||
Serial.println("[STATE] INIT 완료 -> PLC 상태 조회 및 Full-Sync 데이터 전송 개시");
|
||||
|
||||
// 1회 PLC 데이터를 가져와 plcOnline 여부 및 버퍼 업데이트
|
||||
run_plc_read();
|
||||
|
||||
if (plcOnline) {
|
||||
// full_sync_pending 플래그가 설정되어 있으므로 전체 스냅샷이 송신됨
|
||||
if (process_read_changes()) {
|
||||
bool pub_ok = false;
|
||||
Serial.println("[EVENT] id=997 Full-Sync 완료 이벤트 발행 시도...");
|
||||
if (client.connected()) {
|
||||
pub_ok = client.publish("/line1/event", "{\"id\":997,\"val\":1}");
|
||||
}
|
||||
|
||||
if (pub_ok) {
|
||||
full_sync_pending = false; // Full-Sync 완료 이벤트 발행 성공 시에만 일반 운전 상태로 전환
|
||||
currentState = STATE_READ;
|
||||
lastStateChange = now;
|
||||
readExecuted = false;
|
||||
Serial.println("[EVENT] id=997 Full-Sync 완료 이벤트 발행 성공, 정상 루틴 진입");
|
||||
} else {
|
||||
Serial.println("[WARN] Full-Sync 완료 이벤트(997) 발행 실패. 다음 주기에서 재발행을 재시도합니다.");
|
||||
// 상태 전환을 하지 않고 STATE_INIT 대기 유지하여 다음 주기에서 재시도
|
||||
}
|
||||
} else {
|
||||
Serial.println("[WARN] Full-Sync 전송 실패. 다음 주기에서 재동기화 재시도합니다.");
|
||||
// 상태 전환을 하지 않고 STATE_INIT 대기 유지하여 다음 주기에서 재시도
|
||||
}
|
||||
} else {
|
||||
Serial.println("[WARN] PLC 오프라인 상태 - Full-Sync 보류, 정상 루틴 진입");
|
||||
full_sync_pending = false; // 오프라인이어도 플래그는 일단 정리하여 일반 모드 진입 준비
|
||||
|
||||
currentState = STATE_READ;
|
||||
lastStateChange = now;
|
||||
readExecuted = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_READ:
|
||||
if (now - lastStateChange >= SLOT_DURATION) {
|
||||
currentState = STATE_WRITE;
|
||||
lastStateChange = now;
|
||||
writeExecuted = false;
|
||||
} else {
|
||||
if (!readExecuted) {
|
||||
run_plc_read();
|
||||
process_read_changes();
|
||||
readExecuted = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_WRITE:
|
||||
if (now - lastStateChange >= SLOT_DURATION) {
|
||||
currentState = STATE_READ;
|
||||
lastStateChange = now;
|
||||
readExecuted = false;
|
||||
} else {
|
||||
if (!writeExecuted) {
|
||||
run_plc_write();
|
||||
writeExecuted = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
#include "modbus.h"
|
||||
#include "config.h"
|
||||
|
||||
// --- 물리적 GPIO 및 통신 상수 정의 (현장 배선 사양 적용) ---
|
||||
#define MODBUS_TX_PIN 27
|
||||
#define MODBUS_RX_PIN 26
|
||||
#define MODBUS_DE_PIN 4
|
||||
#define MODBUS_BAUDRATE 115200
|
||||
|
||||
// Forward declarations for MQTT recovery/reboot functions
|
||||
void trigger_plc_reboot_event();
|
||||
void trigger_plc_recovery();
|
||||
void publish_ack(const char* command_id, const char* status);
|
||||
|
||||
// M 모멘터리 펌스의 ON~OFF 실제 소요 시간 추적용
|
||||
static bool m_pulse_release_pending = false;
|
||||
static unsigned long m_pulse_started_at = 0;
|
||||
static char m_pulse_command_id[40] = "";
|
||||
|
||||
// --- [G1] Modbus RTU / RS-485 초기화 (상수 정의 사용) ---
|
||||
void setup_modbus() {
|
||||
Serial2.begin(MODBUS_BAUDRATE, SERIAL_8N1, MODBUS_RX_PIN, MODBUS_TX_PIN);
|
||||
pinMode(MODBUS_DE_PIN, OUTPUT);
|
||||
digitalWrite(MODBUS_DE_PIN, LOW); // 기본 상태: 수신 모드
|
||||
Serial.printf("[MODBUS SETUP] Serial2 initialized at %d bps (RX:%d, TX:%d, DE:%d)\n",
|
||||
MODBUS_BAUDRATE, MODBUS_RX_PIN, MODBUS_TX_PIN, MODBUS_DE_PIN);
|
||||
}
|
||||
|
||||
// --- Modbus RTU CRC-16 계산 함수 ---
|
||||
uint16_t calculateCRC(uint8_t* buffer, int length) {
|
||||
uint16_t crc = 0xFFFF;
|
||||
for (int pos = 0; pos < length; pos++) {
|
||||
crc ^= (uint16_t)buffer[pos];
|
||||
for (int i = 8; i != 0; i--) {
|
||||
if ((crc & 0x0001) != 0) {
|
||||
crc >>= 1;
|
||||
crc ^= 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
// --- RS-485 물리 프레임 전송 함수 (DE 핀 전환 제어 및 TX 프레임 로그 출력) ---
|
||||
void send_modbus_frame(uint8_t* frame, int length) {
|
||||
// TX 프레임 Hex 디버그 로그 출력
|
||||
Serial.print("[MODBUS TX] ");
|
||||
for (int i = 0; i < length; i++) {
|
||||
Serial.printf("%02X ", frame[i]);
|
||||
}
|
||||
Serial.println();
|
||||
|
||||
digitalWrite(MODBUS_DE_PIN, HIGH); // 송신 모드로 전환
|
||||
delayMicroseconds(100); // 안정화를 위한 미세 지연
|
||||
|
||||
Serial2.write(frame, length);
|
||||
Serial2.flush(); // 데이터가 시리얼 버스를 통해 나갈 때까지 대기
|
||||
|
||||
delayMicroseconds(100);
|
||||
digitalWrite(MODBUS_DE_PIN, LOW); // 다시 수신 모드로 전환
|
||||
}
|
||||
|
||||
// --- [G3] Modbus Holding Register 읽기 공통 함수 ---
|
||||
bool modbus_read_registers(uint16_t start_addr, uint16_t quantity, uint16_t* dest_buffer) {
|
||||
uint8_t frame[8];
|
||||
frame[0] = PLC_SLAVE_ID;
|
||||
frame[1] = 0x03; // Read Holding Registers
|
||||
frame[2] = (start_addr >> 8) & 0xFF;
|
||||
frame[3] = start_addr & 0xFF;
|
||||
frame[4] = (quantity >> 8) & 0xFF;
|
||||
frame[5] = quantity & 0xFF;
|
||||
|
||||
uint16_t crc = calculateCRC(frame, 6);
|
||||
frame[6] = crc & 0xFF;
|
||||
frame[7] = (crc >> 8) & 0xFF;
|
||||
|
||||
while (Serial2.available()) Serial2.read();
|
||||
|
||||
send_modbus_frame(frame, 8);
|
||||
|
||||
unsigned long start = millis();
|
||||
int expected_bytes = 5 + (quantity * 2);
|
||||
uint8_t response[60];
|
||||
int bytes_read = 0;
|
||||
|
||||
while (millis() - start < 200) {
|
||||
if (Serial2.available()) {
|
||||
response[bytes_read++] = Serial2.read();
|
||||
if (bytes_read >= expected_bytes || bytes_read >= sizeof(response)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
|
||||
// RX 프레임 Hex 디버그 로그 출력
|
||||
if (bytes_read > 0) {
|
||||
Serial.print("[MODBUS RX FC03] ");
|
||||
for (int i = 0; i < bytes_read; i++) {
|
||||
Serial.printf("%02X ", response[i]);
|
||||
}
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("[MODBUS RX FC03 ERROR] No response (Timeout)");
|
||||
}
|
||||
|
||||
if (bytes_read < expected_bytes) {
|
||||
Serial.printf("[MODBUS RX FC03 ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t rx_crc = calculateCRC(response, bytes_read - 2);
|
||||
uint16_t pkt_crc = response[bytes_read - 2] | (response[bytes_read - 1] << 8);
|
||||
if (rx_crc != pkt_crc) {
|
||||
Serial.printf("[MODBUS RX FC03 ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response[0] != PLC_SLAVE_ID || response[1] != 0x03) {
|
||||
Serial.printf("[MODBUS RX FC03 ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
dest_buffer[i] = (response[3 + i * 2] << 8) | response[4 + i * 2];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 임시 비트(Coil) 읽기 함수 (FC 01) ---
|
||||
bool modbus_read_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* dest_word_buffer) {
|
||||
uint8_t frame[8];
|
||||
frame[0] = PLC_SLAVE_ID;
|
||||
frame[1] = 0x01; // Read Coils
|
||||
frame[2] = (start_addr >> 8) & 0xFF;
|
||||
frame[3] = start_addr & 0xFF;
|
||||
frame[4] = (coil_qty >> 8) & 0xFF;
|
||||
frame[5] = coil_qty & 0xFF;
|
||||
|
||||
uint16_t crc = calculateCRC(frame, 6);
|
||||
frame[6] = crc & 0xFF;
|
||||
frame[7] = (crc >> 8) & 0xFF;
|
||||
|
||||
while (Serial2.available()) Serial2.read();
|
||||
|
||||
send_modbus_frame(frame, 8);
|
||||
|
||||
unsigned long start = millis();
|
||||
int byte_count = (coil_qty + 7) / 8;
|
||||
int expected_bytes = 5 + byte_count;
|
||||
uint8_t response[60];
|
||||
int bytes_read = 0;
|
||||
|
||||
while (millis() - start < 200) {
|
||||
if (Serial2.available()) {
|
||||
response[bytes_read++] = Serial2.read();
|
||||
if (bytes_read >= expected_bytes || bytes_read >= sizeof(response)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
|
||||
// RX 프레임 Hex 디버그 로그 출력
|
||||
if (bytes_read > 0) {
|
||||
Serial.print("[MODBUS RX FC01] ");
|
||||
for (int i = 0; i < bytes_read; i++) {
|
||||
Serial.printf("%02X ", response[i]);
|
||||
}
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("[MODBUS RX FC01 ERROR] No response (Timeout)");
|
||||
}
|
||||
|
||||
if (bytes_read < expected_bytes) {
|
||||
Serial.printf("[MODBUS RX FC01 ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t rx_crc = calculateCRC(response, bytes_read - 2);
|
||||
uint16_t pkt_crc = response[bytes_read - 2] | (response[bytes_read - 1] << 8);
|
||||
if (rx_crc != pkt_crc) {
|
||||
Serial.printf("[MODBUS RX FC01 ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response[0] != PLC_SLAVE_ID || response[1] != 0x01) {
|
||||
Serial.printf("[MODBUS RX FC01 ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
int word_qty = (coil_qty + 15) / 16;
|
||||
for (int i = 0; i < word_qty; i++) {
|
||||
// PLC Modbus RTU는 빅엔디안으로 한 워드의 상위 바이트를 먼저 수신함
|
||||
uint8_t first_byte = response[3 + i * 2];
|
||||
uint8_t second_byte = (i * 2 + 1 < byte_count) ? response[3 + i * 2 + 1] : 0x00;
|
||||
// 바이트 순서 교정: 첫 번째 수신 바이트(낮은 코일 주소)가 하위 8비트, 두 번째 바이트가 상위 8비트가 되도록 조립
|
||||
dest_word_buffer[i] = (second_byte << 8) | first_byte;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 임시 비트(Coil) 다중 쓰기 함수 (FC 0F) ---
|
||||
bool modbus_write_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* src_word_buffer) {
|
||||
int byte_count = (coil_qty + 7) / 8;
|
||||
int payload_len = 7 + byte_count;
|
||||
uint8_t frame[40];
|
||||
|
||||
frame[0] = PLC_SLAVE_ID;
|
||||
frame[1] = 0x0F; // Write Multiple Coils
|
||||
frame[2] = (start_addr >> 8) & 0xFF;
|
||||
frame[3] = start_addr & 0xFF;
|
||||
frame[4] = (coil_qty >> 8) & 0xFF;
|
||||
frame[5] = coil_qty & 0xFF;
|
||||
frame[6] = byte_count;
|
||||
|
||||
int word_qty = (coil_qty + 15) / 16;
|
||||
for (int i = 0; i < word_qty; i++) {
|
||||
// PLC Cnet 모듈은 전송받은 바이트 순서 그대로 코일 주소에 대입하므로 스왑 없이 하위 바이트를 먼저 보냄
|
||||
frame[7 + i * 2] = src_word_buffer[i] & 0xFF;
|
||||
if (i * 2 + 1 < byte_count) {
|
||||
frame[7 + i * 2 + 1] = (src_word_buffer[i] >> 8) & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t crc = calculateCRC(frame, payload_len);
|
||||
frame[payload_len] = crc & 0xFF;
|
||||
frame[payload_len + 1] = (crc >> 8) & 0xFF;
|
||||
|
||||
while (Serial2.available()) Serial2.read();
|
||||
|
||||
send_modbus_frame(frame, payload_len + 2);
|
||||
|
||||
unsigned long start = millis();
|
||||
int expected_bytes = 8;
|
||||
uint8_t response[10];
|
||||
int bytes_read = 0;
|
||||
|
||||
while (millis() - start < 200) {
|
||||
if (Serial2.available()) {
|
||||
response[bytes_read++] = Serial2.read();
|
||||
if (bytes_read >= expected_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
|
||||
// RX 프레임 Hex 디버그 로그 출력
|
||||
if (bytes_read > 0) {
|
||||
Serial.print("[MODBUS RX FC0F] ");
|
||||
for (int i = 0; i < bytes_read; i++) {
|
||||
Serial.printf("%02X ", response[i]);
|
||||
}
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("[MODBUS RX FC0F ERROR] No response (Timeout)");
|
||||
}
|
||||
|
||||
if (bytes_read < expected_bytes) {
|
||||
Serial.printf("[MODBUS RX FC0F ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t rx_crc = calculateCRC(response, expected_bytes - 2);
|
||||
uint16_t pkt_crc = response[expected_bytes - 2] | (response[expected_bytes - 1] << 8);
|
||||
if (rx_crc != pkt_crc) {
|
||||
Serial.printf("[MODBUS RX FC0F ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response[0] != PLC_SLAVE_ID || response[1] != 0x0F) {
|
||||
Serial.printf("[MODBUS RX FC0F ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- [G4] Modbus Holding Register 다중 쓰기 공통 함수 (FC 16) ---
|
||||
bool modbus_write_registers(uint16_t start_addr, uint16_t quantity, uint16_t* src_buffer) {
|
||||
int payload_len = 7 + (quantity * 2);
|
||||
uint8_t frame[35];
|
||||
|
||||
frame[0] = PLC_SLAVE_ID;
|
||||
frame[1] = 0x10; // Write Multiple Registers (FC16)
|
||||
frame[2] = (start_addr >> 8) & 0xFF;
|
||||
frame[3] = start_addr & 0xFF;
|
||||
frame[4] = (quantity >> 8) & 0xFF;
|
||||
frame[5] = quantity & 0xFF;
|
||||
frame[6] = quantity * 2;
|
||||
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
frame[7 + i * 2] = (src_buffer[i] >> 8) & 0xFF;
|
||||
frame[8 + i * 2] = src_buffer[i] & 0xFF;
|
||||
}
|
||||
|
||||
uint16_t crc = calculateCRC(frame, payload_len);
|
||||
frame[payload_len] = crc & 0xFF;
|
||||
frame[payload_len + 1] = (crc >> 8) & 0xFF;
|
||||
|
||||
while (Serial2.available()) Serial2.read();
|
||||
|
||||
send_modbus_frame(frame, payload_len + 2);
|
||||
|
||||
unsigned long start = millis();
|
||||
uint8_t response[10];
|
||||
int bytes_read = 0;
|
||||
int expected_bytes = 8;
|
||||
|
||||
while (millis() - start < 200) {
|
||||
if (Serial2.available()) {
|
||||
response[bytes_read++] = Serial2.read();
|
||||
|
||||
if (bytes_read == 2) {
|
||||
if (response[0] == PLC_SLAVE_ID && response[1] == 0x90) {
|
||||
expected_bytes = 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes_read >= expected_bytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
|
||||
// RX 프레임 Hex 디버그 로그 출력
|
||||
if (bytes_read > 0) {
|
||||
Serial.print("[MODBUS RX FC10] ");
|
||||
for (int i = 0; i < bytes_read; i++) {
|
||||
Serial.printf("%02X ", response[i]);
|
||||
}
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("[MODBUS RX FC10 ERROR] No response (Timeout)");
|
||||
}
|
||||
|
||||
if (bytes_read < expected_bytes) {
|
||||
Serial.printf("[MODBUS RX FC10 ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t rx_crc = calculateCRC(response, expected_bytes - 2);
|
||||
uint16_t pkt_crc = response[expected_bytes - 2] | (response[expected_bytes - 1] << 8);
|
||||
if (rx_crc != pkt_crc) {
|
||||
Serial.printf("[MODBUS RX FC10 ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response[1] == 0x90) {
|
||||
Serial.println("[MODBUS RX FC10 ERROR] Exception Response (0x90)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response[0] != PLC_SLAVE_ID || response[1] != 0x10) {
|
||||
Serial.printf("[MODBUS RX FC10 ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- [G3] PLC 읽기 슬롯 처리 함수 ---
|
||||
void run_plc_read() {
|
||||
bool success_M = modbus_read_coils(M_IN_START_ADDR, M_IN_COILS, M_In_New);
|
||||
bool success_D = modbus_read_registers(D_IN_START_ADDR, D_IN_WORDS, D_In_New);
|
||||
|
||||
if (success_M && success_D) {
|
||||
if (modbusErrorCount > 0) {
|
||||
Serial.println("[MODBUS] 통신 복구 완료! 에러 카운트를 리셋합니다.");
|
||||
}
|
||||
modbusErrorCount = 0;
|
||||
|
||||
if (!plcOnline) {
|
||||
plcOnline = true;
|
||||
trigger_plc_recovery();
|
||||
}
|
||||
} else {
|
||||
if (modbusErrorCount < MODBUS_MAX_RETRY) {
|
||||
modbusErrorCount++;
|
||||
Serial.printf("[ERROR] Modbus Read 실패! (M:%d, D:%d) (연속 %d회 실패 / 최대 10회)\n", success_M, success_D, modbusErrorCount);
|
||||
}
|
||||
|
||||
if (modbusErrorCount >= MODBUS_MAX_RETRY && plcOnline) {
|
||||
plcOnline = false;
|
||||
Serial.printf("[ERROR] Modbus Read 장기 실패! (연속 %d회 실패 / 최대 10회)\n", modbusErrorCount);
|
||||
trigger_plc_reboot_event();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- [G4] PLC 쓰기 슬롯 처리 함수 ---
|
||||
void run_plc_write() {
|
||||
if (!plcOnline) return;
|
||||
|
||||
// 1. M 영역 (Coils) 쓰기 판단 및 실행
|
||||
bool need_write_M = force_write_M_once ||
|
||||
(pending_command_active && pending_command_type_is_m) ||
|
||||
(memcmp(M_Out_New, M_Out_Old, sizeof(M_Out_Old)) != 0);
|
||||
if (!need_write_M) {
|
||||
for (int i = 0; i < M_OUT_WORDS; i++) {
|
||||
if (M_Out_Pending_Write[i] != 0 || M_Out_Pending_Clear[i] != 0) {
|
||||
need_write_M = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (need_write_M) {
|
||||
bool is_command_apply = pending_command_active && pending_command_type_is_m;
|
||||
bool is_auto_off = !is_command_apply && m_pulse_release_pending;
|
||||
const char* phase = force_write_M_once ? "INIT_FORCE" :
|
||||
(is_command_apply ? "COMMAND_ON" :
|
||||
(is_auto_off ? "AUTO_OFF" : "INTERNAL_RETRY"));
|
||||
char command_id_for_trace[40] = "";
|
||||
if (is_command_apply) {
|
||||
strncpy(command_id_for_trace, pending_command_id, sizeof(command_id_for_trace) - 1);
|
||||
command_id_for_trace[sizeof(command_id_for_trace) - 1] = '\0';
|
||||
} else if (m_pulse_command_id[0] != '\0') {
|
||||
strncpy(command_id_for_trace, m_pulse_command_id, sizeof(command_id_for_trace) - 1);
|
||||
command_id_for_trace[sizeof(command_id_for_trace) - 1] = '\0';
|
||||
} else {
|
||||
strncpy(command_id_for_trace, "none", sizeof(command_id_for_trace) - 1);
|
||||
}
|
||||
|
||||
Serial.printf("[M PULSE TRACE] phase=%s command_id=%s started_ms=%lu\n",
|
||||
phase, command_id_for_trace, millis());
|
||||
if (is_command_apply && m_pulse_release_pending) {
|
||||
Serial.printf("[M PULSE WARN] command_id=%s arrived before previous pulse AUTO_OFF completed; repeated HIGH may not create a new PLC rising edge.\n",
|
||||
command_id_for_trace);
|
||||
}
|
||||
for (int i = 0; i < M_OUT_WORDS; i++) {
|
||||
if (M_Out_New[i] != M_Out_Old[i] || M_Out_Pending_Write[i] != 0) {
|
||||
Serial.printf("[M PULSE TRACE] word=%d plc=M%04d0~M%04dF old=0x%04X outgoing=0x%04X pending=0x%04X\n",
|
||||
i, 200 + i, 200 + i, M_Out_Old[i], M_Out_New[i], M_Out_Pending_Write[i]);
|
||||
}
|
||||
}
|
||||
|
||||
bool success_M = modbus_write_coils(M_OUT_START_ADDR, M_OUT_COILS, M_Out_New);
|
||||
if (!success_M) {
|
||||
Serial.printf("[ERROR] M_Out Coil Write 실패! (addr=%d, qty=%d, FC0F)\n", M_OUT_START_ADDR, M_OUT_COILS);
|
||||
if (pending_command_active && pending_command_type_is_m) {
|
||||
rollback_pending_command_buffers();
|
||||
publish_ack(pending_command_id, "FAILED");
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
} else {
|
||||
// 모멘터리 OFF 등 내부 쓰기는 목표 버퍼를 유지해 다음 WRITE 슬롯에서 재시도한다.
|
||||
Serial.println("[MODBUS WRITE M] Internal write will be retried without rollback.");
|
||||
}
|
||||
} else {
|
||||
Serial.println("[MODBUS WRITE M] M_Out write sequence completed successfully.");
|
||||
if (is_auto_off) {
|
||||
Serial.printf("[M PULSE TRACE] phase=AUTO_OFF_COMPLETE command_id=%s pulse_width_ms=%lu\n",
|
||||
command_id_for_trace, millis() - m_pulse_started_at);
|
||||
m_pulse_release_pending = false;
|
||||
m_pulse_command_id[0] = '\0';
|
||||
|
||||
// D Apply 펄스의 OFF가 방금 완료되었는지 확인.
|
||||
// d_apply_pending이 true인 D 명령에 한해 이 시점에 PLC_APPLIED를 발행한다(상태 경합 방지).
|
||||
if (d_apply_pending && pending_command_active && !pending_command_type_is_m) {
|
||||
Serial.printf("[D APPLY] command_id=%s Apply OFF 완료 -> PLC_APPLIED 발행\n", pending_command_id);
|
||||
publish_ack(pending_command_id, "PLC_APPLIED");
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
d_apply_pending = false;
|
||||
d_apply_word_mask = 0;
|
||||
d_apply_word_idx = -1;
|
||||
}
|
||||
}
|
||||
if (pending_command_active && pending_command_type_is_m) {
|
||||
publish_ack(pending_command_id, "PLC_APPLIED");
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
}
|
||||
|
||||
// 쓰기 성공 시 Old 버퍼 동기화 및 플래그 해제
|
||||
memcpy(M_Out_Old, M_Out_New, sizeof(M_Out_Old));
|
||||
|
||||
bool scheduled_auto_off = false;
|
||||
for (int i = 0; i < M_OUT_WORDS; i++) {
|
||||
if (M_Out_Pending_Write[i] != 0) {
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if ((M_Out_Pending_Write[i] >> j) & 1) {
|
||||
scheduled_auto_off = true;
|
||||
M_Out_Pending_Write[i] &= ~(1 << j);
|
||||
M_Out_New[i] &= ~(1 << j);
|
||||
Serial.printf("[MODBUS WRITE RESET] Automatically cleared absolute bit %d to 0 for momentary emulation\n", i * 16 + j);
|
||||
Serial.printf("[M PULSE TRACE] command_id=%s plc_target=M%04d%X state=HIGH_APPLIED auto_off=SCHEDULED\n",
|
||||
command_id_for_trace, 200 + i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
M_Out_Pending_Clear[i] = 0;
|
||||
}
|
||||
|
||||
if (scheduled_auto_off) {
|
||||
m_pulse_release_pending = true;
|
||||
m_pulse_started_at = millis();
|
||||
strncpy(m_pulse_command_id, command_id_for_trace, sizeof(m_pulse_command_id) - 1);
|
||||
m_pulse_command_id[sizeof(m_pulse_command_id) - 1] = '\0';
|
||||
Serial.printf("[M PULSE TRACE] phase=ON_COMPLETE command_id=%s auto_off_pending=1\n", command_id_for_trace);
|
||||
}
|
||||
|
||||
force_write_M_once = false; // M 영역 최초 쓰기 완료
|
||||
}
|
||||
}
|
||||
|
||||
// 2. D 영역 (Registers) 쓰기 판단 및 실행
|
||||
bool need_write_D = D_Out_Pending_Groups != 0;
|
||||
if (need_write_D) {
|
||||
uint8_t groups_to_write = D_Out_Pending_Groups;
|
||||
bool success_D = true;
|
||||
for (int group_id = 0; group_id < 4; group_id++) {
|
||||
if ((groups_to_write & (1U << group_id)) == 0) continue;
|
||||
if (!modbus_write_registers(D_OUT_START_ADDR + group_id * 2, 2, &D_Out_New[group_id * 2])) {
|
||||
success_D = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!success_D) {
|
||||
Serial.printf("[ERROR] D_Out Write 실패! (addr=%d, qty=%d, FC16)\n", D_OUT_START_ADDR, D_OUT_WORDS);
|
||||
if (pending_command_active && !pending_command_type_is_m) {
|
||||
rollback_pending_command_buffers();
|
||||
publish_ack(pending_command_id, "FAILED");
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
} else {
|
||||
Serial.println("[MODBUS WRITE D] Internal write will be retried without rollback.");
|
||||
}
|
||||
} else {
|
||||
Serial.println("[MODBUS WRITE D] Pending REAL groups written successfully.");
|
||||
|
||||
// 쓰기 성공 시 Old 버퍼 동기화
|
||||
memcpy(D_Out_Old, D_Out_New, sizeof(D_Out_Old));
|
||||
D_Out_Pending_Groups &= ~groups_to_write;
|
||||
force_write_D_once = false; // D 영역 최초 쓰기 완료
|
||||
|
||||
// D203x 쓰기가 끝났으므로, 이번에 쓴 그룹들의 Apply 비트(M211C~F)를 기존 M 모멘터리 엔진에 예약한다.
|
||||
// PLC는 이 A접이 ON인 동안 DMOV로 실제 설정값을 복사한다. ACK(PLC_APPLIED)는 Apply OFF 완료까지 지연한다.
|
||||
uint16_t apply_mask = 0;
|
||||
for (int group_id = 0; group_id < 4; group_id++) {
|
||||
if (groups_to_write & (1U << group_id)) {
|
||||
int apply_bit = D_APPLY_BIT_BASE + group_id; // 188~191 = M211C~F
|
||||
int w = apply_bit / 16; // 워드 인덱스 (모두 11)
|
||||
int b = apply_bit % 16; // 워드 내 비트 위치
|
||||
M_Out_New[w] |= (1 << b);
|
||||
M_Out_Pending_Write[w] |= (1 << b); // 다음 WRITE 슬롯에서 FC0F ON → 이후 자동 OFF
|
||||
M_Out_Pending_Clear[w] &= ~(1 << b);
|
||||
apply_mask |= (1 << b);
|
||||
d_apply_word_idx = w;
|
||||
// M211C~F: word=11, bit=12~15 → PLC 표기 M2%02d%X
|
||||
Serial.printf("[D APPLY] Group %d -> Apply bit M2%02d%X (offset %d) ON 예약\n",
|
||||
group_id, 200 + w, b, apply_bit);
|
||||
}
|
||||
}
|
||||
|
||||
if (apply_mask != 0 && pending_command_active && !pending_command_type_is_m) {
|
||||
// Apply 펄스가 예약된 D 명령: OFF 완료 시점에 PLC_APPLIED를 발행하도록 대기 상태 진입.
|
||||
d_apply_pending = true;
|
||||
d_apply_word_mask = apply_mask;
|
||||
Serial.printf("[D APPLY] command_id=%s Apply 펄스 예약 완료, PLC_APPLIED는 Apply OFF 후 발행\n",
|
||||
pending_command_id);
|
||||
} else if (pending_command_active && !pending_command_type_is_m) {
|
||||
// 예약된 Apply 비트가 없는 예외 상황: 기존 동작대로 즉시 완료 처리.
|
||||
publish_ack(pending_command_id, "PLC_APPLIED");
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef MODBUS_H
|
||||
#define MODBUS_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
void setup_modbus();
|
||||
uint16_t calculateCRC(uint8_t* buffer, int length);
|
||||
bool modbus_read_registers(uint16_t start_addr, uint16_t quantity, uint16_t* dest_buffer);
|
||||
bool modbus_read_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* dest_word_buffer);
|
||||
bool modbus_write_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* src_word_buffer);
|
||||
bool modbus_write_registers(uint16_t start_addr, uint16_t quantity, uint16_t* src_buffer);
|
||||
|
||||
void run_plc_read();
|
||||
void run_plc_write();
|
||||
|
||||
#endif // MODBUS_H
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"m_read": {
|
||||
"start_addr": 1600,
|
||||
"coil_count": 208,
|
||||
"word_count": 13
|
||||
},
|
||||
"m_write": {
|
||||
"start_addr": 3200,
|
||||
"coil_count": 188,
|
||||
"word_count": 12
|
||||
},
|
||||
"d_read": {
|
||||
"start_addr": 2000,
|
||||
"word_count": 20,
|
||||
"groups": 10
|
||||
},
|
||||
"d_write": {
|
||||
"start_addr": 2030,
|
||||
"word_count": 8,
|
||||
"groups": 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
#include "mqtt_handler.h"
|
||||
#include "config.h"
|
||||
#include "modbus.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_wifi.h>
|
||||
|
||||
WiFiClient espClient;
|
||||
PubSubClient client(espClient);
|
||||
|
||||
// --- MQTT ACK 전송 함수 ---
|
||||
void publish_ack(const char* command_id, const char* status) {
|
||||
if (!client.connected()) return;
|
||||
|
||||
JsonDocument doc;
|
||||
doc["command_id"] = command_id;
|
||||
doc["status"] = status;
|
||||
doc["timestamp"] = millis();
|
||||
|
||||
char buffer[128];
|
||||
serializeJson(doc, buffer, sizeof(buffer));
|
||||
client.publish("/line1/ack", buffer);
|
||||
Serial.printf("[MQTT ACK PUBLISH] Published status %s for command %s to /line1/ack\n", status, command_id);
|
||||
}
|
||||
|
||||
// --- MQTT 수신 콜백: [[id, val], ...] 배치 파싱 및 제어 적용 ---
|
||||
void on_mqtt_message(char* topic, byte* payload, unsigned int length) {
|
||||
// 수신된 페이로드 로그 출력
|
||||
char log_buf[256];
|
||||
unsigned int log_len = (length < 255) ? length : 255;
|
||||
memcpy(log_buf, payload, log_len);
|
||||
log_buf[log_len] = '\0';
|
||||
Serial.printf("\n[MQTT RECEIVED] Topic: %s | Len: %d | Payload: %s\n", topic, length, log_buf);
|
||||
|
||||
// 명령(cmd) 토픽 처리
|
||||
if (strcmp(topic, "/line1/cmd") == 0) {
|
||||
char cmd_str[32];
|
||||
unsigned int len = (length < 31) ? length : 31;
|
||||
memcpy(cmd_str, payload, len);
|
||||
cmd_str[len] = '\0';
|
||||
|
||||
char* clean_cmd = cmd_str;
|
||||
if (clean_cmd[0] == '"') clean_cmd++;
|
||||
int clean_len = strlen(clean_cmd);
|
||||
if (clean_len > 0 && clean_cmd[clean_len - 1] == '"') {
|
||||
clean_cmd[clean_len - 1] = '\0';
|
||||
}
|
||||
|
||||
if (strcmp(clean_cmd, "sync") == 0) {
|
||||
Serial.println("[CMD PROCESS] Manual Full-Sync Request Received!");
|
||||
|
||||
if (pending_command_active) {
|
||||
publish_ack(pending_command_id, "FAILED");
|
||||
}
|
||||
|
||||
setup_buffers();
|
||||
currentState = STATE_INIT;
|
||||
lastStateChange = millis();
|
||||
initSequenceStarted = false;
|
||||
|
||||
if (client.connected()) {
|
||||
client.publish("/line1/event", "{\"id\":999,\"val\":1}");
|
||||
Serial.println("[CMD PROCESS] Published event 999 to broker.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, payload, length);
|
||||
if (error) {
|
||||
Serial.printf("[MQTT ERROR] JSON Deserialization failed: %s\n", error.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const char* cmd_id = nullptr;
|
||||
JsonArray arr;
|
||||
bool has_envelope = false;
|
||||
|
||||
// JSON 객체(새로운 Envelope 규격)와 JSON 배열(기존 규격) 둘 다 호환되도록 처리
|
||||
if (doc.is<JsonObject>()) {
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
if (obj["command_id"].is<const char*>() && obj["items"].is<JsonArray>()) {
|
||||
cmd_id = obj["command_id"].as<const char*>();
|
||||
arr = obj["items"].as<JsonArray>();
|
||||
has_envelope = true;
|
||||
Serial.printf("[MQTT ENVELOPE] command_id: %s\n", cmd_id);
|
||||
} else {
|
||||
Serial.println("[MQTT ERROR] JSON Object requires string command_id and array items");
|
||||
return;
|
||||
}
|
||||
} else if (doc.is<JsonArray>()) {
|
||||
arr = doc.as<JsonArray>();
|
||||
} else {
|
||||
Serial.println("[MQTT ERROR] Payload is neither JSON Array nor Object");
|
||||
return;
|
||||
}
|
||||
|
||||
const bool is_m_topic = strcmp(topic, "/line1/m/out") == 0;
|
||||
const bool is_d_topic = strcmp(topic, "/line1/d/out") == 0;
|
||||
bool valid_items = (is_m_topic || is_d_topic) && !arr.isNull() && arr.size() > 0;
|
||||
|
||||
// ArduinoJson 7에서 중첩 배열을 JsonVariant -> JsonArray로 명시 변환해 검증한다.
|
||||
for (JsonVariant item_variant : arr) {
|
||||
if (!item_variant.is<JsonArray>()) {
|
||||
valid_items = false;
|
||||
break;
|
||||
}
|
||||
JsonArray item = item_variant.as<JsonArray>();
|
||||
if (item.size() < 2 || !item[0].is<int>()) {
|
||||
valid_items = false;
|
||||
break;
|
||||
}
|
||||
|
||||
int id = item[0].as<int>();
|
||||
if (is_m_topic) {
|
||||
if (!item[1].is<int>()) {
|
||||
valid_items = false;
|
||||
break;
|
||||
}
|
||||
int val = item[1].as<int>();
|
||||
if (id < 0 || id >= M_OUT_COILS || (val != 0 && val != 1)) {
|
||||
valid_items = false;
|
||||
break;
|
||||
}
|
||||
} else if (is_d_topic) {
|
||||
if (id < 0 || id >= 4 || !item[1].is<float>()) {
|
||||
valid_items = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid_items) {
|
||||
Serial.printf("[MQTT ERROR] Invalid or empty items for topic %s; command will not be written to PLC\n", topic);
|
||||
if (has_envelope && cmd_id != nullptr) {
|
||||
publish_ack(cmd_id, "FAILED");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Envelope 규격 수신 시 상태를 RECEIVED로 즉시 업데이트하고 전송 대기 정보 설정
|
||||
if (has_envelope && cmd_id != nullptr) {
|
||||
if (!plcOnline || currentState == STATE_INIT) {
|
||||
Serial.printf("[MQTT REJECT] Command %s rejected because PLC is offline or in INIT state\n", cmd_id);
|
||||
publish_ack(cmd_id, "FAILED");
|
||||
return;
|
||||
}
|
||||
if (pending_command_active) {
|
||||
Serial.printf("[MQTT WARN] Command %s rejected because command %s is still active (BUSY)\n", cmd_id, pending_command_id);
|
||||
publish_ack(cmd_id, "BUSY");
|
||||
return;
|
||||
}
|
||||
strncpy(pending_command_id, cmd_id, sizeof(pending_command_id) - 1);
|
||||
pending_command_id[sizeof(pending_command_id) - 1] = '\0';
|
||||
pending_command_active = true;
|
||||
pending_command_time = millis();
|
||||
pending_command_type_is_m = is_m_topic;
|
||||
snapshot_pending_command_buffers(pending_command_type_is_m);
|
||||
|
||||
publish_ack(pending_command_id, "RECEIVED");
|
||||
}
|
||||
|
||||
Serial.printf("[MQTT PROCESS] Parsed array size: %d\n", arr.size());
|
||||
|
||||
if (is_m_topic) {
|
||||
for (JsonVariant item_variant : arr) {
|
||||
JsonArray item = item_variant.as<JsonArray>();
|
||||
if (item.size() >= 2) {
|
||||
int id = item[0];
|
||||
int val = item[1];
|
||||
int word_idx = id / 16;
|
||||
int bit_idx = id % 16;
|
||||
|
||||
if (word_idx >= 0 && word_idx < M_OUT_WORDS) {
|
||||
uint16_t before_word = M_Out_New[word_idx];
|
||||
Serial.printf("[M_OUT REG] Requested bit: %d | val: %d | Calculated word_idx: %d, bit_idx: %d\n", id, val, word_idx, bit_idx);
|
||||
if (val) {
|
||||
M_Out_New[word_idx] |= (1 << bit_idx);
|
||||
M_Out_Pending_Write[word_idx] |= (1 << bit_idx);
|
||||
M_Out_Pending_Clear[word_idx] &= ~(1 << bit_idx);
|
||||
} else {
|
||||
M_Out_New[word_idx] &= ~(1 << bit_idx);
|
||||
M_Out_Pending_Write[word_idx] &= ~(1 << bit_idx);
|
||||
M_Out_Pending_Clear[word_idx] &= ~(1 << bit_idx);
|
||||
}
|
||||
Serial.printf("[M_OUT PROCESS completed] Updated M_Out_New[%d] to 0x%04X (Pending Write: 0x%04X)\n", word_idx, M_Out_New[word_idx], M_Out_Pending_Write[word_idx]);
|
||||
Serial.printf(
|
||||
"[M CMD TRACE] command_id=%s ui_bit=M%d plc_target=M%04d%X coil_addr=%d requested=%d word_before=0x%04X word_after=0x%04X pending=0x%04X\n",
|
||||
pending_command_active ? pending_command_id : "legacy",
|
||||
id,
|
||||
200 + word_idx,
|
||||
bit_idx,
|
||||
M_OUT_START_ADDR + id,
|
||||
val,
|
||||
before_word,
|
||||
M_Out_New[word_idx],
|
||||
M_Out_Pending_Write[word_idx]
|
||||
);
|
||||
} else {
|
||||
Serial.printf("[M_OUT LIMIT ERROR] word_idx %d is out of bounds (max: %d)\n", word_idx, M_OUT_WORDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (is_d_topic) {
|
||||
for (JsonVariant item_variant : arr) {
|
||||
JsonArray item = item_variant.as<JsonArray>();
|
||||
if (item.size() >= 2) {
|
||||
int id = item[0];
|
||||
float val = item[1].as<float>();
|
||||
|
||||
if (id >= 0 && id < 4) {
|
||||
uint32_t raw;
|
||||
memcpy(&raw, &val, sizeof(raw));
|
||||
uint16_t w_low = raw & 0xFFFF;
|
||||
uint16_t w_high = (raw >> 16) & 0xFFFF;
|
||||
|
||||
D_Out_New[id * 2] = w_low;
|
||||
D_Out_New[id * 2 + 1] = w_high;
|
||||
D_Out_Pending_Groups |= (1U << id);
|
||||
|
||||
Serial.printf("[D_OUT REG] Requested Group ID: %d | REAL: %.6g | Low Word: 0x%04X, High Word: 0x%04X\n", id, val, w_low, w_high);
|
||||
} else {
|
||||
Serial.printf("[D_OUT LIMIT ERROR] Group ID %d is out of bounds (max: 4)\n", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MQTT 초기화 및 최초 구독 연결 설정 ---
|
||||
void setup_mqtt() {
|
||||
client.setServer(mqtt_server, 1883);
|
||||
client.setCallback(on_mqtt_message);
|
||||
client.setBufferSize(2048);
|
||||
Serial.println("[MQTT SETUP] Server configured to 1883. Buffer size set to 2048.");
|
||||
}
|
||||
|
||||
// --- MQTT 연결 유지 및 비차단 재연결 함수 ---
|
||||
void maintain_mqtt() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.connected()) {
|
||||
unsigned long now = millis();
|
||||
if (now - lastMqttRetry >= 5000) {
|
||||
lastMqttRetry = now;
|
||||
Serial.print("[MQTT LOOP] Attempting connection...");
|
||||
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
char clientId[30];
|
||||
snprintf(clientId, sizeof(clientId), "ESP32_Gateway_%02X%02X%02X", mac[3], mac[4], mac[5]);
|
||||
|
||||
// Last Will & Testament 설정 (어플리케이션 비정상 종료 시 브로커가 OFFLINE 메시지 자동 배포)
|
||||
if (client.connect(clientId, mqtt_user, mqtt_password, "/line1/status", 1, true, "{\"gateway\": \"OFFLINE\"}")) {
|
||||
Serial.println(" OK (connected)");
|
||||
|
||||
// 연결 성공 직후 retained SYNCING 상태 전송 (초기화 중이므로)
|
||||
char status_msg[160];
|
||||
snprintf(status_msg, sizeof(status_msg),
|
||||
"{\"gateway\": \"SYNCING\", \"plc_state\": \"%s\", \"firmware\": \"%s\"}",
|
||||
plcOnline ? "ONLINE" : "OFFLINE", FIRMWARE_VERSION);
|
||||
client.publish("/line1/status", status_msg, true);
|
||||
|
||||
// MQTT 연결 복구 시 Full-Sync 강제 수행 상태로 전이 (동기 정합성 확보)
|
||||
Serial.println("[MQTT LOOP] Reconnected successfully. Triggering Full-Sync...");
|
||||
setup_buffers();
|
||||
currentState = STATE_INIT;
|
||||
lastStateChange = millis();
|
||||
initSequenceStarted = false;
|
||||
|
||||
client.publish("/line1/event", "{\"id\":999,\"val\":1}");
|
||||
|
||||
client.subscribe("/line1/m/out");
|
||||
client.subscribe("/line1/d/out");
|
||||
client.subscribe("/line1/cmd");
|
||||
Serial.println("[MQTT LOOP] Subscribed to /line1/m/out, /line1/d/out, /line1/cmd successfully.");
|
||||
} else {
|
||||
Serial.print(" FAILED, rc=");
|
||||
Serial.println(client.state());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client.loop();
|
||||
|
||||
// 주기적인 (5초 간격) Retained Heartbeat 발행
|
||||
static unsigned long lastHeartbeat = 0;
|
||||
if (millis() - lastHeartbeat >= 5000) {
|
||||
char status_msg[160];
|
||||
const char* gw_state = (currentState == STATE_INIT) ? "SYNCING" : "ONLINE";
|
||||
snprintf(status_msg, sizeof(status_msg),
|
||||
"{\"gateway\": \"%s\", \"plc_state\": \"%s\", \"firmware\": \"%s\"}",
|
||||
gw_state, plcOnline ? "ONLINE" : "OFFLINE", FIRMWARE_VERSION);
|
||||
client.publish("/line1/status", status_msg, true);
|
||||
lastHeartbeat = millis();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 명령 처리 타임아웃 감시 ---
|
||||
// M 명령: 3초. D 명령: D 쓰기 + Apply ON + Apply OFF의 여러 통신 슬롯을 포함하므로 6초.
|
||||
unsigned long cmd_timeout = pending_command_type_is_m ? 3000 : 6000;
|
||||
if (pending_command_active && (millis() - pending_command_time > cmd_timeout)) {
|
||||
Serial.printf("[TIMEOUT] Command %s expired (%lums). Force unlocking and rolling back buffers.\n",
|
||||
pending_command_id, cmd_timeout);
|
||||
publish_ack(pending_command_id, "FAILED");
|
||||
|
||||
// Old는 모멘터리 ON 상태일 수 있으므로, 명령 수신 직전 스냅샷으로 복원한다.
|
||||
rollback_pending_command_buffers();
|
||||
|
||||
// D Apply 펄스가 걸려 있었다면 Apply 비트와 대기 상태를 정리한다.
|
||||
if (d_apply_pending && d_apply_word_idx >= 0 && d_apply_word_idx < M_OUT_WORDS) {
|
||||
M_Out_New[d_apply_word_idx] &= ~d_apply_word_mask;
|
||||
M_Out_Pending_Write[d_apply_word_idx] &= ~d_apply_word_mask;
|
||||
M_Out_Pending_Clear[d_apply_word_idx] &= ~d_apply_word_mask;
|
||||
}
|
||||
d_apply_pending = false;
|
||||
d_apply_word_mask = 0;
|
||||
d_apply_word_idx = -1;
|
||||
|
||||
pending_command_active = false;
|
||||
pending_command_id[0] = '\0';
|
||||
pending_command_time = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- M영역 변경 감지 및 배치 MQTT 전송 (JsonDocument 힙 고갈 방지를 위해 3개 워드씩 Chunk 분할 전송, 워드 경계 일치로 변경 유실 차단) ---
|
||||
bool detect_and_publish_M() {
|
||||
if (!client.connected()) return false;
|
||||
|
||||
bool overall_success = true;
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
|
||||
int pending_words[M_IN_WORDS];
|
||||
int pending_word_count = 0;
|
||||
|
||||
for (int i = 0; i < M_IN_WORDS; i++) {
|
||||
// Full-Sync 대기 상태인 경우 0xFFFF를 대입하여 전체 비트 송신 강제
|
||||
uint16_t diff = full_sync_pending ? 0xFFFF : (M_In_New[i] ^ M_In_Old[i]);
|
||||
if (diff != 0) {
|
||||
pending_words[pending_word_count++] = i;
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if ((diff >> j) & 1) {
|
||||
int bit_id = i * 16 + j;
|
||||
int val = (M_In_New[i] >> j) & 1;
|
||||
|
||||
Serial.printf("[LOG M_IN CHANGE] PLC M-Read Bit Offset %d (M%d) changed to %d\n", bit_id, 1000 + bit_id, val);
|
||||
|
||||
JsonArray pair = arr.add<JsonArray>();
|
||||
pair.add(bit_id);
|
||||
pair.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 워드 경계 정렬을 위해 루프 본문 완료 시점에 3개 워드 단위로 청크 발행 판단
|
||||
if (pending_word_count >= 3) {
|
||||
char buffer[2048];
|
||||
serializeJson(doc, buffer, sizeof(buffer));
|
||||
|
||||
// 발행 성공 시에만 해당 청크에 완전히 포함된 워드들의 Old 버퍼를 New와 동기화
|
||||
bool ok = client.publish("/line1/m/in", buffer);
|
||||
Serial.printf("[M_IN PUBLISH Chunk] size=%d success=%d payload=%s\n", arr.size(), ok, buffer);
|
||||
if (ok) {
|
||||
for (int w = 0; w < pending_word_count; w++) {
|
||||
M_In_Old[pending_words[w]] = M_In_New[pending_words[w]];
|
||||
}
|
||||
} else {
|
||||
overall_success = false;
|
||||
}
|
||||
pending_word_count = 0;
|
||||
|
||||
doc.clear();
|
||||
arr = doc.to<JsonArray>();
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.size() > 0) {
|
||||
char buffer[2048];
|
||||
serializeJson(doc, buffer, sizeof(buffer));
|
||||
|
||||
// 발행 성공 시에만 남은 워드들의 Old 버퍼를 New와 동기화
|
||||
bool ok = client.publish("/line1/m/in", buffer);
|
||||
Serial.printf("[M_IN PUBLISH Chunk Last] size=%d success=%d payload=%s\n", arr.size(), ok, buffer);
|
||||
if (ok) {
|
||||
for (int w = 0; w < pending_word_count; w++) {
|
||||
M_In_Old[pending_words[w]] = M_In_New[pending_words[w]];
|
||||
}
|
||||
} else {
|
||||
overall_success = false;
|
||||
}
|
||||
}
|
||||
return overall_success;
|
||||
}
|
||||
|
||||
// --- D영역 변경 감지 및 배치 MQTT 전송 ---
|
||||
bool detect_and_publish_D() {
|
||||
if (!client.connected()) return false;
|
||||
|
||||
bool overall_success = true;
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
|
||||
int pending_groups[10];
|
||||
int pending_group_count = 0;
|
||||
|
||||
for (int g = 0; g < 10; g++) {
|
||||
uint16_t w_low_new = D_In_New[g * 2];
|
||||
uint16_t w_high_new = D_In_New[g * 2 + 1];
|
||||
|
||||
uint16_t w_low_old = D_In_Old[g * 2];
|
||||
uint16_t w_high_old = D_In_Old[g * 2 + 1];
|
||||
|
||||
// Full-Sync 대기 상태인 경우 무조건 송신 강제
|
||||
if (full_sync_pending || w_low_new != w_low_old || w_high_new != w_high_old) {
|
||||
JsonArray pair = arr.add<JsonArray>();
|
||||
pair.add(g);
|
||||
|
||||
if (g == 0 || g == 5) {
|
||||
// Pump restart time is an unsigned 16-bit value in the first word.
|
||||
// The second word is still read to preserve the fixed 32-bit group layout, but is not part of the value.
|
||||
pair.add(w_low_new);
|
||||
Serial.printf("[LOG D_IN CHANGE] PLC D-Read Group %d (D%d only, UINT16) changed from %u to %u\n",
|
||||
g, 2000 + g * 2, w_low_old, w_low_new);
|
||||
} else {
|
||||
// All other groups contain an IEEE-754 REAL split into low/high PLC words.
|
||||
uint32_t raw_new = ((uint32_t)w_high_new << 16) | w_low_new;
|
||||
uint32_t raw_old = ((uint32_t)w_high_old << 16) | w_low_old;
|
||||
float val;
|
||||
float old_val;
|
||||
memcpy(&val, &raw_new, sizeof(val));
|
||||
memcpy(&old_val, &raw_old, sizeof(old_val));
|
||||
pair.add(val);
|
||||
Serial.printf("[LOG D_IN CHANGE] PLC D-Read Group %d (D%d-D%d, REAL) changed from %.6g to %.6g\n",
|
||||
g, 2000 + g * 2, 2000 + g * 2 + 1, old_val, val);
|
||||
}
|
||||
|
||||
pending_groups[pending_group_count++] = g;
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.size() > 0) {
|
||||
char buffer[1024];
|
||||
serializeJson(doc, buffer, sizeof(buffer));
|
||||
|
||||
// 발행 성공 시에만 해당 그룹의 Old 버퍼를 New와 동기화
|
||||
bool ok = client.publish("/line1/d/in", buffer);
|
||||
Serial.printf("[D_IN PUBLISH Batch] size=%d success=%d payload=%s\n", arr.size(), ok, buffer);
|
||||
if (ok) {
|
||||
for (int w = 0; w < pending_group_count; w++) {
|
||||
int idx = pending_groups[w];
|
||||
D_In_Old[idx * 2] = D_In_New[idx * 2];
|
||||
D_In_Old[idx * 2 + 1] = D_In_New[idx * 2 + 1];
|
||||
}
|
||||
} else {
|
||||
overall_success = false;
|
||||
}
|
||||
}
|
||||
return overall_success;
|
||||
}
|
||||
|
||||
// --- Read 변경사항 종합 프로세스 실행 ---
|
||||
bool process_read_changes() {
|
||||
bool success_m = detect_and_publish_M();
|
||||
bool success_d = detect_and_publish_D();
|
||||
return success_m && success_d;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef MQTT_HANDLER_H
|
||||
#define MQTT_HANDLER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <PubSubClient.h>
|
||||
|
||||
extern PubSubClient client;
|
||||
|
||||
void setup_mqtt();
|
||||
void maintain_mqtt();
|
||||
bool detect_and_publish_M();
|
||||
bool detect_and_publish_D();
|
||||
bool process_read_changes();
|
||||
void publish_ack(const char* command_id, const char* status);
|
||||
|
||||
#endif // MQTT_HANDLER_H
|
||||
@@ -0,0 +1,18 @@
|
||||
[platformio]
|
||||
src_dir = .
|
||||
build_dir = C:/aaa
|
||||
[env:esp32dev]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
|
||||
monitor_speed = 115200
|
||||
upload_port = COM7
|
||||
monitor_port = COM7
|
||||
COM4monitor_encoding = UTF-8 ; 인코딩 강제 지정
|
||||
; 이 프로젝트에서 사용하는 3가지 라이브러리를 정확히 등록합니다.
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson @ ^7.0.0
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
robtillaart/CRC @ ^1.0.3
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# communication package marker
|
||||
from .mqtt_client import start_mqtt_client, stop_mqtt_client
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"lines": [
|
||||
{
|
||||
"line_id": "line1",
|
||||
"equipment_id": 8,
|
||||
"mqtt_prefix": "line1",
|
||||
"plc_slave_id": 1,
|
||||
"description": "양산 1호기 (CC_MC_TYPE_A)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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그룹 | 이송모터 수동 목표 속도"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
# database package marker
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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">×</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">×</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>
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">×</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">×</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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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, '"')})">수정</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, '"')})">수정</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("일지 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("기기 삭제에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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("서버 통신 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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, '"')})">수정</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, '"')})">수정</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("재고 소진 처리에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
@@ -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, '"')})">수정</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}`);
|
||||
}
|
||||
};
|
||||
@@ -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, '"')})">
|
||||
<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;
|
||||
});
|
||||
}
|
||||
@@ -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("설정 적용 도중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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, '"')})">수정</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, '"')})">수정</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("일지 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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">×</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>
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -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">×</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">×</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">×</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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.95.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
paho-mqtt>=1.6.1
|
||||
mysql-connector-python>=8.0.32
|
||||
@@ -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 */;
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,7 @@
|
||||
# setting package marker
|
||||
from .db_manager import (
|
||||
get_all_settings,
|
||||
get_setting,
|
||||
update_settings
|
||||
)
|
||||
from .router import router
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
pip
|
||||
@@ -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, it’s **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 @@
|
||||
pip
|
||||
@@ -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
|
||||
|
||||
[](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
|
||||
[](https://pypi.python.org/pypi/annotated-types)
|
||||
[](https://github.com/annotated-types/annotated-types)
|
||||
[](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!
|
||||