@ -0,0 +1,4 @@ |
|||
# 프로젝트 개발 지침 |
|||
|
|||
- **개발 전 단계**: 작업이나 구현 계획을 세우기 전, 반드시 `ob/` 폴더 내에 있는 시스템 설계 문서 및 관련 문서들을 상세히 읽어 요구사항과 설계 방향을 확인해야 합니다. |
|||
- **개발 후 단계**: 코드 변경 또는 신규 기능 개발 완료 후, 작업 결과와 관련 설계 변경점을 `ob/` 폴더 내의 해당 문서에 성실히 업데이트하고 기록해야 합니다. |
|||
@ -0,0 +1,24 @@ |
|||
; ===================================================================== |
|||
; Mobi Leak Test System - 데이터베이스 및 로그인 설정 파일 (Database.ini) |
|||
; ===================================================================== |
|||
|
|||
; [데이터베이스 접속 상세 설정] |
|||
; Db_Server : 중앙 MSSQL 데이터베이스 서버의 IP 주소 또는 호스트명 |
|||
; Db_Name : 검사 데이터를 저장할 대상 데이터베이스 이름 |
|||
; Db_User : 데이터베이스 접속 계정 ID (예: sa) |
|||
; Db_Password: 데이터베이스 접속 계정의 비밀번호 |
|||
Db_Server=192.168.0.20 |
|||
Db_Name=NE1aW_PT_SENSOR |
|||
Db_User=sa |
|||
Db_Password=sa1234 |
|||
|
|||
|
|||
[Login] |
|||
; Procedure: 로그인 검증에 사용할 DB 저장 프로시저명입니다. (공란일 경우 기본 테이블 조회 쿼리 작동) |
|||
Procedure=dbo.CheckOperator |
|||
|
|||
; ProcessName: 로그인 검증 시 전달할 공정 이름입니다. |
|||
ProcessName=EOL |
|||
|
|||
; OfflinePreview: 오프라인 모드 활성화 여부 (true = DB 연결 없이 로그인 통과, false = 실제 DB 인증) |
|||
OfflinePreview=false |
|||
@ -0,0 +1,99 @@ |
|||
# 6. 측정 데이터 조회 (Data View) |
|||
|
|||
[← 메뉴얼 목차로 돌아가기](사용자_메뉴얼.md) |
|||
|
|||
--- |
|||
|
|||
하단 메뉴에서 **Data (측정 데이터)** 버튼을 클릭하면 표시됩니다. 다시 클릭하면 메인 화면으로 복귀합니다. |
|||
![[Pasted image 20260624104558.png]] |
|||
## 6.1 화면 구성 |
|||
|
|||
``` |
|||
┌───────────────────────────────────────────────────────────────────────────────────────────┐ |
|||
│ INSPECT DATA LIST (측정 기록 조회) │ ← 타이틀 바 |
|||
├───────────────────────────────────────────────────────────────────────────────────────────┤ |
|||
│ [조회기간] 2026-05-01 ~ 2026-05-08 [판정 결과] 전체 │ |
|||
│ [ID 검색] [작업자] [Lot No] [모델] [Search] [CSV] │ ← 2행 필터 영역 |
|||
├──────┬──────┬───────┬────────┬──────┬─────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┼ |
|||
│검사일│검사시│ IC SN │ PCB │메이커│모델 │변형1 │변형2 │작업자│라인 │Lot No│지그 │... │ ← 데이터 그리드 |
|||
├──────┼──────┼───────┼────────┼──────┼─────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼ |
|||
│ ... │ ... │ABC123 │ ABC123 │ MOBI │ EOL │ A │ B │ admin│ 1 │LOT001│ 1 │... │ |
|||
├──────┴──────┴───────┴────────┴──────┴─────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┼ |
|||
│ [ Close ] │ |
|||
└───────────────────────────────────────────────────────────────────────────────────────────┘ |
|||
``` |
|||
|
|||
## 6.2 필터 조건 상세 |
|||
|
|||
### 1행: 공통 기본 필터 |
|||
|
|||
| 항목 | UI 타입 | 기본값 | 설명 | |
|||
|------|---------|--------|------| |
|||
| **조회 기간** | DatePicker (달력) x 2 | 오늘로부터 7일 전 ~ 오늘 | 조회 대상 날짜 범위 설정 (달력 확대 표시 적용) | |
|||
| **판정 결과** | 드롭다운 ComboBox | `[전체]` / `OK` / `NG` | 판정 결과에 따른 필터링 (기본값: 전체) | |
|||
|
|||
### 2행: 상세 검색 필터 (DB 스키마 필드 교차 검색) |
|||
|
|||
| 항목 | UI 타입 | 설명 | 기본값 | |
|||
|------|---------|------|--------| |
|||
| **ID 검색** | TextBox (텍스트 입력) | `IC_SN` 또는 `PCB_Barcode` 컬럼에 대한 부분 일치 (LIKE) 검색 | (빈 값 = 필터 제외) | |
|||
| **작업자** | TextBox (텍스트 입력) | 작업자 ID (`Operator`) 컬럼에 대한 부분 일치 검색 | (빈 값 = 필터 제외) | |
|||
| **Lot No** | TextBox (텍스트 입력) | 랏 번호 (`Lot_No`) 컬럼에 대한 부분 일치 검색 | (빈 값 = 필터 제외) | |
|||
| **모델** | TextBox (텍스트 입력) | 모델명 (`Model`) 컬럼에 대한 부분 일치 검색 | (빈 값 = 필터 제외) | |
|||
|
|||
## 6.3 조회 및 내보내기 버튼 |
|||
|
|||
| 버튼 | 색상 | 동작 | |
|||
|------|------|------| |
|||
| **Search** | 파란색 계열 | 설정된 2행의 모든 필터 조건으로 SQL Server EOL DB(혹은 CSV)를 부분일치 검색하여 그리드에 표시합니다. | |
|||
| **CSV** | 녹색 계열 | 검색 결과를 새 CSV 파일(16개 전체 컬럼 사양)로 저장합니다. | |
|||
|
|||
### Search 버튼 동작 상세 |
|||
1. DB 연결 활성화 시 SQL Server `dbo.EOL` 테이블에서 Like 조건 쿼리 실행. |
|||
2. DB 연결 실패/주소 미설정 시 로컬 CSV 파일에서 인메모리 부분일치 검색 처리 (구형 8컬럼 파일 하위 호환). |
|||
3. 검색 결과가 없으면 "해당 조건의 데이터가 없습니다." 알림 표시. |
|||
|
|||
### CSV 버튼 동작 상세 |
|||
1. 검색 결과가 없으면 "저장할 데이터가 없습니다." 알림 표시. |
|||
2. 파일 저장 대화상자 표시 (기본 파일명: `InspectData_yyyyMMdd_HHmmss.csv`). |
|||
3. 저장 완료 시 "CSV 파일로 저장되었습니다." 알림 표시. |
|||
|
|||
## 6.4 데이터 그리드 컬럼 |
|||
|
|||
| 컬럼 헤더명 | 바인딩 프로퍼티 | 정렬 | 설명 | |
|||
|------|------|------|------| |
|||
| **검사일자** | `InspectDate` | 좌측 | 검사 일자 (yyyy-MM-dd) | |
|||
| **검사시간** | `InspectTime` | 좌측 | 검사 시각 (HH:mm:ss) | |
|||
| **IC SN (제품ID)** | `ProductId` (`IcSn`) | 좌측 | 제품 고유 IC SN 시리얼 번호 | |
|||
| **PCB 바코드** | `PcbBarcode` | 좌측 | PCB 바코드 식별자 | |
|||
| **메이커** | `Maker` | 좌측 | 로그인 시 입력된 제조사명 | |
|||
| **모델** | `Model` | 좌측 | 로그인 시 입력된 검사 모델명 | |
|||
| **변형 1** | `Variant1` | 좌측 | 로그인 시 입력된 변형 1 사양 | |
|||
| **변형 2** | `Variant2` | 좌측 | 로그인 시 입력된 변형 2 사양 | |
|||
| **작업자** | `Operator` | 좌측 | 작업자 사용자 ID | |
|||
| **작업라인** | `Line` | 좌측 | 로그인 공정 작업 라인 | |
|||
| **Lot No** | `LotNo` | 좌측 | 생산 Lot 번호 | |
|||
| **지그 번호** | `JigNo` | 좌측 | 사용 지그(Jig) 번호 | |
|||
| **검사채널** | `Channel` | 좌측 | 검사 통신 채널 (`LEFT` / `RIGHT`) | |
|||
| **SPEC UL** | `SpecUL` | 좌측 | 상한 규격값 | |
|||
| **SPEC LL** | `SpecLL` | 좌측 | 하한 규격값 | |
|||
| **릭 측정값** | `MeasuredValue` | **우측** | 측정된 리크 유량값 (**볼드 표시**) | |
|||
| **최종판정** | `Judgment` | **중앙** | `OK`(녹색) 또는 `NG`(빨간색) 볼드 강조 | |
|||
|
|||
### 그리드 스타일 |
|||
- 격자선: 수평/수직 연회색 격자선 표시 |
|||
- 홀수 행 배경: 흰색, 짝수 행 배경: 연회색 배경으로 행 식별 편의 제공 |
|||
- 선택된 행: 연파랑 배경에 파란색 텍스트로 강조 |
|||
- 컬럼 헤더 클릭 시 해당 열 기준 오름차순/내림차순 정렬 가능 (컬럼 순서 변경 불가) |
|||
- 최소 50행이 항상 표시됩니다 (데이터가 적어도 격자 형태 유지를 위해 빈 행으로 채워짐) |
|||
|
|||
## 6.5 화면 닫기 |
|||
|
|||
| 방법 | 설명 | |
|||
|------|------| |
|||
| 하단 **Close** 버튼 클릭 | 메인 화면으로 복귀 | |
|||
| 하단 메뉴 **Data** 버튼 재클릭 | 토글 동작으로 메인 화면으로 복귀 | |
|||
|
|||
--- |
|||
|
|||
[← 이전: 시험 설정](05_시험_설정.md) | [다음: 자동 시험 프로세스 →](07_시험_프로세스.md) |
|||
@ -1,99 +0,0 @@ |
|||
# 6. 측정 데이터 조회 (Data View) |
|||
|
|||
[← 메뉴얼 목차로 돌아가기](사용자_메뉴얼.md) |
|||
|
|||
--- |
|||
|
|||
하단 메뉴에서 **Data (측정 데이터)** 버튼을 클릭하면 표시됩니다. 다시 클릭하면 메인 화면으로 복귀합니다. |
|||
|
|||
## 7.1 화면 구성 |
|||
|
|||
``` |
|||
┌───────────────────────────────────────────────────────────┐ |
|||
│ INSPECT DATA LIST (측정 기록 조회) │ ← 타이틀 바 |
|||
├───────────────────────────────────────────────────────────┤ |
|||
│ [조회기간] 2026-05-01 ~ 2026-05-08 [판정] 전체 [ID검색] │ |
|||
│ [Search] [CSV] │ ← 필터 영역 |
|||
├──────┬──────┬───────┬────┬──────┬──────┬──────┬────┬─────┤ |
|||
│ 날짜 │재시험│ ID │좌우│UL │LL │측정값│판정│비고 │ ← 데이터 그리드 |
|||
│ │ │ │ │ │ │ │ │ │ |
|||
│ ... │ N │ABC123 │LEFT│ 1.00 │-1.00 │0.5234│ OK │ │ |
|||
│ ... │ N │DEF456 │RIGHT│1.00 │-1.00 │1.2000│ NG │ │ |
|||
├──────┴──────┴───────┴────┴──────┴──────┴──────┴────┴─────┤ |
|||
│ [ Close ] │ |
|||
└───────────────────────────────────────────────────────────┘ |
|||
``` |
|||
|
|||
## 6.2 필터 조건 상세 |
|||
|
|||
### 조회 기간 (폭 320px) |
|||
|
|||
| 항목 | UI 타입 | 기본값 | 설명 | |
|||
|------|---------|--------|------| |
|||
| **시작일** | DatePicker (달력) | 오늘로부터 7일 전 | 조회 시작 날짜 | |
|||
| **종료일** | DatePicker (달력) | 오늘 | 조회 종료 날짜 | |
|||
|
|||
> **참고**: 달력 팝업은 1.25배 확대되어 표시됩니다. |
|||
|
|||
### 판정 결과 (폭 120px) |
|||
|
|||
| 항목 | UI 타입 | 선택 가능 값 | 기본값 | |
|||
|------|---------|-------------|--------| |
|||
| **판정 결과** | 드롭다운 | `[전체]` / `OK` / `NG` | `[전체]` | |
|||
|
|||
### ID 검색 (폭 280px) |
|||
|
|||
| 항목 | UI 타입 | 설명 | 기본값 | |
|||
|------|---------|------|--------| |
|||
| **ID 검색** | 텍스트 입력 | 제품 ID에 포함된 텍스트로 부분 일치 검색 | (빈 값 = 전체) | |
|||
|
|||
## 6.3 조회 및 내보내기 버튼 |
|||
|
|||
| 버튼 | 색상 | 크기 | 동작 | |
|||
|------|------|------|------| |
|||
| **Search** | 연파랑 배경, 파랑 테두리 | 110×45px | 설정된 필터 조건으로 Logs 폴더의 CSV 파일을 검색합니다 | |
|||
| **CSV** | 연녹색 배경, 녹색 테두리 | 110×45px | 검색 결과를 새 CSV 파일로 저장합니다 (저장 위치 선택 대화상자) | |
|||
|
|||
### Search 버튼 동작 상세 |
|||
1. `[프로그램폴더]\Logs\` 폴더에서 조회 기간에 해당하는 `yyyy-MM-dd.csv` 파일을 탐색 |
|||
2. 각 CSV 파일의 데이터를 판정/ID 필터 조건으로 필터링 |
|||
3. 결과를 날짜 역순(최신순)으로 정렬하여 데이터 그리드에 표시 |
|||
4. 검색 결과가 없으면 "해당 조건의 데이터가 없습니다." 알림 표시 |
|||
|
|||
### CSV 버튼 동작 상세 |
|||
1. 검색 결과가 없으면 "저장할 데이터가 없습니다." 알림 표시 |
|||
2. 파일 저장 대화상자 표시 (기본 파일명: `InspectData_yyyyMMdd_HHmmss.csv`) |
|||
3. 저장 완료 시 "CSV 파일로 저장되었습니다." 알림 표시 |
|||
|
|||
## 6.4 데이터 그리드 컬럼 |
|||
|
|||
| 컬럼 | 폭 | 정렬 | 설명 | |
|||
|------|-----|------|------| |
|||
| **날짜** | 130px | 좌측 | 검사 일자 (yyyy-MM-dd 형식) | |
|||
| **재시험** | 70px | 좌측 | 재시험 여부 (`Y` 또는 `N`) | |
|||
| **ID** | 250px | 좌측 | 제품 ID (ZMDI 센서에서 읽은 값) | |
|||
| **좌/우** | 70px | 좌측 | 검사 채널 (`LEFT` 또는 `RIGHT`) | |
|||
| **SPEC UL** | 80px | 좌측 | 검사 시점의 상한 규격값 | |
|||
| **SPEC LL** | 80px | 좌측 | 검사 시점의 하한 규격값 | |
|||
| **측정값** | 100px | **우측** | 측정된 결과 sccm (**볼드**) | |
|||
| **판정** | 80px | **중앙** | `OK`(녹색, #27AE60) 또는 `NG`(빨간색, #C0392B), **ExtraBold** | |
|||
| **비고** | 나머지(300px~) | 좌측 | 추가 메모 (현재 미사용) | |
|||
|
|||
### 그리드 스타일 |
|||
- 행 높이: 35px |
|||
- 격자선: 수평/수직 모두 연회색(#ECF0F1) |
|||
- 홀수 행 배경: 흰색, 짝수 행 배경: 연회색(#F9FBFC) |
|||
- 선택된 행: 연파랑 배경(#D6EAF8), 파란색 텍스트(#2E86C1) |
|||
- 컬럼 헤더 정렬 가능, 컬럼 순서 변경 불가 |
|||
- 최소 50행이 항상 표시됩니다 (데이터가 적어도 빈 행으로 채워짐) |
|||
|
|||
## 6.5 화면 닫기 |
|||
|
|||
| 방법 | 설명 | |
|||
|------|------| |
|||
| 하단 **Close** 버튼 클릭 | 메인 화면으로 복귀 | |
|||
| 하단 메뉴 **Data** 버튼 재클릭 | 토글 동작으로 메인 화면으로 복귀 | |
|||
|
|||
--- |
|||
|
|||
[← 이전: 시험 설정](05_시험_설정.md) | [다음: 자동 시험 프로세스 →](08_시험_프로세스.md) |
|||
@ -0,0 +1,219 @@ |
|||
# 8. 로그 및 데이터 관리 |
|||
|
|||
[← 메뉴얼 목차로 돌아가기](사용자_메뉴얼.md) |
|||
|
|||
--- |
|||
|
|||
## 8.1 검사 데이터 로그 (CSV) |
|||
|
|||
### 기본 정보 |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **저장 위치** | `[프로그램 폴더]\Logs\yyyy-MM-dd.csv` | |
|||
| **파일 형식** | CSV (UTF-8 인코딩) | |
|||
| **파일 생성** | 날짜별 자동 생성 (해당 날짜 첫 시험 시) | |
|||
| **데이터 추가** | 매 시험 완료 시 자동으로 해당 날짜 파일에 1행 추가 | |
|||
|
|||
### CSV 헤더 및 컬럼 |
|||
|
|||
``` |
|||
IC_SN,PCB_Barcode,Maker,Model,Variant_1,Variant_2,Operator,Production_Date,Line,Lot_No,Jig_No,Channel,Spec_UL,Spec_LL,Leak_Value,Result |
|||
``` |
|||
|
|||
| 컬럼 | 예시 | 설명 | |
|||
|------|------|------| |
|||
| `IC_SN` | `260508A00123B01C` | 제품 고유 시리얼 번호 (PK/FK) | |
|||
| `PCB_Barcode` | `260508A00123B01C` | PCB 바코드 고유 식별 정보 (UK) | |
|||
| `Maker` | `Hyundai` | 메이커 정보 | |
|||
| `Model` | `NE1aW` | 모델 정보 | |
|||
| `Variant_1` | `PT` | 변형1 정보 | |
|||
| `Variant_2` | `SENSOR` | 변형2 정보 | |
|||
| `Operator` | `op001` | 작업자 ID | |
|||
| `Production_Date` | `2026-05-08 10:30:15` | 검사 수행 일시 | |
|||
| `Line` | `L1` | 작업 라인 번호 | |
|||
| `Lot_No` | `LOT001` | Lot 번호 | |
|||
| `Jig_No` | `JIG01` | 지그 번호 | |
|||
| `Channel` | `LEFT` | 검사 채널 (LEFT 또는 RIGHT) | |
|||
| `Spec_UL` | `1.00` | 규격 상한값 | |
|||
| `Spec_LL` | `-1.00` | 규격 하한값 | |
|||
| `Leak_Value` | `0.5234` | 측정된 릭 측정값 (소수점 4자리) | |
|||
| `Result` | `OK` | 최종 판정 결과 (OK 또는 NG) | |
|||
|
|||
### CSV 예시 |
|||
```csv |
|||
IC_SN,PCB_Barcode,Maker,Model,Variant_1,Variant_2,Operator,Production_Date,Line,Lot_No,Jig_No,Channel,Spec_UL,Spec_LL,Leak_Value,Result |
|||
260508A00123B01C,260508A00123B01C,Hyundai,NE1aW,PT,SENSOR,op001,2026-05-08 10:30:15,L1,LOT001,JIG01,LEFT,1.00,-1.00,0.5234,OK |
|||
260508A00456D02E,260508A00456D02E,Hyundai,NE1aW,PT,SENSOR,op002,2026-05-08 10:30:45,L1,LOT001,JIG01,RIGHT,1.00,-1.00,1.2000,NG |
|||
``` |
|||
|
|||
> **참고**: 타임아웃 발생 시에도 측정값 `0.0000`, 판정 `NG`로 로그에 기록됩니다. |
|||
|
|||
> **참고**: 프로그램은 구형 8개 컬럼 포맷(`Date,Time,ID,Channel,SpecUL,SpecLL,Value,Judgment`)의 로그 파일에 대해서도 컬럼 크기를 기준으로 자동 감지 및 파싱(하위 호환성)을 수행합니다. |
|||
|
|||
> **참고**: CSV 값에 쉼표, 따옴표, 줄바꿈이 포함된 경우 자동으로 이스케이프 처리됩니다. |
|||
|
|||
--- |
|||
|
|||
## 8.2 시스템 로그 |
|||
|
|||
### 기본 정보 |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **저장 위치** | `[프로그램 폴더]\Logs\yyyy-MM-dd_system.log` | |
|||
| **파일 형식** | 텍스트 파일 (UTF-8 인코딩) | |
|||
| **내용** | 통신 오류, 시스템 이벤트 등 | |
|||
|
|||
### 로그 형식 |
|||
``` |
|||
[HH:mm:ss.fff] [TAG] 메시지 내용 |
|||
``` |
|||
|
|||
### 예시 |
|||
``` |
|||
[10:30:00.123] [SYSTEM] 프로그램 시작 |
|||
[10:30:01.456] [COMM] Sentinel C28 포트 연결 성공 (COM1) |
|||
[10:35:15.789] [ERROR] Sentinel C28 통신 타임아웃 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 8.3 설정 파일 |
|||
|
|||
### config.xml |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **위치** | 프로그램 실행 폴더 | |
|||
| **생성** | 최초 실행 시 기본값으로 자동 생성 | |
|||
| **수정** | Comm. Settings 또는 Parameters에서 저장 시 갱신 | |
|||
|
|||
```xml |
|||
<AppConfig> |
|||
<SensorPort>COM1</SensorPort> |
|||
<SensorBaudRate>9600</SensorBaudRate> |
|||
<Board4251Port>COM3</Board4251Port> |
|||
<Board4251BaudRate>115200</Board4251BaudRate> |
|||
<Board4251Timeout>5000</Board4251Timeout> |
|||
<SpecUL>1.00</SpecUL> |
|||
<SpecLL>-1.00</SpecLL> |
|||
</AppConfig> |
|||
``` |
|||
|
|||
| 설정 키 | 기본값 | 설정 위치 | 설명 | |
|||
|---------|--------|----------|------| |
|||
| `SensorPort` | COM1 | Comm. Settings | C28 센서 COM 포트 | |
|||
| `SensorBaudRate` | 9600 | Comm. Settings | C28 센서 통신 속도 | |
|||
| `Board4251Port` | COM3 | Comm. Settings | 4251 보드 COM 포트 | |
|||
| `Board4251BaudRate` | 115200 | Comm. Settings | 4251 보드 통신 속도 | |
|||
| `Board4251Timeout` | 5000 | (코드 내 고정) | 4251 보드 응답 대기 시간(ms) | |
|||
| `SpecUL` | 1.00 | Parameters | 누설 상한 규격값 | |
|||
| `SpecLL` | -1.00 | Parameters | 누설 하한 규격값 | |
|||
|
|||
### Database.ini |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **위치** | 프로그램 실행 폴더 | |
|||
| **생성** | 배포 시 기본 포함 및 파일 누락 상태로 기동 시 기본 양식이 자동 생성됨 | |
|||
| **수정** | 메모장 등의 텍스트 에디터를 통해 연결 주소 및 프로시저 정보 직접 수정 | |
|||
|
|||
```ini |
|||
; [데이터베이스 접속 상세 설정] |
|||
Db_Server=192.168.0.20 |
|||
Db_Name=NE1aW_PT_SENSOR |
|||
Db_User=sa |
|||
Db_Password=sa1234 |
|||
|
|||
[Login] |
|||
Procedure=dbo.CheckOperator |
|||
ProcessName=EOL |
|||
OfflinePreview=false |
|||
``` |
|||
|
|||
| 설정 키 | 예시 | 설명 | |
|||
|---------|--------|------| |
|||
| `Db_Server` | 192.168.0.20 | 데이터베이스 서버 IP 주소 또는 호스트명 | |
|||
| `Db_Name` | NE1aW_PT_SENSOR | 대상 데이터베이스(DB) 이름 | |
|||
| `Db_User` | sa | 데이터베이스 접속 계정 ID | |
|||
| `Db_Password` | sa1234 | 데이터베이스 접속 계정 비밀번호 | |
|||
| `Procedure` | dbo.CheckOperator | 로그인 계정 검증용 저장 프로시저명 | |
|||
| `ProcessName` | EOL | 검증 공정명 | |
|||
| `OfflinePreview` | false | true 시 DB 연결 없이 로그인 즉시 성공 (오프라인 모드) | |
|||
|
|||
### DioConfig.ini |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **위치** | `[프로그램 폴더]\Settings\DioConfig.ini` | |
|||
| **생성** | 최초 실행 시 자동 생성 | |
|||
| **수정** | 일반적으로 수정 불필요 (하드웨어 변경 시에만) | |
|||
|
|||
```ini |
|||
[DIO] |
|||
CompanyName=ADLink |
|||
BoardCnt=1 |
|||
|
|||
[BOARD_1] |
|||
BoardType=PCI_7432 |
|||
BoardNumber=0 |
|||
|
|||
[BOARD_1_IN] |
|||
0=LEFT_START,1,좌측 시험 시작 신호 |
|||
1=RIGHT_START,1,우측 시험 시작 신호 |
|||
|
|||
[BOARD_1_OUT] |
|||
0=LEFT_OK,1,좌측 합격 출력 |
|||
1=RIGHT_OK,1,우측 합격 출력 |
|||
2=PC_ON,1,PC 가동 신호 |
|||
3=LEFT_NG,1,좌측 불합격 출력 |
|||
4=RIGHT_NG,1,우측 불합격 출력 |
|||
``` |
|||
|
|||
**INI 포맷 설명**: `비트인덱스=신호명,활성여부,설명` |
|||
|
|||
--- |
|||
|
|||
## 8.4 파일 관리 주의사항 |
|||
|
|||
| 주의사항 | 설명 | |
|||
|---------|------| |
|||
| Logs 폴더 삭제 금지 | 검사 기록이 영구 삭제됩니다 | |
|||
| CSV 파일 직접 편집 | Excel 등으로 편집 시 인코딩이 변경될 수 있으므로 주의 | |
|||
| config.xml 직접 편집 | 잘못된 값 입력 시 프로그램 오작동 가능. 프로그램 내 설정 화면 사용 권장 | |
|||
| 디스크 용량 | 일일 수백 건 검사 기준, 연간 약 50~100MB 정도의 로그가 생성됩니다 | |
|||
|
|||
--- |
|||
|
|||
## 8.5 데이터베이스 데이터 관리 (dbo.EOL) |
|||
|
|||
매 시험 완료 시 검사 결과 및 로그인 정보가 데이터베이스에 실시간으로 저장(MERGE/UPSERT)됩니다. |
|||
|
|||
### 데이터베이스 테이블 및 컬럼 매핑 |
|||
|
|||
* **대상 테이블**: `[dbo].[EOL]` |
|||
* **매핑 상세**: |
|||
|
|||
| DB 컬럼명 | 데이터 타입 | 매핑되는 C# 데이터 | 비고 | |
|||
| :--- | :--- | :--- | :--- | |
|||
| **`IC_SN`** | `NVARCHAR(10)` | 제품의 최종 조합 ID (`inspectData.ProductId`) | **PK / FK** | |
|||
| **`PCB_Barcode`** | `NVARCHAR(10)` | 고유 식별용 바코드 정보 (`inspectData.ProductId` 매핑) | **UK** | |
|||
| `Maker` | `NVARCHAR(10)` | 로그인 제조사 정보 (`App.LoginResult.Maker`) | | |
|||
| `Model` | `NVARCHAR(10)` | 로그인 모델 정보 (`App.LoginResult.Model`) | | |
|||
| `Variant_1` | `NVARCHAR(10)` | 로그인 변형1 정보 (`App.LoginResult.Variant1`) | | |
|||
| `Variant_2` | `NVARCHAR(10)` | 로그인 변형2 정보 (`App.LoginResult.Variant2`) | | |
|||
| `Operator` | `NVARCHAR(10)` | 로그인 검사자 ID (`App.LoginResult.Operator`) | | |
|||
| `Production_Date` | `DATETIME2(0)` | 검사 수행 일시 (`DateTime.Now`) | | |
|||
| `Line` | `NVARCHAR(5)` | 로그인 작업 라인 (`App.LoginResult.LineNo`) | | |
|||
| `Lot_No` | `NVARCHAR(8)` | 로그인 Lot 번호 (`App.LoginResult.LotNo`) | | |
|||
| `Jig_No` | `NVARCHAR(10)` | 로그인 지그 번호 (`App.LoginResult.JigNo`) | | |
|||
| `Channel` | `NVARCHAR(5)` | 검사 채널 (`LEFT` / `RIGHT`) | | |
|||
| `Spec_UL` | `DECIMAL(3,2)` | 규격 상한값 (소수점 2자리) | | |
|||
| `Spec_LL` | `DECIMAL(3,2)` | 규격 하한값 (소수점 2자리) | | |
|||
| `Leak_Value` | `DECIMAL(5,4)` | 측정된 릭 값 (소수점 4자리) | | |
|||
| `Result` | `NVARCHAR(5)` | 최종 판정 결과 (`OK` 또는 `NG`) | | |
|||
|
|||
--- |
|||
|
|||
[← 이전: 시험 프로세스](07_시험_프로세스.md) | [다음: 오류 대응 →](09_오류_대응.md) |
|||
@ -1,150 +0,0 @@ |
|||
# 8. 로그 및 데이터 관리 |
|||
|
|||
[← 메뉴얼 목차로 돌아가기](사용자_메뉴얼.md) |
|||
|
|||
--- |
|||
|
|||
## 8.1 검사 데이터 로그 (CSV) |
|||
|
|||
### 기본 정보 |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **저장 위치** | `[프로그램 폴더]\Logs\yyyy-MM-dd.csv` | |
|||
| **파일 형식** | CSV (UTF-8 인코딩) | |
|||
| **파일 생성** | 날짜별 자동 생성 (해당 날짜 첫 시험 시) | |
|||
| **데이터 추가** | 매 시험 완료 시 자동으로 해당 날짜 파일에 1행 추가 | |
|||
|
|||
### CSV 헤더 및 컬럼 |
|||
|
|||
``` |
|||
Date,Time,Channel,ID,Value,Judgment,SpecUL,SpecLL,Retest |
|||
``` |
|||
|
|||
| 컬럼 | 예시 | 설명 | |
|||
|------|------|------| |
|||
| `Date` | `2026-05-08` | 검사 일자 | |
|||
| `Time` | `10:30:15` | 검사 시각 | |
|||
| `Channel` | `LEFT` | 검사 채널 (LEFT 또는 RIGHT) | |
|||
| `ID` | `260508A00123B01C` | 제품 ID | |
|||
| `Value` | `0.5234` | 측정값 (sccm, 소수점 4자리) | |
|||
| `Judgment` | `OK` | 판정 결과 (OK 또는 NG) | |
|||
| `SpecUL` | `1.00` | 검사 시점의 상한 규격값 | |
|||
| `SpecLL` | `-1.00` | 검사 시점의 하한 규격값 | |
|||
| `Retest` | `N` | 재시험 여부 (Y 또는 N) | |
|||
|
|||
### CSV 예시 |
|||
```csv |
|||
Date,Time,Channel,ID,Value,Judgment,SpecUL,SpecLL,Retest |
|||
2026-05-08,10:30:15,LEFT,260508A00123B01C,0.5234,OK,1.00,-1.00,N |
|||
2026-05-08,10:30:45,RIGHT,260508A00456D02E,1.2000,NG,1.00,-1.00,N |
|||
2026-05-08,10:31:20,LEFT,260508A00789F03G,0.0000,NG,1.00,-1.00,N |
|||
``` |
|||
|
|||
> **참고**: 타임아웃 발생 시에도 측정값 `0.0000`, 판정 `NG`로 로그에 기록됩니다. |
|||
|
|||
> **참고**: CSV 값에 쉼표, 따옴표, 줄바꿈이 포함된 경우 자동으로 이스케이프 처리됩니다. |
|||
|
|||
--- |
|||
|
|||
## 8.2 시스템 로그 |
|||
|
|||
### 기본 정보 |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **저장 위치** | `[프로그램 폴더]\Logs\yyyy-MM-dd_system.log` | |
|||
| **파일 형식** | 텍스트 파일 (UTF-8 인코딩) | |
|||
| **내용** | 통신 오류, 시스템 이벤트 등 | |
|||
|
|||
### 로그 형식 |
|||
``` |
|||
[HH:mm:ss.fff] [TAG] 메시지 내용 |
|||
``` |
|||
|
|||
### 예시 |
|||
``` |
|||
[10:30:00.123] [SYSTEM] 프로그램 시작 |
|||
[10:30:01.456] [COMM] Sentinel C28 포트 연결 성공 (COM1) |
|||
[10:35:15.789] [ERROR] Sentinel C28 통신 타임아웃 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 8.3 설정 파일 |
|||
|
|||
### config.xml |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **위치** | 프로그램 실행 폴더 | |
|||
| **생성** | 최초 실행 시 기본값으로 자동 생성 | |
|||
| **수정** | Comm. Settings 또는 Parameters에서 저장 시 갱신 | |
|||
|
|||
```xml |
|||
<AppConfig> |
|||
<SensorPort>COM1</SensorPort> |
|||
<SensorBaudRate>9600</SensorBaudRate> |
|||
<Board4251Port>COM3</Board4251Port> |
|||
<Board4251BaudRate>115200</Board4251BaudRate> |
|||
<Board4251Timeout>5000</Board4251Timeout> |
|||
<SpecUL>1.00</SpecUL> |
|||
<SpecLL>-1.00</SpecLL> |
|||
</AppConfig> |
|||
``` |
|||
|
|||
| 설정 키 | 기본값 | 설정 위치 | 설명 | |
|||
|---------|--------|----------|------| |
|||
| `SensorPort` | COM1 | Comm. Settings | C28 센서 COM 포트 | |
|||
| `SensorBaudRate` | 9600 | Comm. Settings | C28 센서 통신 속도 | |
|||
| `Board4251Port` | COM3 | Comm. Settings | 4251 보드 COM 포트 | |
|||
| `Board4251BaudRate` | 115200 | Comm. Settings | 4251 보드 통신 속도 | |
|||
| `Board4251Timeout` | 5000 | (코드 내 고정) | 4251 보드 응답 대기 시간(ms) | |
|||
| `SpecUL` | 1.00 | Parameters | 누설 상한 규격값 | |
|||
| `SpecLL` | -1.00 | Parameters | 누설 하한 규격값 | |
|||
|
|||
### DioConfig.ini |
|||
|
|||
| 항목 | 내용 | |
|||
|------|------| |
|||
| **위치** | `[프로그램 폴더]\Settings\DioConfig.ini` | |
|||
| **생성** | 최초 실행 시 자동 생성 | |
|||
| **수정** | 일반적으로 수정 불필요 (하드웨어 변경 시에만) | |
|||
|
|||
```ini |
|||
[DIO] |
|||
CompanyName=ADLink |
|||
BoardCnt=1 |
|||
|
|||
[BOARD_1] |
|||
BoardType=PCI_7432 |
|||
BoardNumber=0 |
|||
|
|||
[BOARD_1_IN] |
|||
0=LEFT_START,1,좌측 시험 시작 신호 |
|||
1=RIGHT_START,1,우측 시험 시작 신호 |
|||
|
|||
[BOARD_1_OUT] |
|||
0=LEFT_OK,1,좌측 합격 출력 |
|||
1=RIGHT_OK,1,우측 합격 출력 |
|||
2=PC_ON,1,PC 가동 신호 |
|||
3=LEFT_NG,1,좌측 불합격 출력 |
|||
4=RIGHT_NG,1,우측 불합격 출력 |
|||
``` |
|||
|
|||
**INI 포맷 설명**: `비트인덱스=신호명,활성여부,설명` |
|||
|
|||
--- |
|||
|
|||
## 8.4 파일 관리 주의사항 |
|||
|
|||
| 주의사항 | 설명 | |
|||
|---------|------| |
|||
| Logs 폴더 삭제 금지 | 검사 기록이 영구 삭제됩니다 | |
|||
| CSV 파일 직접 편집 | Excel 등으로 편집 시 인코딩이 변경될 수 있으므로 주의 | |
|||
| config.xml 직접 편집 | 잘못된 값 입력 시 프로그램 오작동 가능. 프로그램 내 설정 화면 사용 권장 | |
|||
| 디스크 용량 | 일일 수백 건 검사 기준, 연간 약 50~100MB 정도의 로그가 생성됩니다 | |
|||
|
|||
--- |
|||
|
|||
[← 이전: 시험 프로세스](08_시험_프로세스.md) | [다음: 오류 대응 →](10_오류_대응.md) |
|||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 30 KiB |
@ -0,0 +1,122 @@ |
|||
using System; |
|||
using System.IO; |
|||
|
|||
namespace Housing.Services |
|||
{ |
|||
public class DatabaseSettings |
|||
{ |
|||
public string Db_Server { get; set; } = string.Empty; |
|||
public string Db_Name { get; set; } = string.Empty; |
|||
public string Db_User { get; set; } = string.Empty; |
|||
public string Db_Password { get; set; } = string.Empty; |
|||
|
|||
private string _rawConnectionString = string.Empty; |
|||
|
|||
public string ConnectionString |
|||
{ |
|||
get |
|||
{ |
|||
// 개별 설정이 모두 지정되어 있는 경우 조합해서 사용
|
|||
if (!string.IsNullOrWhiteSpace(Db_Server) && |
|||
!string.IsNullOrWhiteSpace(Db_Name) && |
|||
!string.IsNullOrWhiteSpace(Db_User) && |
|||
!string.IsNullOrWhiteSpace(Db_Password)) |
|||
{ |
|||
return $"Server={Db_Server};Database={Db_Name};User Id={Db_User};Password={Db_Password};"; |
|||
} |
|||
// 없으면 레거시 ConnectionString 필드를 그대로 사용
|
|||
return _rawConnectionString; |
|||
} |
|||
set |
|||
{ |
|||
_rawConnectionString = value; |
|||
} |
|||
} |
|||
|
|||
private static readonly object _dbLock = new object(); |
|||
private static DatabaseSettings _cachedSettings; |
|||
private static DateTime _lastWriteTime; |
|||
private static string _cachedPath; |
|||
|
|||
public static DatabaseSettings Load(string path) |
|||
{ |
|||
lock (_dbLock) |
|||
{ |
|||
try |
|||
{ |
|||
if (File.Exists(path)) |
|||
{ |
|||
var currentWriteTime = File.GetLastWriteTime(path); |
|||
if (_cachedSettings != null && _cachedPath == path && _lastWriteTime == currentWriteTime) |
|||
{ |
|||
return _cachedSettings; |
|||
} |
|||
|
|||
var settings = new DatabaseSettings(); |
|||
var lines = File.ReadAllLines(path); |
|||
foreach (var line in lines) |
|||
{ |
|||
var trimmed = line.Trim(); |
|||
|
|||
// 주석 행 스킵
|
|||
if (trimmed.StartsWith(";") || trimmed.StartsWith("#")) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (trimmed.StartsWith("ConnectionString", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.ConnectionString = parts[1].Trim(); |
|||
} |
|||
} |
|||
else if (trimmed.StartsWith("Db_Server", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.Db_Server = parts[1].Trim(); |
|||
} |
|||
} |
|||
else if (trimmed.StartsWith("Db_Name", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.Db_Name = parts[1].Trim(); |
|||
} |
|||
} |
|||
else if (trimmed.StartsWith("Db_User", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.Db_User = parts[1].Trim(); |
|||
} |
|||
} |
|||
else if (trimmed.StartsWith("Db_Password", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.Db_Password = parts[1].Trim(); |
|||
} |
|||
} |
|||
} |
|||
_cachedSettings = settings; |
|||
_lastWriteTime = currentWriteTime; |
|||
_cachedPath = path; |
|||
return settings; |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
System.Diagnostics.Debug.WriteLine($"[DatabaseSettings Load Error] {ex.Message}"); |
|||
} |
|||
return _cachedSettings ?? new DatabaseSettings(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,99 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Housing.Services |
|||
{ |
|||
public class LoginAccountRepository |
|||
{ |
|||
private readonly DatabaseSettings _dbSettings; |
|||
private readonly LoginSettings _loginSettings; |
|||
|
|||
public LoginAccountRepository(DatabaseSettings dbSettings, LoginSettings loginSettings) |
|||
{ |
|||
_dbSettings = dbSettings; |
|||
_loginSettings = loginSettings; |
|||
} |
|||
|
|||
public async Task<bool> ExistsAsync(string operatorId, string password) |
|||
{ |
|||
// 1. 데모 계정(test/test)인 경우 DB 조회 없이 즉시 로그인 통과
|
|||
if (IsDefaultAccount(operatorId, password)) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
// 2. 오프라인 모드인 경우 기본 성공
|
|||
if (_loginSettings.OfflinePreview) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
// DB 커넥션 스트링이 비어있으면 실패 처리
|
|||
if (string.IsNullOrWhiteSpace(_dbSettings.ConnectionString)) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
// 실제 DB에서 계정을 조회해보기 위한 시도
|
|||
using (var conn = new System.Data.SqlClient.SqlConnection(_dbSettings.ConnectionString)) |
|||
{ |
|||
await conn.OpenAsync(); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(_loginSettings.Procedure)) |
|||
{ |
|||
// 저장 프로시저 호출
|
|||
using (var cmd = new System.Data.SqlClient.SqlCommand(_loginSettings.Procedure, conn)) |
|||
{ |
|||
cmd.CommandType = System.Data.CommandType.StoredProcedure; |
|||
cmd.Parameters.AddWithValue("@OperatorId", operatorId); |
|||
cmd.Parameters.AddWithValue("@Password", password); |
|||
cmd.Parameters.AddWithValue("@ProcessName", _loginSettings.ProcessName ?? string.Empty); |
|||
|
|||
var result = await cmd.ExecuteScalarAsync(); |
|||
if (result != null && result != DBNull.Value) |
|||
{ |
|||
if (int.TryParse(result.ToString(), out var count)) |
|||
{ |
|||
return count > 0; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
// 일반 쿼리 수행
|
|||
const string query = "SELECT COUNT(*) FROM [Users] WHERE [OperatorId] = @Id AND [Password] = @Pw"; |
|||
using (var cmd = new System.Data.SqlClient.SqlCommand(query, conn)) |
|||
{ |
|||
cmd.Parameters.AddWithValue("@Id", operatorId); |
|||
cmd.Parameters.AddWithValue("@Pw", password); |
|||
|
|||
var count = (int)await cmd.ExecuteScalarAsync(); |
|||
return count > 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
System.Diagnostics.Debug.WriteLine($"[LoginAccountRepository ExistsAsync Error] {ex.Message}"); |
|||
// DB 연결이나 쿼리 오류 발생 시 로그인 실패 처리
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
private bool IsDefaultAccount(string operatorId, string password) |
|||
{ |
|||
// 기본 개발/테스트용 데모 계정 처리 (test/test만 허용)
|
|||
if (string.Equals(operatorId, "test", StringComparison.OrdinalIgnoreCase) && |
|||
string.Equals(password, "test", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return true; |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,147 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Xml.Serialization; |
|||
|
|||
namespace Housing.Services |
|||
{ |
|||
// XML 직렬화를 위한 데이터 컨테이너 클래스 정의
|
|||
[XmlRoot("LoginHistory")] |
|||
public class LoginHistoryData |
|||
{ |
|||
[XmlElement("Category")] |
|||
public List<HistoryCategory> Categories { get; set; } = new List<HistoryCategory>(); |
|||
} |
|||
|
|||
public class HistoryCategory |
|||
{ |
|||
[XmlAttribute("Name")] |
|||
public string Name { get; set; } |
|||
|
|||
[XmlElement("Value")] |
|||
public List<string> Values { get; set; } = new List<string>(); |
|||
} |
|||
|
|||
public class LoginHistoryStore |
|||
{ |
|||
private readonly string _filePath; |
|||
private readonly Dictionary<string, List<string>> _history = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); |
|||
|
|||
private LoginHistoryStore(string filePath) |
|||
{ |
|||
_filePath = filePath; |
|||
} |
|||
|
|||
public static LoginHistoryStore Load(string path) |
|||
{ |
|||
var store = new LoginHistoryStore(path); |
|||
store.LoadFromFile(); |
|||
return store; |
|||
} |
|||
|
|||
public List<string> GetValues(string fieldName) |
|||
{ |
|||
if (_history.TryGetValue(fieldName, out var list)) |
|||
{ |
|||
return new List<string>(list); |
|||
} |
|||
return new List<string>(); |
|||
} |
|||
|
|||
public void Remember(string fieldName, string value) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(value)) return; |
|||
value = value.Trim(); |
|||
|
|||
if (!_history.TryGetValue(fieldName, out var list)) |
|||
{ |
|||
list = new List<string>(); |
|||
_history[fieldName] = list; |
|||
} |
|||
|
|||
// 중복 제거 및 맨 앞으로 이동
|
|||
list.RemoveAll(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase)); |
|||
list.Insert(0, value); |
|||
|
|||
// 최대 5개 이력 유지
|
|||
if (list.Count > 5) |
|||
{ |
|||
list.RemoveRange(5, list.Count - 5); |
|||
} |
|||
} |
|||
|
|||
public bool Forget(string fieldName, string value) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(value)) return false; |
|||
value = value.Trim(); |
|||
|
|||
if (_history.TryGetValue(fieldName, out var list)) |
|||
{ |
|||
bool removed = list.RemoveAll(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase)) > 0; |
|||
return removed; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
public void Save() |
|||
{ |
|||
try |
|||
{ |
|||
var dir = Path.GetDirectoryName(_filePath); |
|||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) |
|||
{ |
|||
Directory.CreateDirectory(dir); |
|||
} |
|||
|
|||
var data = new LoginHistoryData(); |
|||
foreach (var kvp in _history) |
|||
{ |
|||
data.Categories.Add(new HistoryCategory |
|||
{ |
|||
Name = kvp.Key, |
|||
Values = kvp.Value |
|||
}); |
|||
} |
|||
|
|||
XmlSerializer serializer = new XmlSerializer(typeof(LoginHistoryData)); |
|||
using (var sw = new StreamWriter(_filePath)) |
|||
{ |
|||
serializer.Serialize(sw, data); |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
System.Diagnostics.Debug.WriteLine($"[LoginHistoryStore Save Error] {ex.Message}"); |
|||
} |
|||
} |
|||
|
|||
private void LoadFromFile() |
|||
{ |
|||
try |
|||
{ |
|||
if (!File.Exists(_filePath)) return; |
|||
|
|||
XmlSerializer serializer = new XmlSerializer(typeof(LoginHistoryData)); |
|||
using (var sr = new StreamReader(_filePath)) |
|||
{ |
|||
var data = (LoginHistoryData)serializer.Deserialize(sr); |
|||
if (data?.Categories != null) |
|||
{ |
|||
_history.Clear(); |
|||
foreach (var cat in data.Categories) |
|||
{ |
|||
if (!string.IsNullOrEmpty(cat.Name)) |
|||
{ |
|||
_history[cat.Name] = cat.Values ?? new List<string>(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
System.Diagnostics.Debug.WriteLine($"[LoginHistoryStore Load Error] {ex.Message}"); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,295 +0,0 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Text.RegularExpressions; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Housing.Services |
|||
{ |
|||
// 1. DatabaseSettings
|
|||
public class DatabaseSettings |
|||
{ |
|||
public string ConnectionString { get; set; } = string.Empty; |
|||
|
|||
public static DatabaseSettings Load(string path) |
|||
{ |
|||
var settings = new DatabaseSettings(); |
|||
try |
|||
{ |
|||
if (File.Exists(path)) |
|||
{ |
|||
var lines = File.ReadAllLines(path); |
|||
foreach (var line in lines) |
|||
{ |
|||
var trimmed = line.Trim(); |
|||
if (trimmed.StartsWith("ConnectionString", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.ConnectionString = parts[1].Trim(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
catch |
|||
{ |
|||
// 로드 실패 시 무시
|
|||
} |
|||
return settings; |
|||
} |
|||
} |
|||
|
|||
// 2. LoginSettings
|
|||
public class LoginSettings |
|||
{ |
|||
public bool OfflinePreview { get; set; } = true; // 기본값은 오프라인 테스트 편의를 위해 true
|
|||
|
|||
public static LoginSettings Load(string path) |
|||
{ |
|||
var settings = new LoginSettings(); |
|||
try |
|||
{ |
|||
if (File.Exists(path)) |
|||
{ |
|||
var lines = File.ReadAllLines(path); |
|||
foreach (var line in lines) |
|||
{ |
|||
var trimmed = line.Trim(); |
|||
if (trimmed.StartsWith("OfflinePreview", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
if (bool.TryParse(parts[1].Trim(), out var val)) |
|||
{ |
|||
settings.OfflinePreview = val; |
|||
} |
|||
else if (int.TryParse(parts[1].Trim(), out var intVal)) |
|||
{ |
|||
settings.OfflinePreview = intVal != 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
catch |
|||
{ |
|||
// 로드 실패 시 무시
|
|||
} |
|||
return settings; |
|||
} |
|||
} |
|||
|
|||
// 3. LoginHistoryStore
|
|||
public class LoginHistoryStore |
|||
{ |
|||
private readonly string _filePath; |
|||
private readonly Dictionary<string, List<string>> _history = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); |
|||
|
|||
private LoginHistoryStore(string filePath) |
|||
{ |
|||
_filePath = filePath; |
|||
} |
|||
|
|||
public static LoginHistoryStore Load(string path) |
|||
{ |
|||
var store = new LoginHistoryStore(path); |
|||
store.LoadFromFile(); |
|||
return store; |
|||
} |
|||
|
|||
public List<string> GetValues(string fieldName) |
|||
{ |
|||
if (_history.TryGetValue(fieldName, out var list)) |
|||
{ |
|||
return new List<string>(list); |
|||
} |
|||
return new List<string>(); |
|||
} |
|||
|
|||
public void Remember(string fieldName, string value) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(value)) return; |
|||
value = value.Trim(); |
|||
|
|||
if (!_history.TryGetValue(fieldName, out var list)) |
|||
{ |
|||
list = new List<string>(); |
|||
_history[fieldName] = list; |
|||
} |
|||
|
|||
// 중복 제거 및 맨 앞으로 이동
|
|||
list.RemoveAll(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase)); |
|||
list.Insert(0, value); |
|||
|
|||
// 최대 5개 이력 유지
|
|||
if (list.Count > 5) |
|||
{ |
|||
list.RemoveRange(5, list.Count - 5); |
|||
} |
|||
} |
|||
|
|||
public bool Forget(string fieldName, string value) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(value)) return false; |
|||
value = value.Trim(); |
|||
|
|||
if (_history.TryGetValue(fieldName, out var list)) |
|||
{ |
|||
bool removed = list.RemoveAll(x => string.Equals(x, value, StringComparison.OrdinalIgnoreCase)) > 0; |
|||
return removed; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
public void Save() |
|||
{ |
|||
try |
|||
{ |
|||
var dir = Path.GetDirectoryName(_filePath); |
|||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) |
|||
{ |
|||
Directory.CreateDirectory(dir); |
|||
} |
|||
|
|||
// 초간단 JSON 형식으로 직렬화 (의존성 최소화)
|
|||
using (var sw = new StreamWriter(_filePath)) |
|||
{ |
|||
sw.WriteLine("{"); |
|||
var keys = new List<string>(_history.Keys); |
|||
for (int i = 0; i < keys.Count; i++) |
|||
{ |
|||
var key = keys[i]; |
|||
var values = _history[key]; |
|||
var arrayItems = new List<string>(); |
|||
foreach (var val in values) |
|||
{ |
|||
// 따옴표 escape
|
|||
var escaped = val.Replace("\"", "\\\""); |
|||
arrayItems.Add($"\"{escaped}\""); |
|||
} |
|||
var line = $" \"{key}\": [{string.Join(", ", arrayItems)}]"; |
|||
if (i < keys.Count - 1) |
|||
{ |
|||
line += ","; |
|||
} |
|||
sw.WriteLine(line); |
|||
} |
|||
sw.WriteLine("}"); |
|||
} |
|||
} |
|||
catch |
|||
{ |
|||
// 저장 오류 시 무시
|
|||
} |
|||
} |
|||
|
|||
private void LoadFromFile() |
|||
{ |
|||
try |
|||
{ |
|||
if (!File.Exists(_filePath)) return; |
|||
|
|||
var text = File.ReadAllText(_filePath); |
|||
// 정규식이나 아주 심플한 파싱으로 키와 배열 파싱
|
|||
// 형식 예: "Maker": ["MOBI", "TEST"]
|
|||
var matches = Regex.Matches(text, @"""([^""]+)""\s*:\s*\[([^\]]*)\]"); |
|||
foreach (Match match in matches) |
|||
{ |
|||
var key = match.Groups[1].Value; |
|||
var arrayContent = match.Groups[2].Value; |
|||
var valueMatches = Regex.Matches(arrayContent, @"""([^""]*)"""); |
|||
|
|||
var list = new List<string>(); |
|||
foreach (Match valMatch in valueMatches) |
|||
{ |
|||
list.Add(valMatch.Groups[1].Value); |
|||
} |
|||
_history[key] = list; |
|||
} |
|||
} |
|||
catch |
|||
{ |
|||
// 로드 오류 시 무시
|
|||
} |
|||
} |
|||
} |
|||
|
|||
// 4. LoginAccountRepository
|
|||
public class LoginAccountRepository |
|||
{ |
|||
private readonly DatabaseSettings _dbSettings; |
|||
private readonly LoginSettings _loginSettings; |
|||
|
|||
public LoginAccountRepository(DatabaseSettings dbSettings, LoginSettings loginSettings) |
|||
{ |
|||
_dbSettings = dbSettings; |
|||
_loginSettings = loginSettings; |
|||
} |
|||
|
|||
public async Task<bool> ExistsAsync(string operatorId, string password) |
|||
{ |
|||
// 오프라인 모드인 경우 기본 성공
|
|||
if (_loginSettings.OfflinePreview) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
// DB 커넥션 스트링이 비어있으면 기본값 확인
|
|||
if (string.IsNullOrWhiteSpace(_dbSettings.ConnectionString)) |
|||
{ |
|||
return IsDefaultAccount(operatorId, password); |
|||
} |
|||
|
|||
try |
|||
{ |
|||
// 실제 DB에서 계정을 조회해보기 위한 시도
|
|||
using (var conn = new System.Data.SqlClient.SqlConnection(_dbSettings.ConnectionString)) |
|||
{ |
|||
await conn.OpenAsync(); |
|||
|
|||
// 우선 Users 또는 Accounts와 같이 로그인 테이블명이 뭔지 모를 수 있으므로
|
|||
// 쿼리 실패 시 default 계정 검증으로 fallback 하도록 구성
|
|||
const string query = "SELECT COUNT(*) FROM [Users] WHERE [OperatorId] = @Id AND [Password] = @Pw"; |
|||
using (var cmd = new System.Data.SqlClient.SqlCommand(query, conn)) |
|||
{ |
|||
cmd.Parameters.AddWithValue("@Id", operatorId); |
|||
cmd.Parameters.AddWithValue("@Pw", password); |
|||
|
|||
var count = (int)await cmd.ExecuteScalarAsync(); |
|||
return count > 0; |
|||
} |
|||
} |
|||
} |
|||
catch |
|||
{ |
|||
// DB 연결이나 쿼리 오류 발생 시, 테스트 편의를 위해 test/test 혹은 admin/admin 등 디폴트 계정을 제공
|
|||
return IsDefaultAccount(operatorId, password); |
|||
} |
|||
} |
|||
|
|||
private bool IsDefaultAccount(string operatorId, string password) |
|||
{ |
|||
// 기본 개발/테스트용 계정 처리
|
|||
if (string.Equals(operatorId, "test", StringComparison.OrdinalIgnoreCase) && |
|||
string.Equals(password, "test", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return true; |
|||
} |
|||
if (string.Equals(operatorId, "admin", StringComparison.OrdinalIgnoreCase) && |
|||
string.Equals(password, "admin", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return true; |
|||
} |
|||
if (string.Equals(operatorId, "su", StringComparison.OrdinalIgnoreCase) && |
|||
string.Equals(password, "su", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return true; |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
using System; |
|||
using System.IO; |
|||
|
|||
namespace Housing.Services |
|||
{ |
|||
public class LoginSettings |
|||
{ |
|||
public bool OfflinePreview { get; set; } = true; // 기본값은 오프라인 테스트 편의를 위해 true
|
|||
public string Procedure { get; set; } = string.Empty; |
|||
public string ProcessName { get; set; } = string.Empty; |
|||
|
|||
private static readonly object _loginLock = new object(); |
|||
private static LoginSettings _cachedSettings; |
|||
private static DateTime _lastWriteTime; |
|||
private static string _cachedPath; |
|||
|
|||
public static LoginSettings Load(string path) |
|||
{ |
|||
lock (_loginLock) |
|||
{ |
|||
try |
|||
{ |
|||
if (File.Exists(path)) |
|||
{ |
|||
var currentWriteTime = File.GetLastWriteTime(path); |
|||
if (_cachedSettings != null && _cachedPath == path && _lastWriteTime == currentWriteTime) |
|||
{ |
|||
return _cachedSettings; |
|||
} |
|||
|
|||
var settings = new LoginSettings(); |
|||
var lines = File.ReadAllLines(path); |
|||
foreach (var line in lines) |
|||
{ |
|||
var trimmed = line.Trim(); |
|||
if (trimmed.StartsWith("OfflinePreview", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
if (bool.TryParse(parts[1].Trim(), out var val)) |
|||
{ |
|||
settings.OfflinePreview = val; |
|||
} |
|||
else if (int.TryParse(parts[1].Trim(), out var intVal)) |
|||
{ |
|||
settings.OfflinePreview = intVal != 0; |
|||
} |
|||
} |
|||
} |
|||
else if (trimmed.StartsWith("Procedure", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.Procedure = parts[1].Trim(); |
|||
} |
|||
} |
|||
else if (trimmed.StartsWith("ProcessName", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var parts = trimmed.Split(new[] { '=' }, 2); |
|||
if (parts.Length == 2) |
|||
{ |
|||
settings.ProcessName = parts[1].Trim(); |
|||
} |
|||
} |
|||
} |
|||
_cachedSettings = settings; |
|||
_lastWriteTime = currentWriteTime; |
|||
_cachedPath = path; |
|||
return settings; |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
System.Diagnostics.Debug.WriteLine($"[LoginSettings Load Error] {ex.Message}"); |
|||
} |
|||
return _cachedSettings ?? new LoginSettings(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
using System.Windows; |
|||
|
|||
namespace leak_test_project.Utils |
|||
{ |
|||
public interface IDialogService |
|||
{ |
|||
void ShowMessage(string message, string title = "알림"); |
|||
void ShowWarning(string message, string title = "경고"); |
|||
} |
|||
|
|||
public class DefaultDialogService : IDialogService |
|||
{ |
|||
public void ShowMessage(string message, string title = "알림") |
|||
{ |
|||
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information); |
|||
} |
|||
|
|||
public void ShowWarning(string message, string title = "경고") |
|||
{ |
|||
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Warning); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
; ===================================================================== |
|||
; Mobi Leak Test System - 데이터베이스 및 로그인 설정 파일 (Database.ini) |
|||
; ===================================================================== |
|||
|
|||
; [데이터베이스 접속 상세 설정] |
|||
; Db_Server : 중앙 MSSQL 데이터베이스 서버의 IP 주소 또는 호스트명 |
|||
; Db_Name : 검사 데이터를 저장할 대상 데이터베이스 이름 |
|||
; Db_User : 데이터베이스 접속 계정 ID (예: sa) |
|||
; Db_Password: 데이터베이스 접속 계정의 비밀번호 |
|||
Db_Server=192.168.0.20 |
|||
Db_Name=NE1aW_PT_SENSOR |
|||
Db_User=sa |
|||
Db_Password=sa1234 |
|||
|
|||
|
|||
[Login] |
|||
; Procedure: 로그인 검증에 사용할 DB 저장 프로시저명입니다. (공란일 경우 기본 테이블 조회 쿼리 작동) |
|||
Procedure=dbo.CheckOperator |
|||
|
|||
; ProcessName: 로그인 검증 시 전달할 공정 이름입니다. |
|||
ProcessName=EOL |
|||
|
|||
; OfflinePreview: 오프라인 모드 활성화 여부 (true = DB 연결 없이 로그인 통과, false = 실제 DB 인증) |
|||
OfflinePreview=false |
|||
@ -0,0 +1,24 @@ |
|||
; ===================================================================== |
|||
; Mobi Leak Test System - 데이터베이스 및 로그인 설정 파일 (Database.ini) |
|||
; ===================================================================== |
|||
|
|||
; [데이터베이스 접속 상세 설정] |
|||
; Db_Server : 중앙 MSSQL 데이터베이스 서버의 IP 주소 또는 호스트명 |
|||
; Db_Name : 검사 데이터를 저장할 대상 데이터베이스 이름 |
|||
; Db_User : 데이터베이스 접속 계정 ID (예: sa) |
|||
; Db_Password: 데이터베이스 접속 계정의 비밀번호 |
|||
Db_Server=192.168.0.20 |
|||
Db_Name=NE1aW_PT_SENSOR |
|||
Db_User=sa |
|||
Db_Password=sa1234 |
|||
|
|||
|
|||
[Login] |
|||
; Procedure: 로그인 검증에 사용할 DB 저장 프로시저명입니다. (공란일 경우 기본 테이블 조회 쿼리 작동) |
|||
Procedure=dbo.CheckOperator |
|||
|
|||
; ProcessName: 로그인 검증 시 전달할 공정 이름입니다. |
|||
ProcessName=EOL |
|||
|
|||
; OfflinePreview: 오프라인 모드 활성화 여부 (true = DB 연결 없이 로그인 통과, false = 실제 DB 인증) |
|||
OfflinePreview=false |
|||
@ -0,0 +1,2 @@ |
|||
[11:13:29.052] [ERROR] [Board4251] Failed to receive response after 3 retries: x00o |
|||
[11:13:30.394] [ERROR] [Board4251] Failed to receive response after 3 retries: x00c_001101:owt28006727ea97c7801 |
|||
@ -0,0 +1,8 @@ |
|||
[10:17:36.444] [ERROR] [Board4251] Failed to receive response after 3 retries: x00o |
|||
[10:17:37.692] [ERROR] [Board4251] Failed to receive response after 3 retries: x00c_001101:owt28006727ea97c7801 |
|||
[10:17:39.458] [ERROR] [Board4251] Failed to receive response after 3 retries: x00o |
|||
[10:17:40.707] [ERROR] [Board4251] Failed to receive response after 3 retries: x00c_002101:owt28006727ea97c7801 |
|||
[10:17:43.159] [ERROR] [Board4251] Failed to receive response after 3 retries: x00o |
|||
[10:17:44.414] [ERROR] [Board4251] Failed to receive response after 3 retries: x00c_002101:owt28006727ea97c7801 |
|||
[10:17:46.175] [ERROR] [Board4251] Failed to receive response after 3 retries: x00o |
|||
[10:17:47.441] [ERROR] [Board4251] Failed to receive response after 3 retries: x00c_001101:owt28006727ea97c7801 |
|||
@ -1 +1 @@ |
|||
f74477eeb98a106556f1b65e440f8cfa8362ac43ae60c03174d4f2bb878b7a02 |
|||
628713ffbc5f55cd805898b1d31f7980abb2a5a8c3c1aad9e2f31b3c27ae4897 |
|||
|
|||
@ -1,62 +1,2 @@ |
|||
//------------------------------------------------------------------------------
|
|||
// <auto-generated>
|
|||
// 이 코드는 도구를 사용하여 생성되었습니다.
|
|||
// 런타임 버전:4.0.30319.42000
|
|||
//
|
|||
// 파일 내용을 변경하면 잘못된 동작이 발생할 수 있으며, 코드를 다시 생성하면
|
|||
// 이러한 변경 내용이 손실됩니다.
|
|||
// </auto-generated>
|
|||
//------------------------------------------------------------------------------
|
|||
|
|||
namespace XamlGeneratedNamespace { |
|||
|
|||
|
|||
/// <summary>
|
|||
/// GeneratedInternalTypeHelper
|
|||
/// </summary>
|
|||
[System.Diagnostics.DebuggerNonUserCodeAttribute()] |
|||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "4.0.0.0")] |
|||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] |
|||
public sealed class GeneratedInternalTypeHelper : System.Windows.Markup.InternalTypeHelper { |
|||
|
|||
/// <summary>
|
|||
/// CreateInstance
|
|||
/// </summary>
|
|||
protected override object CreateInstance(System.Type type, System.Globalization.CultureInfo culture) { |
|||
return System.Activator.CreateInstance(type, ((System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic) |
|||
| (System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.CreateInstance)), null, null, culture); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// GetPropertyValue
|
|||
/// </summary>
|
|||
protected override object GetPropertyValue(System.Reflection.PropertyInfo propertyInfo, object target, System.Globalization.CultureInfo culture) { |
|||
return propertyInfo.GetValue(target, System.Reflection.BindingFlags.Default, null, null, culture); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// SetPropertyValue
|
|||
/// </summary>
|
|||
protected override void SetPropertyValue(System.Reflection.PropertyInfo propertyInfo, object target, object value, System.Globalization.CultureInfo culture) { |
|||
propertyInfo.SetValue(target, value, System.Reflection.BindingFlags.Default, null, null, culture); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// CreateDelegate
|
|||
/// </summary>
|
|||
protected override System.Delegate CreateDelegate(System.Type delegateType, object target, string handler) { |
|||
return ((System.Delegate)(target.GetType().InvokeMember("_CreateDelegate", (System.Reflection.BindingFlags.InvokeMethod |
|||
| (System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)), null, target, new object[] { |
|||
delegateType, |
|||
handler}, null))); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// AddEventHandler
|
|||
/// </summary>
|
|||
protected override void AddEventHandler(System.Reflection.EventInfo eventInfo, object target, System.Delegate handler) { |
|||
eventInfo.AddEventHandler(target, handler); |
|||
} |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
@ -1 +1 @@ |
|||
cbc4c65e0c5c8abdc171ee59e1f11d54d6b7bd62192c87410037a9efab12cf46 |
|||
8765edc9fc5fcfe1db8d41cec7744abfac5abd652e5521541b6bb33dc8fa7af8 |
|||
|
|||