Compare commits
8 Commits
fd49c71910
...
Main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd68e2c98 | |||
| 7622ed2793 | |||
| a8f14e8c76 | |||
| 4bb2692ad0 | |||
| 7d184d2e97 | |||
| a510f34f71 | |||
| 6790991471 | |||
| aca94d175c |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -17,3 +17,9 @@ Thumbs.db
|
||||
|
||||
# Local scratch
|
||||
*.tmp
|
||||
|
||||
# SQLite runtime files / local recovery backups
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.db.bak-*
|
||||
db-backups/
|
||||
|
||||
241
README.md
Normal file
241
README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# 장헌산업 업무 데이터 조회 시스템
|
||||
|
||||
이 저장소는 장헌산업 내부 업무 데이터를 조회하고, ERP 정보와 매칭해서 웹 화면으로 보여주는 사내용 도구입니다.
|
||||
|
||||
주요 목적은 아래 2가지입니다.
|
||||
|
||||
- `8091` 페이지: 사람별/프로젝트별 근무 데이터 조회
|
||||
- `8092` 페이지: 시공 코드, 계약정보, 공사개요, 연계코드 조회
|
||||
|
||||
|
||||
## 구성 개요
|
||||
|
||||
이 프로젝트는 Python 기반의 간단한 웹 서버와 SQLite DB(`matching.db`)를 사용합니다.
|
||||
|
||||
- 웹 서버: `mysql_preview_server.py`
|
||||
- 공용 DB: `matching.db`
|
||||
- 메인 대시보드 화면: `index.html`
|
||||
- 사람별 근무 화면: `people-unified.html`
|
||||
- 시공 코드 조회 화면: `project-codes.html`
|
||||
|
||||
같은 저장소 안에서 여러 HTML 화면을 제공하지만, 데이터는 공통으로 `matching.db`를 사용합니다.
|
||||
|
||||
|
||||
## 페이지 설명
|
||||
|
||||
### `8091` 메인/근무 데이터 페이지
|
||||
|
||||
`8091` 포트는 근무 데이터와 집계 화면용입니다.
|
||||
|
||||
포함 내용:
|
||||
|
||||
- 프로젝트별 투입시간 집계
|
||||
- 사람별 근무내역 조회
|
||||
- 월별/기간별 프로젝트 투입 현황
|
||||
- 사업관리 작업일보 기반 추가 공수 데이터 조회
|
||||
|
||||
관련 화면:
|
||||
|
||||
- `index.html`: 메인 대시보드
|
||||
- `people-unified.html`: 사람별 / 프로젝트별 근무내역
|
||||
- `detail-view.html`, `detail-view-project.html`: 상세 보기
|
||||
|
||||
|
||||
### `8092` 시공 코드 조회 페이지
|
||||
|
||||
`8092` 포트는 ERP 시공 코드 전용 조회 화면입니다.
|
||||
|
||||
포함 내용:
|
||||
|
||||
- 시공코드 목록
|
||||
- 계약정보
|
||||
- 공사개요
|
||||
- 교량 매칭 정보
|
||||
- 공사시행계획서 기반 교량제원
|
||||
- 연계코드 표시
|
||||
|
||||
연계코드는 아래 형식으로 표시됩니다.
|
||||
|
||||
- `영업/설계 약칭(코드번호)`가 아니라
|
||||
- 현재는 `약칭(코드번호) · 약칭(코드번호)` 형식
|
||||
|
||||
예시:
|
||||
|
||||
- `새만금~전주간 고속도로 건설고사(제4공구) [8차](25-교영-09)`
|
||||
- `새만금~전주간 고속도로 건설고사(제4공구) [8차](25-설계-05)`
|
||||
|
||||
관련 화면:
|
||||
|
||||
- `project-codes.html`
|
||||
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
이 시스템은 여러 데이터 소스를 함께 사용합니다.
|
||||
|
||||
### 1. SQLite 캐시 DB
|
||||
|
||||
- 파일: `matching.db`
|
||||
- 저장소에 포함되어 있음
|
||||
- 클론한 사람도 같은 시점의 데이터를 바로 볼 수 있음
|
||||
|
||||
주요 저장 내용:
|
||||
|
||||
- 직원 정보
|
||||
- `dailyproject` 근무 데이터
|
||||
- 프로젝트 약칭
|
||||
- ERP 시공 코드 캐시
|
||||
- ERP 계약정보 캐시
|
||||
- ERP 교량 개요 캐시
|
||||
- ERP 공사시행계획서 캐시
|
||||
- 영업/설계/시공 연계코드 캐시
|
||||
|
||||
|
||||
### 2. MySQL 원본 데이터
|
||||
|
||||
사내 MySQL에서 근무 데이터를 읽어와 SQLite로 적재합니다.
|
||||
|
||||
관련 환경값:
|
||||
|
||||
- `MYSQL_HOST`
|
||||
- `MYSQL_PORT`
|
||||
- `MYSQL_USER`
|
||||
- `MYSQL_PASSWORD`
|
||||
- `MYSQL_DB`
|
||||
|
||||
|
||||
### 3. ERP 데이터
|
||||
|
||||
ERP에서 아래 정보를 가져옵니다.
|
||||
|
||||
- 프로젝트 약칭
|
||||
- 시공 코드 목록
|
||||
- 계약정보
|
||||
- 공사관리 / 공사개요
|
||||
- 공사시행계획서
|
||||
- 영업/설계/시공 연계코드
|
||||
|
||||
관련 기본 URL:
|
||||
|
||||
- `http://erp.jangheon.co.kr/projt_mng`
|
||||
|
||||
|
||||
## DB 관련 중요 사항
|
||||
|
||||
이 저장소는 **코드만이 아니라 `matching.db`도 함께 관리**합니다.
|
||||
|
||||
이유:
|
||||
|
||||
- 클론한 사람이 바로 같은 데이터를 볼 수 있어야 함
|
||||
- 내부 페이지가 DB 캐시를 전제로 동작함
|
||||
- ERP/MySQL 접속 없이도 기본 조회가 가능해야 함
|
||||
|
||||
즉, 이 저장소에서는 `matching.db`도 실제 배포/공유 자산입니다.
|
||||
|
||||
다만 아래 파일은 운영 중 자동 생성될 수 있습니다.
|
||||
|
||||
- `matching.db-wal`
|
||||
- `matching.db-shm`
|
||||
- `matching.db.bak-*`
|
||||
|
||||
이 파일들은 보조 파일/백업 파일이며, 기본 공유 대상은 `matching.db`입니다.
|
||||
|
||||
|
||||
## 연계코드 저장 방식
|
||||
|
||||
시공코드 페이지의 연계코드는 DB에도 저장됩니다.
|
||||
|
||||
관련 테이블:
|
||||
|
||||
- `project_alias`
|
||||
- `erp_project_alias_cache`
|
||||
- `erp_linked_code_cache`
|
||||
|
||||
예를 들어 시공코드 하나에 대해 아래 정보를 저장합니다.
|
||||
|
||||
- 시공코드
|
||||
- 사업코드
|
||||
- 연관 영업코드
|
||||
- 연관 영업 약칭
|
||||
- 연관 설계코드
|
||||
- 연관 설계 약칭
|
||||
|
||||
그래서 나중에 아래와 같은 질문에 바로 답할 수 있습니다.
|
||||
|
||||
- “이 시공코드의 연관 영업코드는 뭐야?”
|
||||
- “이 설계코드 약칭이 뭐야?”
|
||||
|
||||
|
||||
## 실행 방법
|
||||
|
||||
### 로컬 실행
|
||||
|
||||
Python 서버 실행:
|
||||
|
||||
```bash
|
||||
python3 mysql_preview_server.py
|
||||
```
|
||||
|
||||
기본 포트:
|
||||
|
||||
- `8091`: 메인/근무 데이터
|
||||
- `8092`: 시공 코드 조회
|
||||
|
||||
|
||||
### Docker 실행
|
||||
|
||||
이미지/컨테이너는 `docker-compose.yml`과 `Dockerfile`로 실행할 수 있습니다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `DEPLOY_DOCKER.md`
|
||||
|
||||
|
||||
## 주요 파일 설명
|
||||
|
||||
- `mysql_preview_server.py`: 현재 실제 기능 대부분이 들어있는 메인 서버
|
||||
- `app.py`: 이전/보조 서버 코드
|
||||
- `index.html`: 8091 메인 대시보드
|
||||
- `people-unified.html`: 사람별/프로젝트별 근무 조회
|
||||
- `project-codes.html`: 8092 시공 코드 전용 화면
|
||||
- `matching.db`: 공용 SQLite 데이터베이스
|
||||
- `docker-compose.yml`: Docker 배포 설정
|
||||
- `DEPLOY_DOCKER.md`: Docker 배포 방법
|
||||
|
||||
|
||||
## 사용 흐름
|
||||
|
||||
### 근무 데이터 흐름
|
||||
|
||||
1. MySQL 원본 데이터를 읽음
|
||||
2. SQLite `matching.db`에 적재
|
||||
3. `8091` 페이지에서 집계/상세 조회
|
||||
|
||||
|
||||
### 시공 코드 데이터 흐름
|
||||
|
||||
1. ERP에서 시공코드 목록 조회
|
||||
2. ERP 계약정보 / 공사개요 / 공사시행계획서 조회
|
||||
3. 영업/설계/시공 연계코드와 약칭 매칭
|
||||
4. SQLite `matching.db`에 캐시 저장
|
||||
5. `8092` 페이지에서 빠르게 조회
|
||||
|
||||
|
||||
## 운영 시 주의사항
|
||||
|
||||
- `matching.db`는 실제 운영 데이터가 들어 있으므로 함부로 초기화하면 안 됩니다.
|
||||
- ERP 재조회 버튼을 누르면 캐시가 갱신될 수 있습니다.
|
||||
- 운영 중에는 DB 파일이 변경될 수 있어 `matching.db-wal`, `matching.db-shm`가 생길 수 있습니다.
|
||||
- 다른 사람이 같은 결과를 보려면 저장소의 `matching.db`도 함께 최신 상태여야 합니다.
|
||||
|
||||
|
||||
## 권장 공유 방식
|
||||
|
||||
다른 사람이 이 저장소를 받아서 바로 확인하려면:
|
||||
|
||||
1. 저장소를 클론
|
||||
2. `matching.db`가 포함되어 있는지 확인
|
||||
3. 서버 실행
|
||||
4. `8091`, `8092` 페이지 접속
|
||||
|
||||
이렇게 하면 ERP를 다시 긁지 않아도 커밋 시점 기준의 데이터를 바로 볼 수 있습니다.
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
- "8091:8091"
|
||||
environment:
|
||||
PORT: "8091"
|
||||
STARTUP_MAINTENANCE: "1"
|
||||
STARTUP_MAINTENANCE: "0"
|
||||
MYSQL_HOST: "${MYSQL_HOST:-172.16.42.111}"
|
||||
MYSQL_PORT: "${MYSQL_PORT:-3306}"
|
||||
MYSQL_USER: "${MYSQL_USER:-root}"
|
||||
|
||||
BIN
matching.db
BIN
matching.db
Binary file not shown.
@@ -12,6 +12,7 @@ import html as html_lib
|
||||
import threading
|
||||
import uuid
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
@@ -55,8 +56,11 @@ DB_WRITE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def configure_sqlite_connection(conn):
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('PRAGMA synchronous=NORMAL')
|
||||
# Docker에서 8091/8092가 같은 SQLite 파일을 bind mount로 공유한다.
|
||||
# WAL/SHM 파일이 남은 상태에서 컨테이너 재시작/복구가 섞이면 malformed가 반복될 수 있어
|
||||
# 운영 모드는 단일 DB 파일 중심의 DELETE journal로 둔다.
|
||||
conn.execute('PRAGMA journal_mode=DELETE')
|
||||
conn.execute('PRAGMA synchronous=FULL')
|
||||
conn.execute('PRAGMA busy_timeout=30000')
|
||||
return conn
|
||||
|
||||
@@ -140,6 +144,14 @@ def init_db(conn):
|
||||
shortName TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS erp_project_alias_cache (
|
||||
sourcePage TEXT NOT NULL,
|
||||
projectCode TEXT NOT NULL,
|
||||
shortName TEXT NOT NULL DEFAULT '',
|
||||
syncedAt TEXT NOT NULL,
|
||||
PRIMARY KEY (sourcePage, projectCode)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS site_worksheet_record (
|
||||
projectCode TEXT,
|
||||
workDate TEXT,
|
||||
@@ -288,6 +300,20 @@ def init_db(conn):
|
||||
PRIMARY KEY (sourcePage, projectCode)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS erp_linked_code_cache (
|
||||
sourcePage TEXT NOT NULL,
|
||||
projectCode TEXT NOT NULL,
|
||||
projectName TEXT NOT NULL DEFAULT '',
|
||||
businessCode TEXT DEFAULT '',
|
||||
salesCode TEXT DEFAULT '',
|
||||
salesName TEXT DEFAULT '',
|
||||
designCode TEXT DEFAULT '',
|
||||
designName TEXT DEFAULT '',
|
||||
matchedBy TEXT DEFAULT '',
|
||||
syncedAt TEXT NOT NULL,
|
||||
PRIMARY KEY (sourcePage, projectCode)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS naver_address_token_cache (
|
||||
address TEXT PRIMARY KEY,
|
||||
tokenX TEXT NOT NULL,
|
||||
@@ -328,6 +354,7 @@ def init_db(conn):
|
||||
conn.execute("ALTER TABLE dallyproject ADD COLUMN BusinessTripHours REAL DEFAULT 0")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_dally_workdate ON dallyproject(WorkDate)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_project_alias_code ON project_alias(projectCode)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_project_alias_cache_page ON erp_project_alias_cache(sourcePage, projectCode)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_member ON site_worksheet_record(memberNo, workDate)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_name ON site_worksheet_record(korName, workDate)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_sync_member ON site_worksheet_sync(memberNo, yearMonth)")
|
||||
@@ -340,6 +367,7 @@ def init_db(conn):
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_contract_detail_cache_page ON erp_contract_detail_cache(sourcePage, projectCode)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_bridge_overview_cache_page ON erp_bridge_overview_cache(sourcePage, projectCode, bridgeNo)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_budget_plan_cache_page ON erp_budget_plan_cache(sourcePage, projectCode)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_linked_code_cache_page ON erp_linked_code_cache(sourcePage, projectCode)")
|
||||
conn.commit()
|
||||
DB_SCHEMA_READY = True
|
||||
|
||||
@@ -853,7 +881,7 @@ def backfill_daily_hours_if_needed(conn):
|
||||
return need
|
||||
|
||||
|
||||
def load_project_alias_from_erp():
|
||||
def load_project_alias_entries_from_erp():
|
||||
login_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/common/main_controller.php'
|
||||
s = requests.Session()
|
||||
login_payload = {
|
||||
@@ -886,8 +914,12 @@ def load_project_alias_from_erp():
|
||||
code = _as_text(item.get('ProjectCode')).strip().upper()
|
||||
nick = _as_text(item.get('ProjectNickname')).strip()
|
||||
if code and nick:
|
||||
out[code] = nick
|
||||
return sorted(out.items())
|
||||
out[code] = {'sourcePage': page, 'projectCode': code, 'shortName': nick}
|
||||
return sorted(out.values(), key=lambda item: (item['projectCode'], item['sourcePage']))
|
||||
|
||||
|
||||
def load_project_alias_from_erp():
|
||||
return [(item['projectCode'], item['shortName']) for item in load_project_alias_entries_from_erp()]
|
||||
|
||||
|
||||
def erp_login_session():
|
||||
@@ -1679,6 +1711,7 @@ def fetch_erp_contract_detail(page='const', project_code='', project_name=''):
|
||||
'finalContractAmountText': contract_map.get('최종계약금액', ''),
|
||||
'finalContractAmountValue': _parse_amount_text(contract_map.get('최종계약금액', '')),
|
||||
'contractType': contract_map.get('계약종류', ''),
|
||||
'linkedCodes': find_erp_linked_codes(code, contract_map.get('사업코드', ''), short_name or contract_map.get('약칭', '')),
|
||||
'contractFields': contract_map,
|
||||
'scaleRows': scale_rows,
|
||||
}
|
||||
@@ -1695,6 +1728,7 @@ def replace_erp_contract_detail_cache(conn, detail):
|
||||
raw_payload = {
|
||||
'contractFields': contract_fields,
|
||||
'scaleRows': detail.get('scaleRows') or [],
|
||||
'linkedCodes': detail.get('linkedCodes') or {},
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
@@ -1743,9 +1777,11 @@ def get_erp_contract_detail_cache(conn, source_page='const', project_code=''):
|
||||
if isinstance(raw_contract, dict) and ('contractFields' in raw_contract or 'scaleRows' in raw_contract):
|
||||
contract_fields = raw_contract.get('contractFields') or {}
|
||||
scale_rows = raw_contract.get('scaleRows') or []
|
||||
linked_codes = _decorate_linked_code_names(raw_contract.get('linkedCodes') or {}, conn)
|
||||
else:
|
||||
contract_fields = raw_contract if isinstance(raw_contract, dict) else {}
|
||||
scale_rows = []
|
||||
linked_codes = {}
|
||||
return {
|
||||
'sourcePage': source_page,
|
||||
'projectCode': project_code,
|
||||
@@ -1757,6 +1793,7 @@ def get_erp_contract_detail_cache(conn, source_page='const', project_code=''):
|
||||
'finalContractAmountText': row[5] or '',
|
||||
'finalContractAmountValue': row[6] or 0,
|
||||
'contractType': row[7] or '',
|
||||
'linkedCodes': linked_codes,
|
||||
'contractFields': contract_fields,
|
||||
'scaleRows': scale_rows,
|
||||
'syncedAt': row[9] or '',
|
||||
@@ -1768,6 +1805,158 @@ def _clean_html_text(value):
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
|
||||
def _normalize_text_key(value):
|
||||
return re.sub(r'\s+', '', _as_text(value)).strip()
|
||||
|
||||
|
||||
def _extract_business_code_key(value):
|
||||
text = _as_text(value).strip()
|
||||
match = re.search(r'\d{2}-\d{3}', text)
|
||||
return match.group(0) if match else text
|
||||
|
||||
|
||||
def _load_linked_code_map_from_html():
|
||||
html_path = Path(__file__).with_name('project-codes.html')
|
||||
if not html_path.exists():
|
||||
return {}
|
||||
try:
|
||||
html_text = html_path.read_text(encoding='utf-8')
|
||||
except OSError:
|
||||
return {}
|
||||
match = re.search(r'const\s+LINKED_CODE_BY_BUSINESS\s*=\s*(\{.*?\});', html_text, re.S)
|
||||
if not match:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _decorate_linked_code_names(linked_codes, conn=None):
|
||||
payload = dict(linked_codes or {})
|
||||
close_conn = False
|
||||
if conn is None:
|
||||
conn = open_db_connection()
|
||||
close_conn = True
|
||||
try:
|
||||
sales_code = _as_text(payload.get('salesCode')).strip()
|
||||
design_code = _as_text(payload.get('designCode')).strip()
|
||||
if sales_code:
|
||||
# 연계코드는 페이지에서 항상 "약칭(코드번호)" 형태로 보여야 한다.
|
||||
# 원본 REPORT_5가 긴 정식명을 주더라도 ERP 프로젝트 약칭 캐시를 우선 사용한다.
|
||||
payload['salesName'] = get_project_alias_name(conn, sales_code, 'sales') or _as_text(payload.get('salesName')).strip()
|
||||
if design_code:
|
||||
payload['designName'] = get_project_alias_name(conn, design_code, 'design') or _as_text(payload.get('designName')).strip()
|
||||
return payload
|
||||
finally:
|
||||
if close_conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_erp_linked_code_rows():
|
||||
session = erp_login_session()
|
||||
report_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/report/design_step_controller.php'
|
||||
response = session.get(report_url, params={'ActionMode': 'REPORT_5'}, timeout=20)
|
||||
html = response.content.decode('utf-8', errors='ignore')
|
||||
tables = _extract_table_rows(html)
|
||||
target_rows = []
|
||||
for rows in tables:
|
||||
if not rows:
|
||||
continue
|
||||
header = rows[0]
|
||||
if '사업코드' in header and '영업코드' in header and '설계코드' in header and '시공코드' in header:
|
||||
target_rows = rows
|
||||
break
|
||||
if not target_rows:
|
||||
return []
|
||||
|
||||
header = target_rows[0]
|
||||
header_index = {label: index for index, label in enumerate(header)}
|
||||
business_index = header_index.get('사업코드')
|
||||
bridge_name_index = header_index.get('교량명')
|
||||
sales_index = header_index.get('영업코드')
|
||||
design_index = header_index.get('설계코드')
|
||||
const_index = header_index.get('시공코드')
|
||||
status_index = header_index.get('진행상태')
|
||||
|
||||
out = []
|
||||
for row in target_rows[1:]:
|
||||
if not row:
|
||||
continue
|
||||
if business_index is None or business_index >= len(row):
|
||||
continue
|
||||
business_code = _as_text(row[business_index]).strip()
|
||||
if not business_code or not re.match(r'^\d{2}-\d{3}', business_code):
|
||||
continue
|
||||
out.append({
|
||||
'businessCode': business_code,
|
||||
'bridgeName': _as_text(row[bridge_name_index]).strip() if bridge_name_index is not None and bridge_name_index < len(row) else '',
|
||||
'status': _as_text(row[status_index]).strip() if status_index is not None and status_index < len(row) else '',
|
||||
'salesCode': _as_text(row[sales_index]).strip() if sales_index is not None and sales_index < len(row) else '',
|
||||
'designCode': _as_text(row[design_index]).strip() if design_index is not None and design_index < len(row) else '',
|
||||
'constCode': _as_text(row[const_index]).strip() if const_index is not None and const_index < len(row) else '',
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def find_erp_linked_codes(project_code='', business_code='', project_name=''):
|
||||
project_code = _as_text(project_code).strip()
|
||||
business_code = _extract_business_code_key(business_code)
|
||||
project_name_key = _normalize_text_key(project_name)
|
||||
html_map = _load_linked_code_map_from_html()
|
||||
|
||||
def html_map_match():
|
||||
if project_code:
|
||||
for key, value in html_map.items():
|
||||
if _as_text((value or {}).get('constCode')).strip() == project_code:
|
||||
return _decorate_linked_code_names({
|
||||
'businessCode': key,
|
||||
'bridgeName': '',
|
||||
'status': '',
|
||||
'salesCode': _as_text((value or {}).get('salesCode')).strip(),
|
||||
'designCode': _as_text((value or {}).get('designCode')).strip(),
|
||||
'constCode': _as_text((value or {}).get('constCode')).strip(),
|
||||
'matchedBy': 'htmlMap.constCode',
|
||||
})
|
||||
if business_code and business_code in html_map:
|
||||
value = html_map.get(business_code) or {}
|
||||
return _decorate_linked_code_names({
|
||||
'businessCode': business_code,
|
||||
'bridgeName': '',
|
||||
'status': '',
|
||||
'salesCode': _as_text(value.get('salesCode')).strip(),
|
||||
'designCode': _as_text(value.get('designCode')).strip(),
|
||||
'constCode': _as_text(value.get('constCode')).strip(),
|
||||
'matchedBy': 'htmlMap.businessCode',
|
||||
})
|
||||
return None
|
||||
|
||||
html_hit = html_map_match()
|
||||
if html_hit:
|
||||
return html_hit
|
||||
|
||||
rows = fetch_erp_linked_code_rows()
|
||||
if not rows:
|
||||
return {'salesCode': '', 'salesName': '', 'designCode': '', 'designName': '', 'constCode': '', 'matchedBy': ''}
|
||||
|
||||
for row in rows:
|
||||
if project_code and _as_text(row.get('constCode')).strip() == project_code:
|
||||
return _decorate_linked_code_names({**row, 'matchedBy': 'constCode'})
|
||||
|
||||
candidates = [row for row in rows if business_code and _as_text(row.get('businessCode')).strip() == business_code]
|
||||
if len(candidates) == 1:
|
||||
return _decorate_linked_code_names({**candidates[0], 'matchedBy': 'businessCode'})
|
||||
if len(candidates) > 1 and project_name_key:
|
||||
for row in candidates:
|
||||
bridge_name_key = _normalize_text_key(row.get('bridgeName'))
|
||||
if project_name_key and project_name_key in bridge_name_key:
|
||||
return _decorate_linked_code_names({**row, 'matchedBy': 'businessCode+projectName'})
|
||||
return _decorate_linked_code_names({**candidates[0], 'matchedBy': 'businessCode'})
|
||||
|
||||
return {'salesCode': '', 'salesName': '', 'designCode': '', 'designName': '', 'constCode': '', 'matchedBy': ''}
|
||||
|
||||
|
||||
def _parse_site_worker_rows(html):
|
||||
rows = []
|
||||
for table in re.findall(r'<table[^>]*>(.*?)</table>', html or '', re.I | re.S):
|
||||
@@ -2293,7 +2482,49 @@ def get_site_worksheet_records_by_days(conn, start_date, end_date, member_nos, r
|
||||
progress['processedTargets'] = month_idx
|
||||
|
||||
if progress is not None:
|
||||
progress['phase'] = '달력 테이블 정리'
|
||||
progress['phase'] = '월별 근무현황 보강'
|
||||
identity_rows = conn.execute(
|
||||
f'''
|
||||
SELECT m.MemberNo, IFNULL(m.korName, '') AS korName, IFNULL(i.juminno, '') AS juminno
|
||||
FROM member m
|
||||
JOIN member_site_identity i ON i.memberNo = m.MemberNo
|
||||
WHERE m.MemberNo IN ({ph})
|
||||
AND IFNULL(m.korName, '') <> ''
|
||||
AND IFNULL(i.juminno, '') <> ''
|
||||
''',
|
||||
member_nos
|
||||
).fetchall()
|
||||
for idx, (identity_member_no, identity_name, juminno) in enumerate(identity_rows, start=1):
|
||||
if progress is not None:
|
||||
progress.update({
|
||||
'phase': '월별 근무현황 보강',
|
||||
'processedMembers': idx - 1,
|
||||
'totalMembers': len(identity_rows),
|
||||
'currentProjectCode': '',
|
||||
'currentYearMonth': _as_text(end_date)[:7],
|
||||
'currentWorkDate': end_date,
|
||||
})
|
||||
try:
|
||||
added += insert_construct_paymonth_records(
|
||||
conn,
|
||||
s,
|
||||
identity_member_no,
|
||||
identity_name,
|
||||
juminno,
|
||||
end_date,
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# Project/day crawl is the primary source; paymonth is a best-effort gap filler.
|
||||
pass
|
||||
if progress is not None:
|
||||
progress.update({
|
||||
'phase': '달력 테이블 정리',
|
||||
'processedMembers': len(identity_rows),
|
||||
'added': added,
|
||||
})
|
||||
conn.commit()
|
||||
cleanup_site_records_after_retire(conn)
|
||||
rebuild_work_calendar_tables(conn)
|
||||
@@ -2684,11 +2915,152 @@ def replace_project_alias(conn, rows):
|
||||
conn.commit()
|
||||
|
||||
|
||||
def replace_erp_project_alias_cache(conn, rows):
|
||||
synced_at = datetime.now().isoformat(timespec='seconds')
|
||||
conn.execute('DELETE FROM erp_project_alias_cache')
|
||||
if rows:
|
||||
conn.executemany(
|
||||
'''
|
||||
INSERT OR REPLACE INTO erp_project_alias_cache (sourcePage, projectCode, shortName, syncedAt)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''',
|
||||
[
|
||||
(
|
||||
_as_text(row.get('sourcePage')).strip().lower(),
|
||||
_as_text(row.get('projectCode')).strip().upper(),
|
||||
_as_text(row.get('shortName')).strip(),
|
||||
synced_at,
|
||||
)
|
||||
for row in rows
|
||||
if _as_text(row.get('sourcePage')).strip() and _as_text(row.get('projectCode')).strip()
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
return {'count': len(rows), 'syncedAt': synced_at}
|
||||
|
||||
|
||||
def get_project_alias_map(conn):
|
||||
cur = conn.execute('SELECT projectCode, shortName FROM project_alias')
|
||||
return {code: short for code, short in cur.fetchall() if code}
|
||||
|
||||
|
||||
def infer_source_page_from_code(project_code=''):
|
||||
code = _as_text(project_code).strip().upper()
|
||||
if '-교영-' in code:
|
||||
return 'sales'
|
||||
if '-설계-' in code:
|
||||
return 'design'
|
||||
if '-시공-' in code:
|
||||
return 'const'
|
||||
if '-제조-' in code:
|
||||
return 'make'
|
||||
if '-연구-' in code:
|
||||
return 'research'
|
||||
return ''
|
||||
|
||||
|
||||
def get_project_alias_name(conn, project_code='', source_page=''):
|
||||
code = _as_text(project_code).strip().upper()
|
||||
page_name = _as_text(source_page).strip().lower() or infer_source_page_from_code(code)
|
||||
if not code:
|
||||
return ''
|
||||
if page_name:
|
||||
row = conn.execute(
|
||||
'SELECT shortName FROM erp_project_alias_cache WHERE sourcePage = ? AND projectCode = ?',
|
||||
(page_name, code),
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
row = conn.execute('SELECT shortName FROM project_alias WHERE projectCode = ?', (code,)).fetchone()
|
||||
return row[0] if row and row[0] else ''
|
||||
|
||||
|
||||
def replace_erp_linked_code_cache(conn, source_page, project_code, project_name, business_code, linked_codes):
|
||||
source_page = _as_text(source_page).strip().lower() or 'const'
|
||||
project_code = _as_text(project_code).strip()
|
||||
if not project_code:
|
||||
return None
|
||||
payload = linked_codes or {}
|
||||
synced_at = datetime.now().isoformat(timespec='seconds')
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT OR REPLACE INTO erp_linked_code_cache
|
||||
(sourcePage, projectCode, projectName, businessCode, salesCode, salesName, designCode, designName, matchedBy, syncedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
source_page,
|
||||
project_code,
|
||||
_as_text(project_name).strip(),
|
||||
_extract_business_code_key(business_code),
|
||||
_as_text(payload.get('salesCode')).strip(),
|
||||
_as_text(payload.get('salesName')).strip(),
|
||||
_as_text(payload.get('designCode')).strip(),
|
||||
_as_text(payload.get('designName')).strip(),
|
||||
_as_text(payload.get('matchedBy')).strip(),
|
||||
synced_at,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {'sourcePage': source_page, 'projectCode': project_code, 'syncedAt': synced_at}
|
||||
|
||||
|
||||
def sync_project_alias_caches(conn):
|
||||
alias_entries = load_project_alias_entries_from_erp()
|
||||
replace_project_alias(conn, [(item['projectCode'], item['shortName']) for item in alias_entries])
|
||||
cache_info = replace_erp_project_alias_cache(conn, alias_entries)
|
||||
return {'entries': alias_entries, 'cacheInfo': cache_info}
|
||||
|
||||
|
||||
def rebuild_erp_linked_code_cache(conn, source_page='const'):
|
||||
source_page = _as_text(source_page).strip().lower() or 'const'
|
||||
rows = conn.execute(
|
||||
'''
|
||||
SELECT p.projectCode,
|
||||
p.projectName,
|
||||
COALESCE(d.businessCode, '')
|
||||
FROM erp_project_code_cache p
|
||||
LEFT JOIN erp_contract_detail_cache d
|
||||
ON d.sourcePage = p.sourcePage
|
||||
AND d.projectCode = p.projectCode
|
||||
WHERE p.sourcePage = ?
|
||||
ORDER BY p.projectCode ASC
|
||||
''',
|
||||
(source_page,),
|
||||
).fetchall()
|
||||
synced_at = datetime.now().isoformat(timespec='seconds')
|
||||
payload_rows = []
|
||||
for project_code, project_name, business_code in rows:
|
||||
linked_codes = find_erp_linked_codes(project_code, business_code, project_name)
|
||||
if linked_codes.get('salesCode') or linked_codes.get('designCode'):
|
||||
payload_rows.append(
|
||||
(
|
||||
source_page,
|
||||
_as_text(project_code).strip(),
|
||||
_as_text(project_name).strip(),
|
||||
_extract_business_code_key(business_code),
|
||||
_as_text(linked_codes.get('salesCode')).strip(),
|
||||
_as_text(linked_codes.get('salesName')).strip(),
|
||||
_as_text(linked_codes.get('designCode')).strip(),
|
||||
_as_text(linked_codes.get('designName')).strip(),
|
||||
_as_text(linked_codes.get('matchedBy')).strip(),
|
||||
synced_at,
|
||||
)
|
||||
)
|
||||
conn.execute('DELETE FROM erp_linked_code_cache WHERE sourcePage = ?', (source_page,))
|
||||
if payload_rows:
|
||||
conn.executemany(
|
||||
'''
|
||||
INSERT OR REPLACE INTO erp_linked_code_cache
|
||||
(sourcePage, projectCode, projectName, businessCode, salesCode, salesName, designCode, designName, matchedBy, syncedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
payload_rows,
|
||||
)
|
||||
conn.commit()
|
||||
return len(payload_rows)
|
||||
|
||||
|
||||
def rebuild_work_calendar_tables(conn):
|
||||
conn.execute('DELETE FROM work_calendar_detail')
|
||||
conn.execute('DELETE FROM work_calendar_day')
|
||||
@@ -3340,7 +3712,8 @@ def _duration_sql(alias='d'):
|
||||
|
||||
|
||||
def get_people_summary(conn, start_date, end_date):
|
||||
q = f'''
|
||||
params = (start_date, end_date, start_date, end_date)
|
||||
q_with_site = f'''
|
||||
WITH d AS (
|
||||
SELECT
|
||||
MemberNo,
|
||||
@@ -3375,7 +3748,37 @@ def get_people_summary(conn, start_date, end_date):
|
||||
LEFT JOIN s ON s.MemberNo = m.MemberNo
|
||||
ORDER BY CASE WHEN IFNULL(m.korName,'')='' THEN 1 ELSE 0 END, korName ASC, m.MemberNo ASC
|
||||
'''
|
||||
cur = conn.execute(q, (start_date, end_date, start_date, end_date))
|
||||
try:
|
||||
cur = conn.execute(q_with_site, params)
|
||||
except sqlite3.DatabaseError as error:
|
||||
if 'malformed' not in str(error).lower():
|
||||
raise
|
||||
q_without_site = f'''
|
||||
WITH d AS (
|
||||
SELECT
|
||||
MemberNo,
|
||||
ROUND(SUM(TotalHours), 2) AS totalHours,
|
||||
COUNT(*) AS totalRows
|
||||
FROM dallyproject
|
||||
WHERE date(substr(EntryTime,1,10)) BETWEEN date(?) AND date(?)
|
||||
GROUP BY MemberNo
|
||||
)
|
||||
SELECT
|
||||
m.MemberNo,
|
||||
IFNULL(m.korName, '') AS korName,
|
||||
IFNULL(m.rankName, '') AS rankName,
|
||||
IFNULL(m.teamName, '') AS teamName,
|
||||
IFNULL(m.retireFlag, '') AS retireFlag,
|
||||
IFNULL(m.isRetired, 0) AS isRetired,
|
||||
IFNULL(d.totalHours, 0) AS totalHours,
|
||||
IFNULL(d.totalRows, 0) AS totalRows,
|
||||
0 AS siteHours,
|
||||
0 AS siteRows
|
||||
FROM member m
|
||||
LEFT JOIN d ON d.MemberNo = m.MemberNo
|
||||
ORDER BY CASE WHEN IFNULL(m.korName,'')='' THEN 1 ELSE 0 END, korName ASC, m.MemberNo ASC
|
||||
'''
|
||||
cur = conn.execute(q_without_site, (start_date, end_date))
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
|
||||
@@ -4225,10 +4628,49 @@ class Handler(BaseHTTPRequestHandler):
|
||||
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
|
||||
if refresh:
|
||||
detail = fetch_erp_contract_detail(page_name, project_code, project_name)
|
||||
run_db_write(lambda conn: replace_erp_contract_detail_cache(conn, detail))
|
||||
def _write_refresh(conn):
|
||||
replace_erp_contract_detail_cache(conn, detail)
|
||||
replace_erp_linked_code_cache(
|
||||
conn,
|
||||
page_name,
|
||||
project_code,
|
||||
detail.get('projectName', '') or project_name,
|
||||
detail.get('businessCode', ''),
|
||||
detail.get('linkedCodes') or {},
|
||||
)
|
||||
run_db_write(_write_refresh)
|
||||
with open_db_connection() as conn:
|
||||
ensure_db_schema(conn)
|
||||
cached = get_erp_contract_detail_cache(conn, page_name, project_code)
|
||||
if cached and (
|
||||
(
|
||||
not (cached.get('linkedCodes') or {}).get('salesCode')
|
||||
and not (cached.get('linkedCodes') or {}).get('designCode')
|
||||
) or (
|
||||
(cached.get('linkedCodes') or {}).get('salesCode')
|
||||
and not (cached.get('linkedCodes') or {}).get('salesName')
|
||||
) or (
|
||||
(cached.get('linkedCodes') or {}).get('designCode')
|
||||
and not (cached.get('linkedCodes') or {}).get('designName')
|
||||
)
|
||||
):
|
||||
linked_codes = find_erp_linked_codes(project_code, cached.get('businessCode', ''), cached.get('projectName', '') or project_name)
|
||||
if linked_codes.get('salesCode') or linked_codes.get('designCode'):
|
||||
cached['linkedCodes'] = linked_codes
|
||||
def _write_backfill(conn):
|
||||
replace_erp_contract_detail_cache(conn, cached)
|
||||
replace_erp_linked_code_cache(
|
||||
conn,
|
||||
page_name,
|
||||
project_code,
|
||||
cached.get('projectName', '') or project_name,
|
||||
cached.get('businessCode', ''),
|
||||
linked_codes,
|
||||
)
|
||||
run_db_write(_write_backfill)
|
||||
with open_db_connection() as conn:
|
||||
ensure_db_schema(conn)
|
||||
cached = get_erp_contract_detail_cache(conn, page_name, project_code)
|
||||
if not cached:
|
||||
return self._json(404, {'ok': False, 'error': '계약정보 캐시가 없습니다.'})
|
||||
return self._json(
|
||||
@@ -4438,9 +4880,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if parsed.path == '/api/sync-project-aliases':
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
alias_rows = load_project_alias_from_erp()
|
||||
replace_project_alias(conn, alias_rows)
|
||||
return self._json(200, {'ok': True, 'project_alias_source': 'erp', 'project_alias_loaded': len(alias_rows)})
|
||||
ensure_db_schema(conn)
|
||||
sync_info = sync_project_alias_caches(conn)
|
||||
linked_count = rebuild_erp_linked_code_cache(conn, 'const')
|
||||
return self._json(200, {
|
||||
'ok': True,
|
||||
'project_alias_source': 'erp',
|
||||
'project_alias_loaded': len(sync_info['entries']),
|
||||
'linked_code_cache_loaded': linked_count,
|
||||
'syncedAt': sync_info['cacheInfo']['syncedAt'],
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return self._json(500, {'ok': False, 'error': str(e)})
|
||||
@@ -4461,7 +4910,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
q = parse_qs(parsed.query)
|
||||
page_name = q.get('page', ['const'])[0]
|
||||
result = fetch_erp_project_codes(page_name)
|
||||
sync_info = run_db_write(lambda conn: replace_erp_project_code_cache(conn, result['page'], result['rows']))
|
||||
def _write_project_codes(conn):
|
||||
sync_info = replace_erp_project_code_cache(conn, result['page'], result['rows'])
|
||||
linked_count = rebuild_erp_linked_code_cache(conn, result['page']) if result['page'] == 'const' else 0
|
||||
return sync_info, linked_count
|
||||
sync_info, linked_count = run_db_write(_write_project_codes)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
@@ -4469,6 +4922,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
'count': sync_info['count'],
|
||||
'page': sync_info['sourcePage'],
|
||||
'syncedAt': sync_info['syncedAt'],
|
||||
'linkedCodeCacheLoaded': linked_count,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -4482,7 +4936,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
project_code = q.get('projectCode', [''])[0]
|
||||
project_name = q.get('projectName', [''])[0]
|
||||
detail = fetch_erp_contract_detail(page_name, project_code, project_name)
|
||||
sync_info = run_db_write(lambda conn: replace_erp_contract_detail_cache(conn, detail))
|
||||
def _write_contract(conn):
|
||||
sync_info = replace_erp_contract_detail_cache(conn, detail)
|
||||
replace_erp_linked_code_cache(
|
||||
conn,
|
||||
page_name,
|
||||
project_code,
|
||||
detail.get('projectName', '') or project_name,
|
||||
detail.get('businessCode', ''),
|
||||
detail.get('linkedCodes') or {},
|
||||
)
|
||||
return sync_info
|
||||
sync_info = run_db_write(_write_contract)
|
||||
with open_db_connection() as conn:
|
||||
ensure_db_schema(conn)
|
||||
cached = get_erp_contract_detail_cache(conn, page_name, project_code)
|
||||
@@ -4546,13 +5011,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
ensure_db_schema(conn)
|
||||
used = load_from_mysql_into_sqlite(conn)
|
||||
try:
|
||||
alias_rows = load_project_alias_from_erp()
|
||||
sync_info = sync_project_alias_caches(conn)
|
||||
used['project_alias_source'] = 'erp'
|
||||
replace_project_alias(conn, alias_rows)
|
||||
used['project_alias_loaded'] = len(alias_rows)
|
||||
used['project_alias_loaded'] = len(sync_info['entries'])
|
||||
used['linked_code_cache_loaded'] = rebuild_erp_linked_code_cache(conn, 'const')
|
||||
except Exception as e:
|
||||
used['project_alias_source'] = 'erp_failed_keep_existing'
|
||||
used['project_alias_loaded'] = 0
|
||||
used['linked_code_cache_loaded'] = 0
|
||||
used['project_alias_error'] = str(e)
|
||||
# member rank는 MySQL member.rankCode -> systemconfig 매핑을 우선 사용
|
||||
# (used['member_rank_source'], used['member_rank_loaded']는 load_from_mysql_into_sqlite에서 설정)
|
||||
@@ -4591,9 +5057,10 @@ if __name__ == '__main__':
|
||||
init_db(conn)
|
||||
if os.environ.get('STARTUP_MAINTENANCE', '1') not in ('0', 'false', 'False', 'no'):
|
||||
try:
|
||||
alias_rows = load_project_alias_from_erp()
|
||||
print(f'Loaded project aliases from ERP: {len(alias_rows)}')
|
||||
replace_project_alias(conn, alias_rows)
|
||||
sync_info = sync_project_alias_caches(conn)
|
||||
print(f"Loaded project aliases from ERP: {len(sync_info['entries'])}")
|
||||
linked_count = rebuild_erp_linked_code_cache(conn, 'const')
|
||||
print(f'Loaded linked code cache: {linked_count}')
|
||||
except Exception as e:
|
||||
print(f'Load project aliases from ERP failed, keep existing aliases: {e}')
|
||||
# 직급은 MySQL member.rankCode -> systemconfig 매핑 기반으로 /api/load 시 반영
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user