Compare commits

..

3 Commits

Author SHA1 Message Date
b5e121136f feat: 엑셀 원본 파일 선택 기능 및 프론트엔드/백엔드 최적화
- PTC(2023-2026.02).xlsx 최신화
- PTC/index.html: 에러 핸들링, 동적 API 베이스, 예산 계산 로직 개선 및 UI 최적화
- server/ptc_api_server.py: 4000 포트에서 프론트엔드 직접 서빙, 원본 엑셀 경로 설정 기능, DB 인덱스 추가 및 성능 최적화
- windows/: 원본 파일 선택을 위한 set_ptc_source.bat 추가 및 기존 스크립트 수정
2026-03-24 13:21:20 +09:00
f88d8e53cb feat: 프로젝트 시공 시작일 및 종료일 관리 기능 추가 2026-03-23 16:59:45 +09:00
358585da53 Organize root directory: Moved legacy HTML files to legacy/ 2026-03-23 14:45:20 +09:00
13 changed files with 1744 additions and 833 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

50
PTC_ISSUES_LIST.md Normal file
View File

@@ -0,0 +1,50 @@
# PTC 프로젝트 이슈 정리 (레이블: ptc 실행분석)
## 1. [마스터] [PTC::실행분석] 전용 실행 분석 및 계정 관리 시스템 구축
**설명**: PTC 전용 실행 분석 시스템 구축을 위한 전체 진행 상황을 관리하는 마스터 이슈입니다.
### 체크리스트
- [ ] UI 렌더링 완성 (PTC 데이터 선택 시 테이블 공백 문제 해결)
- [ ] PTC 고유 계정 체계(7xx, 8xx, 513) 분류 로직 고도화
- [ ] PTC 전용 실행 예산 보고서 양식 개발 및 출력 기능
- [ ] PTC 대시보드 고도화 (순유입/유출 잔액 합계 및 시각화 개선)
---
## 2. [PTC::UI] 테이블 렌더링 오류 수정
**설명**: `index.html` 등에서 PTC 데이터를 불러올 때 실행 예산 테이블이 빈 공백으로 표시되는 문제를 해결합니다.
### 주요 작업
- PTC 전용 데이터 매핑 정의 추가 (7xx, 8xx 계정 대응)
- 데이터 로드 후 UI 렌더링 분기 로직 점검 및 수정
- 테이블 데이터가 없을 경우의 예외 처리 강화
---
## 3. [PTC::계정] 전용 계정 코드(7xx, 8xx, 513) 분류 로직 강화
**설명**: 분석 로직에서 PTC 고유의 계정 체계를 정확히 인식하도록 개선합니다.
### 주요 작업
- 7xx(시공), 8xx(관리) 계정 코드에 대한 분류 로직 최적화
- 513(시공 퇴직금) 항목의 프로젝트별 분리 및 예산 대비 실적 비교 기능 검증
- PTC 프로젝트 성격 기반 계정 추천 로직 최적화
---
## 4. [PTC::보고서] 전용 실행 예산 보고서 양식 개발
**설명**: PTC의 공사원가 및 관리비 기준에 최적화된 보고서 출력 양식을 구현합니다.
### 주요 작업
- PTC 전용 엑셀/PDF 출력 템플릿 설계
- 실행 예산 보고서 내 계정별 집계 데이터 매핑
- PTC 특화 항목(현장운영비, 보증료 등) 반영
---
## 5. [PTC::대시보드] 순유입/유출 잔액 합계 및 시각화 개선
**설명**: 대시보드에서 PTC 데이터를 보여줄 때, 잔액 계산 방식과 시각적 표현을 개선합니다.
### 주요 작업
- PTC 순유입 및 유출 잔액 합계 산출 로직 개선
- 대시보드 상의 차트 및 요약 테이블에 실시간 데이터 반영
- 데이터 동기화 및 탭 전환 최적화

View File

