Compare commits

...

8 Commits

6 changed files with 787 additions and 22 deletions

6
.gitignore vendored
View File

@@ -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
View 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를 다시 긁지 않아도 커밋 시점 기준의 데이터를 바로 볼 수 있습니다.

View File

@@ -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}"

Binary file not shown.

View File

@@ -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