@@ -11,15 +11,15 @@
## 실행 방법
### 1. API 서버 실행
### 1. 서버 실행
```bash
python3 server/ptc_api_server.py
```
서버는 기본적으로 4000 포트에서 실행니다.
서버는 기본적으로 4000 포트에서 실행되며, API와 프론트엔드(`/PTC/`)를 함께 제공합니다.
### 2. 프론트엔드 접속
`PTC/index.html` 파일을 브라우저로 열거나, 로컬 웹 서버(예: 8000 포트)를 통해 접속합니다.
API 서버 주소는 `index.html``API_BASE` 변수에서 설정할 수 있습니다.
브라우저에서 `http://localhost:4000/PTC/` 접속합니다.
필요하면 `index.html``apiBase` 쿼리 파라미터로 API 주소를 덮어쓸 수 있습니다.
## 데이터 업데이트
`db/import_ptc_xlsx.py` 스크립트를 사용하여 엑셀 데이터를 DB로 변환할 수 있습니다. 자세한 내용은 `db/README.md`를 참조하세요.

View File

@@ -14,9 +14,12 @@ from zipfile import ZipFile
BASE_DIR = Path("/home/hyein/project")
XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx"
DEFAULT_XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx"
XLSX_SOURCE_CONFIG_PATH = BASE_DIR / "server" / "ptc_source_path.txt"
METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx"
DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3"
FRONTEND_INDEX_PATH = BASE_DIR / "PTC" / "index.html"
FRONTEND_CACHE: dict[str, str | int] = {"mtime": -1, "html": ""}
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"]
METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
@@ -169,6 +172,28 @@ ACCOUNT_STRUCTURE_TEMPLATE = [
]
def get_xlsx_path() -> Path:
if XLSX_SOURCE_CONFIG_PATH.exists():
configured = XLSX_SOURCE_CONFIG_PATH.read_text(encoding="utf-8").strip()
if configured:
path = Path(configured).expanduser()
if not path.is_absolute():
path = (BASE_DIR / path).resolve()
return path
return DEFAULT_XLSX_PATH
def get_frontend_html() -> str:
if not FRONTEND_INDEX_PATH.exists():
raise FileNotFoundError("PTC frontend not found")
mtime = int(FRONTEND_INDEX_PATH.stat().st_mtime)
if FRONTEND_CACHE["mtime"] != mtime:
FRONTEND_CACHE["mtime"] = mtime
FRONTEND_CACHE["html"] = FRONTEND_INDEX_PATH.read_text(encoding="utf-8")
return str(FRONTEND_CACHE["html"])
def infer_project_type_from_code(project_code: str) -> str:
match = re.match(r"\d{2}-(.+?)-\d+", (project_code or "").strip())
return match.group(1) if match else ""
@@ -433,6 +458,8 @@ def init_db() -> None:
project_type text,
construction_family text,
construction_method text,
start_date text,
end_date text,
note text,
updated_at text not null
)
@@ -456,6 +483,8 @@ def init_db() -> None:
create table if not exists project_progress (
project_code text primary key,
progress_rate real not null default 0,
contract_pile_count real not null default 0,
constructed_pile_count real not null default 0,
updated_at text not null
)
"""
@@ -475,9 +504,52 @@ def init_db() -> None:
)
"""
)
cur.execute(
"""
create table if not exists project_pile_progress_entries (
id integer primary key autoincrement,
project_code text not null,
work_date text not null,
start_date text not null,
end_date text,
pile_count real not null default 0,
note text,
sort_order integer not null default 0,
updated_at text not null
)
"""
)
cur.execute("create index if not exists idx_ptc_transactions_project_code on ptc_transactions(project_code)")
cur.execute("create index if not exists idx_ptc_transactions_project_code_date on ptc_transactions(project_code, transaction_date desc, source_row_no desc)")
cur.execute("create index if not exists idx_ptc_transactions_project_code_account on ptc_transactions(project_code, account_code_final)")
cur.execute("create index if not exists idx_ptc_transactions_project_code_in_out on ptc_transactions(project_code, in_out)")
cur.execute("create index if not exists idx_ptc_transactions_vendor_name on ptc_transactions(vendor_name)")
cur.execute("create index if not exists idx_ptc_transactions_account_code_final on ptc_transactions(account_code_final)")
cur.execute("create index if not exists idx_project_pile_progress_entries_project_code on project_pile_progress_entries(project_code)")
cur.execute("create index if not exists idx_project_budget_lines_project_code on project_budget_lines(project_code)")
cur.execute("create index if not exists idx_project_budget_account_lines_project_code on project_budget_account_lines(project_code)")
existing_cols = [row["name"] for row in cur.execute("pragma table_info(project_master)").fetchall()]
if "construction_family" not in existing_cols:
cur.execute("alter table project_master add column construction_family text")
if "start_date" not in existing_cols:
cur.execute("alter table project_master add column start_date text")
if "end_date" not in existing_cols:
cur.execute("alter table project_master add column end_date text")
progress_cols = [row["name"] for row in cur.execute("pragma table_info(project_progress)").fetchall()]
if "contract_pile_count" not in progress_cols:
cur.execute("alter table project_progress add column contract_pile_count real not null default 0")
if "constructed_pile_count" not in progress_cols:
cur.execute("alter table project_progress add column constructed_pile_count real not null default 0")
pile_progress_cols = [row["name"] for row in cur.execute("pragma table_info(project_pile_progress_entries)").fetchall()]
if "work_date" not in pile_progress_cols:
cur.execute("alter table project_pile_progress_entries add column work_date text")
if "start_date" not in pile_progress_cols and "work_date" in pile_progress_cols:
cur.execute("alter table project_pile_progress_entries add column start_date text")
cur.execute("update project_pile_progress_entries set start_date = coalesce(nullif(start_date, ''), work_date)")
if "end_date" not in pile_progress_cols:
cur.execute("alter table project_pile_progress_entries add column end_date text")
cur.execute("update project_pile_progress_entries set work_date = coalesce(nullif(work_date, ''), start_date)")
cur.execute("update project_pile_progress_entries set end_date = coalesce(nullif(end_date, ''), start_date)")
txn_cols = [row["name"] for row in cur.execute("pragma table_info(ptc_transactions)").fetchall()]
if "account_code_final" not in txn_cols:
cur.execute("alter table ptc_transactions add column account_code_final text")
@@ -493,11 +565,15 @@ def init_db() -> None:
)
conn.commit()
xlsx_mtime = str(int(XLSX_PATH.stat().st_mtime))
row = cur.execute("select value from meta where key = 'xlsx_mtime'").fetchone()
needs_refresh = row is None or row["value"] != xlsx_mtime
xlsx_path = get_xlsx_path()
if not xlsx_path.exists():
raise FileNotFoundError(f"PTC source xlsx not found: {xlsx_path}")
xlsx_source_signature = f"{xlsx_path.resolve()}|{int(xlsx_path.stat().st_mtime)}"
row = cur.execute("select value from meta where key = 'xlsx_source_signature'").fetchone()
needs_refresh = row is None or row["value"] != xlsx_source_signature
if needs_refresh:
rows = read_xlsx_rows(XLSX_PATH)
rows = read_xlsx_rows(xlsx_path)
cur.execute("delete from ptc_transactions")
cur.executemany(
"""
@@ -516,9 +592,9 @@ def init_db() -> None:
[{**item, "imported_at": datetime.utcnow().isoformat()} for item in rows],
)
cur.execute(
"insert into meta(key, value) values('xlsx_mtime', ?) "
"insert into meta(key, value) values('xlsx_source_signature', ?) "
"on conflict(key) do update set value = excluded.value",
(xlsx_mtime,),
(xlsx_source_signature,),
)
conn.commit()
@@ -577,7 +653,7 @@ def init_db() -> None:
def fetch_project_master(conn: sqlite3.Connection, project_code: str) -> dict | None:
row = conn.execute(
"""
select project_code, project_name, project_type, construction_family, construction_method, note, updated_at
select project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at
from project_master
where project_code = ?
""",
@@ -728,6 +804,21 @@ def build_account_structure_rows(account_rows: list[sqlite3.Row]) -> list[dict]:
def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_structure_rows: list[dict]) -> dict:
pile_progress_rows = conn.execute(
"""
select
id,
coalesce(nullif(start_date, ''), work_date) as start_date,
coalesce(nullif(end_date, ''), nullif(start_date, ''), work_date) as end_date,
pile_count,
note,
sort_order
from project_pile_progress_entries
where project_code = ?
order by coalesce(nullif(start_date, ''), work_date) asc, sort_order asc, id asc
""",
(project_code,),
).fetchall()
item_budget_rows = conn.execute(
"""
select section, group_name, category, budget_amount
@@ -753,10 +844,17 @@ def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_s
for row in budget_rows
}
progress_row = conn.execute(
"select progress_rate from project_progress where project_code = ?",
"select progress_rate, contract_pile_count, constructed_pile_count from project_progress where project_code = ?",
(project_code,),
).fetchone()
progress_rate = progress_row["progress_rate"] if progress_row else 0
contract_pile_count = float(progress_row["contract_pile_count"] or 0) if progress_row else 0
constructed_pile_count = float(progress_row["constructed_pile_count"] or 0) if progress_row else 0
entry_pile_total = sum(float(row["pile_count"] or 0) for row in pile_progress_rows)
if pile_progress_rows:
constructed_pile_count = entry_pile_total
if contract_pile_count > 0:
progress_rate = (constructed_pile_count / contract_pile_count) * 100
rows = []
expense_budget_total = 0.0
@@ -801,6 +899,9 @@ def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_s
execution_rate_total = (expense_actual_total / expense_budget_total * 100) if expense_budget_total > 0 else 0
return {
"progress_rate": progress_rate,
"contract_pile_count": contract_pile_count,
"constructed_pile_count": constructed_pile_count,
"pile_progress_entries": [dict(row) for row in pile_progress_rows],
"execution_rate_total": execution_rate_total,
"expense_budget_total": expense_budget_total,
"expense_actual_total": expense_actual_total,
@@ -848,7 +949,7 @@ def build_where(params: dict[str, list[str]]) -> tuple[str, list]:
def build_project_where(project_code: str, keyword: str = "", in_out: str = "전체") -> tuple[str, list]:
clauses = ["coalesce(project_code, '') = ?"]
clauses = ["project_code = ?"]
values = [project_code]
if keyword.strip():
@@ -941,6 +1042,12 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _send_frontend(self) -> None:
if not FRONTEND_INDEX_PATH.exists():
self._send_html(404, "<h1>PTC frontend not found</h1>")
return
self._send_html(200, get_frontend_html())
def _send(self, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
@@ -977,23 +1084,27 @@ class Handler(BaseHTTPRequestHandler):
self._send(400, {"ok": False, "message": "invalid construction_method"})
return
construction_family = resolve_construction_family(construction_method, construction_family)
start_date = str(payload.get("start_date", "")).strip()
end_date = str(payload.get("end_date", "")).strip()
note = str(payload.get("note", "")).strip()
updated_at = datetime.now().isoformat()
conn.execute(
"""
insert into project_master (
project_code, project_name, project_type, construction_family, construction_method, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?)
project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(project_code) do update set
project_name = excluded.project_name,
project_type = excluded.project_type,
construction_family = excluded.construction_family,
construction_method = excluded.construction_method,
start_date = excluded.start_date,
end_date = excluded.end_date,
note = excluded.note,
updated_at = excluded.updated_at
""",
(project_code, project_name, project_type, construction_family, construction_method, note, updated_at),
(project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at),
)
conn.commit()
self._send(200, {"ok": True, "item": fetch_project_master(conn, project_code)})
@@ -1030,16 +1141,21 @@ class Handler(BaseHTTPRequestHandler):
)
merged_note = note if note else (existing.get("note") or "")
start_date = existing.get("start_date") or ""
end_date = existing.get("end_date") or ""
conn.execute(
"""
insert into project_master (
project_code, project_name, project_type, construction_family, construction_method, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?)
project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(project_code) do update set
project_name = excluded.project_name,
project_type = excluded.project_type,
construction_family = excluded.construction_family,
construction_method = excluded.construction_method,
start_date = excluded.start_date,
end_date = excluded.end_date,
note = excluded.note,
updated_at = excluded.updated_at
""",
@@ -1049,6 +1165,8 @@ class Handler(BaseHTTPRequestHandler):
project_type,
construction_family,
construction_method,
start_date,
end_date,
merged_note,
updated_at,
),
@@ -1181,6 +1299,8 @@ class Handler(BaseHTTPRequestHandler):
item_rows = payload.get("item_rows", [])
account_rows = payload.get("account_rows", [])
progress_rate = float(payload.get("progress_rate", 0) or 0)
contract_pile_count = float(payload.get("contract_pile_count", 0) or 0)
constructed_pile_count = float(payload.get("constructed_pile_count", 0) or 0)
if not project_code:
self._send(400, {"ok": False, "message": "project_code is required"})
return
@@ -1228,18 +1348,84 @@ class Handler(BaseHTTPRequestHandler):
)
conn.execute(
"""
insert into project_progress (project_code, progress_rate, updated_at)
values (?, ?, ?)
insert into project_progress (
project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at
)
values (?, ?, ?, ?, ?)
on conflict(project_code) do update set
progress_rate = excluded.progress_rate,
contract_pile_count = excluded.contract_pile_count,
constructed_pile_count = excluded.constructed_pile_count,
updated_at = excluded.updated_at
""",
(project_code, progress_rate, updated_at),
(project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at),
)
conn.commit()
self._send(200, {"ok": True, "project_code": project_code, "updated_at": updated_at})
return
if parsed.path == "/api/project-pile-progress/upsert":
payload = self._read_json()
project_code = str(payload.get("project_code", "")).strip()
contract_pile_count = float(payload.get("contract_pile_count", 0) or 0)
entries = payload.get("entries", [])
if not project_code:
self._send(400, {"ok": False, "message": "project_code is required"})
return
if not isinstance(entries, list):
self._send(400, {"ok": False, "message": "entries must be a list"})
return
updated_at = datetime.now().isoformat()
conn.execute("delete from project_pile_progress_entries where project_code = ?", (project_code,))
constructed_pile_count = 0.0
for idx, item in enumerate(entries):
start_date = str(item.get("start_date", "")).strip()
end_date = str(item.get("end_date", "")).strip()
pile_count = float(item.get("pile_count", 0) or 0)
note = str(item.get("note", "")).strip()
if not start_date:
continue
if not end_date:
end_date = start_date
constructed_pile_count += pile_count
conn.execute(
"""
insert into project_pile_progress_entries (
project_code, work_date, start_date, end_date, pile_count, note, sort_order, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(project_code, start_date, start_date, end_date, pile_count, note, idx, updated_at),
)
progress_rate = (constructed_pile_count / contract_pile_count * 100) if contract_pile_count > 0 else 0
conn.execute(
"""
insert into project_progress (
project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at
)
values (?, ?, ?, ?, ?)
on conflict(project_code) do update set
progress_rate = excluded.progress_rate,
contract_pile_count = excluded.contract_pile_count,
constructed_pile_count = excluded.constructed_pile_count,
updated_at = excluded.updated_at
""",
(project_code, progress_rate, contract_pile_count, constructed_pile_count, updated_at),
)
conn.commit()
self._send(
200,
{
"ok": True,
"project_code": project_code,
"contract_pile_count": contract_pile_count,
"constructed_pile_count": constructed_pile_count,
"progress_rate": progress_rate,
"updated_at": updated_at,
},
)
return
self._send(404, {"ok": False, "message": "Not found"})
finally:
conn.close()
@@ -1249,8 +1435,13 @@ class Handler(BaseHTTPRequestHandler):
params = parse_qs(parsed.query)
conn = get_conn()
try:
if parsed.path in {"/PTC", "/PTC/", "/PTC/index.html"}:
self._send_frontend()
return
if parsed.path == "/":
count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"]
xlsx_path = get_xlsx_path()
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
@@ -1347,22 +1538,23 @@ class Handler(BaseHTTPRequestHandler):
<div style="display:inline-flex;padding:7px 12px;border-radius:999px;background:#eaf3fb;color:#124c7c;font-size:11px;font-weight:700;">PTC Data API</div>
<h1 style="font-size:36px;line-height:1.2;margin:16px 0 12px;">PTC 원장 데이터 서버</h1>
<p class="subtle">
이 서버는 `PTC(2023-2026.02).xlsx`를 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다.
메인 화면은 <a href="http://localhost:8000/PTC">http://localhost:8000/PTC</a> 에서 확인할 수 있습니다.
이 서버는 선택된 PTC 원본 엑셀 파일을 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다.
메인 화면은 <a href="http://localhost:4000/PTC/">http://localhost:4000/PTC/</a> 에서 바로 확인할 수 있습니다.
</p>
</div>
<div class="card">
<div style="font-size:12px;color:var(--muted);font-weight:700;">현재 적재 건수</div>
<div style="font-size:34px;font-weight:700;margin-top:10px;">{count:,}</div>
<div class="subtle" style="margin-top:10px;">원본 파일 기준 전체 거래 행 수</div>
<div class="subtle" style="margin-top:10px;word-break:break-all;">원본 파일: {xlsx_path}</div>
</div>
</div>
<div class="cards">
<div class="card">
<div style="font-size:12px;color:var(--muted);font-weight:700;">메인 화면</div>
<div style="font-size:20px;font-weight:700;margin-top:8px;">localhost:8000/PTC</div>
<div class="subtle" style="margin-top:10px;">사용자가 보는 메인 대시보드</div>
<div style="font-size:20px;font-weight:700;margin-top:8px;">localhost:4000/PTC/</div>
<div class="subtle" style="margin-top:10px;">사용자가 보는 메인 대시보드와 API를 같은 서버에서 제공합니다.</div>
</div>
<div class="card">
<div style="font-size:12px;color:var(--muted);font-weight:700;">헬스체크</div>
@@ -1459,7 +1651,7 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path == "/api/projects":
keyword = params.get("keyword", [""])[0].strip().lower()
project_type = params.get("project_type", ["전체"])[0]
clauses = ["coalesce(project_code, '') <> ''"]
clauses = ["project_code is not null", "project_code <> ''"]
values = []
if keyword:
like = f"%{keyword}%"
@@ -1495,8 +1687,23 @@ class Handler(BaseHTTPRequestHandler):
values,
).fetchall()
items = rows_to_dicts(rows)
project_codes = [item["project_code"] for item in items if item.get("project_code")]
master_rows = {}
if project_codes:
placeholders = ",".join("?" for _ in project_codes)
master_rows = {
row["project_code"]: dict(row)
for row in conn.execute(
f"""
select project_code, project_name, project_type, construction_family, construction_method, note
from project_master
where project_code in ({placeholders})
""",
project_codes,
).fetchall()
}
for item in items:
master = fetch_project_master(conn, item["project_code"])
master = master_rows.get(item["project_code"])
item["project_type"] = resolve_project_type(
item["project_code"],
item["project_type"],
@@ -1968,6 +2175,104 @@ class Handler(BaseHTTPRequestHandler):
)
return
if parsed.path == "/api/project-budget-actual-detail":
project_code = params.get("project_code", [""])[0].strip()
section = params.get("section", [""])[0].strip()
group_name = params.get("group_name", [""])[0].strip()
category = params.get("category", [""])[0].strip()
if not project_code or not section or not group_name or not category:
self._send(400, {"ok": False, "message": "project_code, section, group_name, category are required"})
return
category_accounts = get_category_account_items(section, group_name, category)
account_codes = [item["account_code"] for item in category_accounts if item.get("account_code")]
if not account_codes:
self._send(
200,
{
"project_code": project_code,
"section": section,
"group_name": group_name,
"category": category,
"summary": {"txn_count": 0, "income_count": 0, "expense_count": 0, "income_sum": 0, "expense_sum": 0, "supply_sum": 0},
"accounts": [],
"transactions": [],
},
)
return
placeholders = ",".join("?" for _ in account_codes)
values = [project_code, *account_codes]
summary = conn.execute(
f"""
select
count(*) as txn_count,
sum(case when in_out = '입금' then 1 else 0 end) as income_count,
sum(case when in_out = '출금' then 1 else 0 end) as expense_count,
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_sum,
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_sum,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
where project_code = ?
and account_code_final in ({placeholders})
""",
values,
).fetchone()
account_rows = conn.execute(
f"""
select
account_code_final as account_code,
max(account_name_final) as account_name,
count(*) as txn_count,
coalesce(sum(supply_amount), 0) as supply_sum
from ptc_transactions
where project_code = ?
and account_code_final in ({placeholders})
group by account_code_final
order by account_code_final
""",
values,
).fetchall()
transaction_rows = conn.execute(
f"""
select
source_row_no,
transaction_date,
in_out,
account_code_final as account_code,
account_name_final as account_name,
department_name,
vendor_name,
description,
supply_amount,
vat_amount,
total_amount
from ptc_transactions
where project_code = ?
and account_code_final in ({placeholders})
order by transaction_date desc, source_row_no desc
limit 100
""",
values,
).fetchall()
self._send(
200,
{
"project_code": project_code,
"section": section,
"group_name": group_name,
"category": category,
"summary": dict(summary) if summary else None,
"accounts": rows_to_dicts(account_rows),
"transactions": rows_to_dicts(transaction_rows),
},
)
return
if parsed.path == "/api/top-accounts":
where, values = build_where(params)
rows = conn.execute(

View File

@@ -0,0 +1 @@
/home/hyein/project/PTC(2023-2026.02).xlsx

View File

@@ -1,14 +1,17 @@
사용 파일
- start_ptc_share.bat : 관리자 권한으로 실행되며, WSL 서버 시작 + portproxy + 방화벽까지 자동 설정
- start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
- set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다.
- stop_ptc_share.bat : 공유 중지
- check_ptc_share.bat : 현재 공유 상태 확인
사용 순서
1. start_ptc_share.bat 실행
2. 브라우저에서 http://172.16.40.36:8000/PTC/ 확인
3. 안 되면 check_ptc_share.bat 실행
1. 원본 파일을 바꾸려면 set_ptc_source.bat 실행
2. start_ptc_share.bat 실행
3. 같은 PC에서는 `http://localhost:4000/PTC/` 확인
4. 다른 사람에게는 배치파일이 출력한 `http://내PCIP:4000/PTC/` 주소 전달
4. 안 되면 check_ptc_share.bat 실행
주의
- PC가 켜져 있어야 합니다.
- WSL이 재시작되어 IP가 바뀌면 start_ptc_share.bat 를 다시 실행하세요.
- 관리자 권한이 필요합니다.
- 이제 프론트와 API를 모두 4000 포트 하나로 제공합니다.

View File

@@ -1,20 +1,26 @@
@echo off
setlocal EnableExtensions
set "HOST_IP="
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$ip = Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Sort-Object InterfaceMetric, SkipAsSource ^| Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do (
set "HOST_IP=%%i"
)
if "%HOST_IP%"=="" set "HOST_IP=localhost"
echo [Windows portproxy]
netsh interface portproxy show v4tov4
echo.
echo [WSL web]
wsl.exe bash -lc "curl -I -s http://127.0.0.1:8000/PTC/ | head -n 1"
echo.
echo [WSL api]
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health"
echo.
echo [WSL web]
wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC/ | head -n 1"
echo.
echo [Office LAN web]
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:8000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
echo.
echo [Office LAN api]
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
echo.
pause

View File

@@ -0,0 +1,42 @@
@echo off
setlocal EnableExtensions
set "CONFIG_PATH=/home/hyein/project/server/ptc_source_path.txt"
set "SELECTED_FILE="
set "WSL_SOURCE_PATH="
for /f "usebackq delims=" %%i in (`powershell -NoProfile -STA -Command "Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.OpenFileDialog; $dialog.Filter = 'Excel Files (*.xlsx)|*.xlsx'; $dialog.Title = 'PTC 원본 엑셀 파일 선택'; $dialog.InitialDirectory = [Environment]::GetFolderPath('Desktop'); if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $dialog.FileName }"`) do (
set "SELECTED_FILE=%%i"
)
if "%SELECTED_FILE%"=="" (
echo 파일 선택이 취소되었습니다.
pause
exit /b 1
)
echo 선택한 파일:
echo %SELECTED_FILE%
for /f "usebackq delims=" %%i in (`wsl.exe wslpath -a "%SELECTED_FILE%"`) do (
set "WSL_SOURCE_PATH=%%i"
)
if "%WSL_SOURCE_PATH%"=="" (
echo WSL 경로 변환에 실패했습니다.
pause
exit /b 1
)
wsl.exe bash -lc "printf '%s\n' \"%WSL_SOURCE_PATH%\" > %CONFIG_PATH%"
if errorlevel 1 (
echo 원본 파일 설정 저장에 실패했습니다.
pause
exit /b 1
)
echo 설정 저장 완료
echo 다음 실행부터 이 파일을 사용합니다.
echo.
echo 바로 서버를 다시 시작합니다...
call "%~dp0start_ptc_share.bat"

View File

@@ -1,70 +1,77 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
setlocal EnableExtensions
set "PROJECT_DIR=/home/hyein/project"
set "WEB_PORT=8000"
set "API_PORT=4000"
set "LOCAL_URL=http://localhost:4000/PTC/"
set "SHARE_URL="
set "LAN_IP="
set "CURRENT_SOURCE="
net session >nul 2>&1
if not "%errorlevel%"=="0" (
echo 관리자 권한으로 다시 실행합니다...
echo 관리자 권한으로 다시 실행해 공유 설정까지 적용합니다...
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b
)
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "if [ -f /home/hyein/project/server/ptc_source_path.txt ]; then cat /home/hyein/project/server/ptc_source_path.txt; else echo /home/hyein/project/PTC(2023-2026.02).xlsx; fi"`) do (
set "CURRENT_SOURCE=%%i"
)
echo PTC 서버 시작 중...
echo 원본 파일: %CURRENT_SOURCE%
wsl.exe bash -lc "pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 3"
if errorlevel 1 (
echo WSL에서 서버 시작 명령 실행에 실패했습니다.
echo WSL이 실행 가능한지 확인해 주세요.
pause
exit /b 1
)
echo 로컬 서버 상태 확인 중...
wsl.exe bash -lc "curl -fsS http://127.0.0.1:4000/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:4000/PTC/ >/tmp/ptc_web_health.txt"
if errorlevel 1 (
echo 로컬 서버 확인에 실패했습니다.
echo 아래 로그를 확인해 주세요.
wsl.exe bash -lc "tail -n 80 /tmp/ptc_api.log"
pause
exit /b 1
)
echo 브라우저를 엽니다...
start "" "%LOCAL_URL%"
echo.
echo 로컬 실행 완료
echo 메인 화면: %LOCAL_URL%
echo.
echo 사내망 공유용 Windows IP 확인 중...
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$ip = Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do (
set "LAN_IP=%%i"
)
echo WSL IP 확인 중...
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | cut -d' ' -f1"`) do (
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | awk '{print $1}'"`) do (
set "WSL_IP=%%i"
)
if "%WSL_IP%"=="" (
echo WSL IP를 확인하지 못했습니다.
pause
exit /b 1
)
if "%LAN_IP%"=="" goto :share_done
if "%WSL_IP%"=="" goto :share_done
echo WSL IP: %WSL_IP%
echo 기존 서버 정리 및 재실행 중...
wsl.exe bash -lc "pkill -f 'python3 -m http.server 8000' >/dev/null 2>&1 || true; pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 -m http.server 8000 --directory /home/hyein/project >/tmp/ptc_web.log 2>&1 & nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 2"
echo 포트포워딩 갱신 중...
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=%WEB_PORT% >nul 2>&1
echo 사내망 공유 설정 중...
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% >nul 2>&1
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=%WEB_PORT% connectaddress=%WSL_IP% connectport=%WEB_PORT%
if errorlevel 1 (
echo 8000 포트포워딩 설정에 실패했습니다.
pause
exit /b 1
)
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% connectaddress=%WSL_IP% connectport=%API_PORT%
if errorlevel 1 (
echo 4000 포트포워딩 설정에 실패했습니다.
pause
exit /b 1
)
echo 방화벽 규칙 적용 중...
netsh advfirewall firewall delete rule name="PTC 8000" >nul 2>&1
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% connectaddress=%WSL_IP% connectport=%API_PORT% >nul 2>&1
netsh advfirewall firewall delete rule name="PTC 4000" >nul 2>&1
netsh advfirewall firewall add rule name="PTC 8000" dir=in action=allow protocol=TCP localport=%WEB_PORT% >nul
netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=%API_PORT% >nul
netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=%API_PORT% >nul 2>&1
echo 서버 상태 확인 중...
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health >/tmp/ptc_api_health.json && curl -I -s http://127.0.0.1:8000/PTC/ >/tmp/ptc_web_health.txt"
if errorlevel 1 (
echo WSL 내부 서버 확인에 실패했습니다.
echo /tmp/ptc_api.log 와 /tmp/ptc_web.log 를 확인해 주세요.
pause
exit /b 1
:share_done
if not "%LAN_IP%"=="" (
set "SHARE_URL=http://%LAN_IP%:%API_PORT%/PTC/"
echo 사내망 접속 주소: %SHARE_URL%
echo %SHARE_URL%| clip
echo 공유 주소를 클립보드에 복사했습니다.
)
echo.
echo 공유 준비 완료
echo 메인 화면: http://172.16.40.36:%WEB_PORT%/PTC/
echo API 안내 : http://172.16.40.36:%API_PORT%/
echo.
echo 참고: WSL이 재시작되어 IP가 바뀌면 이 파일을 다시 실행하세요.
pause