commit 35ababe236fe2980ea3db92e3a77db3837cd76e4 Author: 김혜인 Date: Mon Mar 23 14:44:39 2026 +0900 Initial commit: Organized PTC project structure with .gitignore and README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e75f3a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script, before a executable +# is created. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesist/ +.pytest_cache/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the Python version is actually +# a property of the package, not the developer's system. +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or even +# fail to install them. +# Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm.lock + +# PEP 582; used by e.g. github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# SQLite +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal + +# Excel (Temp files) +~$*.xlsx + +# Local data +db/ptc_staging.csv diff --git a/PTC(2023-2026.02).xlsx b/PTC(2023-2026.02).xlsx new file mode 100644 index 0000000..9be5b08 Binary files /dev/null and b/PTC(2023-2026.02).xlsx differ diff --git a/PTC/index.html b/PTC/index.html new file mode 100644 index 0000000..8e5f8af --- /dev/null +++ b/PTC/index.html @@ -0,0 +1,3100 @@ + + + + + + PTC 프로젝트 관리 + + + + + + + +
+ + + diff --git a/PTC공법.xlsx b/PTC공법.xlsx new file mode 100644 index 0000000..d89c5a6 Binary files /dev/null and b/PTC공법.xlsx differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..516ea7f --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# PTC Project Management System + +이 프로젝트는 `PTC(2023-2026.02).xlsx` 데이터를 기반으로 한 프로젝트 관리 및 집행 분석 시스템입니다. + +## 주요 구성 + +- **Frontend (`/PTC/index.html`)**: React 기반의 단일 페이지 애플리케이션 (SPA)으로 프로젝트 대시보드 및 예산 관리를 담당합니다. +- **Backend (`/server/ptc_api_server.py`)**: Python 기반의 API 서버로 SQLite DB를 통해 데이터를 제공합니다. +- **Database (`/db/`)**: PostgreSQL 스키마 및 로컬 SQLite DB 관련 스크립트가 포함되어 있습니다. +- **Windows Scripts (`/windows/`)**: 로컬 환경에서 서버를 실행하고 공유하기 위한 배치 파일들입니다. + +## 실행 방법 + +### 1. API 서버 실행 +```bash +python3 server/ptc_api_server.py +``` +서버는 기본적으로 4000 포트에서 실행됩니다. + +### 2. 프론트엔드 접속 +`PTC/index.html` 파일을 브라우저로 열거나, 로컬 웹 서버(예: 8000 포트)를 통해 접속합니다. +API 서버 주소는 `index.html` 내의 `API_BASE` 변수에서 설정할 수 있습니다. + +## 데이터 업데이트 +`db/import_ptc_xlsx.py` 스크립트를 사용하여 엑셀 데이터를 DB로 변환할 수 있습니다. 자세한 내용은 `db/README.md`를 참조하세요. diff --git a/combine.html b/combine.html new file mode 100644 index 0000000..66e8938 --- /dev/null +++ b/combine.html @@ -0,0 +1,648 @@ + + + + + + PTC 거래 원장 분석 페이지 + + + + + + + + + +
+ + + + diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..9c033f6 --- /dev/null +++ b/db/README.md @@ -0,0 +1,324 @@ +# Execution Budget DB + +회사 실행예산 분석 시스템을 바로 시작할 수 있도록 만든 PostgreSQL 기본 스키마입니다. + +현재 문서에는 2가지 데이터 흐름이 함께 있습니다. + +- `seed.sql`: 구조 확인과 화면 테스트를 위한 샘플 데이터 +- [`PTC(2023-2026.02).xlsx`](/home/hyein/project/PTC(2023-2026.02).xlsx): 실제 적재 대상 원본 거래 데이터 + +즉, 지금 DB는 "데모용 샘플 데이터"로 바로 볼 수 있고, 실제 운영 데이터는 위 엑셀을 기준으로 다음 단계에서 적재해야 합니다. + +## 기준 DB + +- PostgreSQL 14 이상 +- 스키마명: `budget_app` +- 파일: [`schema.sql`](/home/hyein/project/db/schema.sql) +- 샘플 데이터: [`seed.sql`](/home/hyein/project/db/seed.sql) +- 조회 예제: [`sample_queries.sql`](/home/hyein/project/db/sample_queries.sql) +- 실제 엑셀 파싱: [`import_ptc_xlsx.py`](/home/hyein/project/db/import_ptc_xlsx.py) +- 실제 엑셀 검증 쿼리: [`staging_queries.sql`](/home/hyein/project/db/staging_queries.sql) +- 로컬 실행: [`docker-compose.yml`](/home/hyein/project/docker-compose.yml) + +## 현재 상태 + +- [`schema.sql`](/home/hyein/project/db/schema.sql): 실행예산 분석용 기본 스키마 +- [`seed.sql`](/home/hyein/project/db/seed.sql): 브라우저에서 구조를 바로 확인하기 위한 예시 데이터 +- [`PTC(2023-2026.02).xlsx`](/home/hyein/project/PTC(2023-2026.02).xlsx): 실제 회사 거래내역 원본 + +중요: + +- 현재 `docker compose up -d`로 보이는 데이터는 `seed.sql` 기준입니다 +- 아직 `PTC(2023-2026.02).xlsx` 내용이 자동으로 DB에 들어가도록 연결되지는 않았습니다 +- 이 엑셀은 성격상 `예산 파일`보다는 `실적/거래 원장`에 가깝습니다 +- 따라서 실제 적재 시에는 먼저 `staging` 테이블을 만든 뒤 정제해서 `actual_transactions`로 넣는 방식이 적합합니다 +- 현재는 `staging_ptc_transactions` 테이블과 CSV 생성 스크립트까지 준비된 상태입니다 + +## 바로 확인하는 방법 + +1. 터미널에서 `/home/hyein/project`로 이동 +2. `docker compose up -d` +3. 브라우저에서 `http://localhost:8080` 접속 +4. 아래 정보로 로그인 + +- System: `PostgreSQL` +- Server: `postgres` +- Username: `budget` +- Password: `budget123` +- Database: `budgetdb` + +접속 후 왼쪽에서 `budget_app` 스키마 안의 테이블과 뷰를 볼 수 있습니다. + +처음 데이터가 안 보이면 아래를 확인하세요. + +- `budget_app.companies` +- `budget_app.projects` +- `budget_app.vw_budget_vs_actual_monthly` +- `budget_app.vw_project_profit_summary` +- `budget_app.staging_ptc_transactions` + +기존에 같은 컨테이너를 띄운 적이 있으면 초기 SQL이 다시 실행되지 않을 수 있습니다. + +- 초기화가 필요하면 `docker compose down -v` +- 다시 실행은 `docker compose up -d` + +주의: + +- 여기서 보이는 값은 현재 샘플 데이터입니다 +- 실제 엑셀 원본과 동일한 숫자가 보이는 단계는 아직 아닙니다 + +## 핵심 테이블 + +- `companies`: 회사 기본 정보 +- `fiscal_years`: 회계연도 +- `departments`: 부서 +- `employees`: 사용자/담당자 +- `business_units`: 사업부 +- `clients`: 발주처 +- `vendors`: 거래처 +- `projects`: 프로젝트/현장/사업 +- `account_categories`, `accounts`: 예산/실적 계정 과목 +- `budget_versions`: 예산 버전 +- `budget_items`: 월별 예산 금액 +- `purchase_requests`, `purchase_orders`: 집행 요청과 발주 +- `invoices`: 매출/매입 세금계산서 +- `actual_transactions`: 실제 실적 데이터 +- `cashflow_transactions`: 현금 유입/유출 +- `file_import_logs`: 엑셀 업로드 이력 +- `staging_ptc_transactions`: `PTC(2023-2026.02).xlsx` 원본 적재용 임시 테이블 + +## 실제 원본 엑셀 헤더 + +[`PTC(2023-2026.02).xlsx`](/home/hyein/project/PTC(2023-2026.02).xlsx) 확인 결과 헤더는 아래 14개입니다. + +- `거래일` +- `입/출금` +- `계정코드` +- `구분` +- `부서` +- `거래처` +- `프로젝트코드` +- `프로젝트 구분(안)` +- `프로젝트명` +- `적요` +- `공급가액` +- `부가세` +- `합계금액` +- `비고` + +파일 특성 요약: + +- 데이터 건수: `6,678건` +- 기간: `2023-01-10 ~ 2026-02-28` +- `출금` 중심 거래 데이터이며 일부 `입금` 포함 +- 운영상 `actual_transactions` 원본으로 보는 것이 가장 적절 +- `departments`, `accounts`, `projects`, `vendors` 마스터 추출에도 활용 가능 + +주의할 점: + +- `프로젝트코드 -> 프로젝트명` 일부 불일치 존재 +- `프로젝트코드 -> 프로젝트 구분(안)` 일부 불일치 존재 +- 누락값 소수 존재 +- 음수 금액 존재 + +그래서 이 파일은 바로 본 테이블에 넣기보다 `staging -> 정제 -> 최종 적재` 흐름이 필요합니다. + +## 실제 엑셀 적재 방법 + +### 1. 엑셀을 CSV로 변환 + +프로젝트 루트에서: + +```bash +python3 db/import_ptc_xlsx.py \ + --input "PTC(2023-2026.02).xlsx" \ + --output "db/ptc_staging.csv" \ + --batch "ptc_20260323" +``` + +생성 결과: + +- `db/ptc_staging.csv` + +이 CSV는 `budget_app.staging_ptc_transactions`에 바로 넣을 수 있는 컬럼 구조입니다. + +### 2. CSV를 Postgres 컨테이너 안으로 복사 + +```bash +docker cp db/ptc_staging.csv budget-postgres:/tmp/ptc_staging.csv +``` + +### 3. staging 테이블로 적재 + +```bash +docker exec -i budget-postgres psql -U budget -d budgetdb -c " +set search_path = budget_app, public; +truncate table staging_ptc_transactions; +copy staging_ptc_transactions ( + import_batch, + source_file_name, + source_sheet_name, + source_row_no, + transaction_date_raw, + transaction_date, + in_out, + account_code_raw, + account_name_raw, + department_name_raw, + vendor_name_raw, + project_code_raw, + project_type_raw, + project_name_raw, + description_raw, + supply_amount_raw, + vat_amount_raw, + total_amount_raw, + remarks_raw, + supply_amount, + vat_amount, + total_amount, + normalized_transaction_type, + load_status, + load_error +) from '/tmp/ptc_staging.csv' with (format csv, header true, encoding 'UTF8'); +" +``` + +### 4. 적재 결과 확인 + +```bash +docker exec -it budget-postgres psql -U budget -d budgetdb +``` + +접속 후: + +```sql +set search_path = budget_app, public; +select import_batch, count(*) from staging_ptc_transactions group by import_batch; +select * from staging_ptc_transactions order by source_row_no limit 20; +``` + +또는 [`staging_queries.sql`](/home/hyein/project/db/staging_queries.sql)을 기준으로 검증하면 됩니다. + +## 먼저 넣어야 하는 데이터 순서 + +샘플 데이터 기준: + +1. `companies` +2. `fiscal_years` +3. `departments` +4. `employees` +5. `business_units` +6. `clients`, `vendors` +7. `account_categories` +8. `accounts` +9. `projects` +10. `budget_versions` +11. `budget_items` +12. `purchase_requests`, `purchase_orders`, `invoices` +13. `actual_transactions` + +실제 엑셀 적재 기준 권장 순서: + +1. 원본 엑셀을 `staging_ptc_transactions`에 그대로 적재 +2. `departments` 후보 추출 +3. `accounts` 후보 추출 +4. `projects` 후보 추출 +5. `vendors` 후보 추출 +6. 코드/명칭 불일치 정제 +7. `actual_transactions` 적재 + +즉, 실제 운영은 샘플 순서보다 `staging` 단계가 먼저입니다. + +## 가장 많이 쓰게 될 분석 + +- 프로젝트별 예산 대비 실적 +- 부서별 월 집행률 +- 계정과목별 초과 집행 +- 프로젝트 손익 +- 발주/세금계산서 기준 실적 반영 + +기본 뷰도 포함돼 있습니다. + +- `vw_budget_vs_actual_monthly` +- `vw_project_profit_summary` + +## 샘플 데이터 내용 + +- 회사 1개: `장헌건설` +- 회계연도 1개: `2026` +- 부서 4개 +- 직원 5명 +- 사업부 2개 +- 발주처 2개 +- 거래처 3개 +- 프로젝트 2개 +- 예산 버전 1개 +- 월별 예산 데이터 다수 +- 발주 요청/발주/세금계산서/실적 데이터 포함 + +즉, DB를 올리면 바로 "예산 대비 실적"과 "프로젝트 손익" 화면 구조를 테스트할 수 있습니다. + +다만 이 샘플 데이터는 실제 엑셀 원본을 반영한 것이 아니라 데모용 예시입니다. + +## 권장 운영 방식 + +- 예산은 `budget_versions` + `budget_items`로 버전 관리 +- 실제 집행은 최종적으로 `actual_transactions`에 적재 +- ERP, 엑셀, 수기 입력이 섞여도 `source_type`으로 출처 추적 +- 프로젝트 단위와 부서 단위를 모두 지원 +- 실제 엑셀 원본은 우선 `staging` 테이블에 저장 후 정제 +- `공급가액`, `부가세`, `합계금액` 중 어떤 금액을 실적으로 사용할지 회사 기준 확정 필요 +- `입/출금`과 `계정코드`를 함께 써서 `revenue/cost/expense` 분류 규칙 확정 필요 +- `staging_ptc_transactions.normalized_transaction_type`은 현재 1차 추정값입니다 +- 최종 적재 전 계정코드 기준 매핑표를 별도로 확정하는 것이 안전합니다 + +## 다음 추천 작업 + +1. `staging_ptc_transactions`로 실제 엑셀 적재 실행 +2. 계정코드별 `revenue/cost/expense` 분류표 확정 +3. 프로젝트코드/프로젝트명 불일치 정제 규칙 확정 +4. `staging -> actual_transactions` 변환 SQL 작성 +5. 필요하면 예산용 별도 엑셀 구조 추가 +6. 화면에 필요한 조회 API 설계 + +## 쿼리로 확인하는 방법 + +웹 화면 대신 SQL로 먼저 보고 싶으면 아래처럼 접속해서 확인할 수 있습니다. + +```bash +docker exec -it budget-postgres psql -U budget -d budgetdb +``` + +접속 후: + +```sql +set search_path = budget_app, public; +select * from companies; +select * from projects; +select * from vw_project_profit_summary; +``` + +또는 [`sample_queries.sql`](/home/hyein/project/db/sample_queries.sql)에 준비된 조회문을 사용하면 됩니다. + +## 예산 입력 예시 + +예를 들어 2026년 3월, 특정 프로젝트의 외주비 예산 5,000,000원을 넣으려면: + +- `budget_versions`에 2026년 예산 버전 생성 +- `accounts`에 외주비 계정 등록 +- `budget_items`에 `month_no = 3`, `planned_amount = 5000000` 입력 + +## 실적 입력 예시 + +실제 외주비가 4,300,000원 집행되면: + +- `actual_transactions`에 같은 프로젝트, 같은 계정, 같은 월로 입력 +- 이후 `vw_budget_vs_actual_monthly`에서 차이와 집행률 조회 가능 + +## 현재 문서에서 꼭 구분할 점 + +- `seed.sql`은 데모 확인용 +- 실제 운영 데이터는 [`PTC(2023-2026.02).xlsx`](/home/hyein/project/PTC(2023-2026.02).xlsx) +- 현재 README의 실행 방법은 "데모 DB 확인 방법"으로 이해하면 맞습니다 +- 실제 엑셀 적재 절차는 다음 단계에서 추가 구현이 필요합니다 diff --git a/db/import_ptc_xlsx.py b/db/import_ptc_xlsx.py new file mode 100644 index 0000000..82872fa --- /dev/null +++ b/db/import_ptc_xlsx.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Parse PTC(2023-2026.02).xlsx without external dependencies and export a CSV +that can be loaded into budget_app.staging_ptc_transactions. + +Usage: + python3 db/import_ptc_xlsx.py \ + --input "PTC(2023-2026.02).xlsx" \ + --output db/ptc_staging.csv \ + --batch ptc_20260323 +""" + +from __future__ import annotations + +import argparse +import csv +import re +from collections import defaultdict +from datetime import datetime, timedelta +from pathlib import Path +from xml.etree import ElementTree as ET +from zipfile import ZipFile + + +NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} +EXPECTED_HEADERS = [ + "거래일", + "입/출금", + "계정코드", + "구분", + "부서", + "거래처", + "프로젝트코드", + "프로젝트 구분(안)", + "프로젝트명", + "적요", + "공급가액", + "부가세", + "합계금액", + "비고", +] + + +def col_to_num(col: str) -> int: + value = 0 + for ch in col: + if ch.isalpha(): + value = value * 26 + ord(ch.upper()) - 64 + return value + + +def read_shared_strings(book: ZipFile) -> list[str]: + strings = [] + root = ET.fromstring(book.read("xl/sharedStrings.xml")) + for si in root.findall("a:si", NS): + text = "".join(node.text or "" for node in si.iterfind(".//a:t", NS)) + strings.append(text) + return strings + + +def read_sheet_rows(book: ZipFile, shared_strings: list[str], sheet_path: str) -> list[list[str]]: + root = ET.fromstring(book.read(sheet_path)) + rows = [] + for row in root.find("a:sheetData", NS).findall("a:row", NS): + values = defaultdict(str) + for cell in row.findall("a:c", NS): + ref = cell.attrib.get("r", "") + match = re.match(r"([A-Z]+)(\d+)", ref) + col = col_to_num(match.group(1)) if match else None + value_node = cell.find("a:v", NS) + if value_node is None: + value = "" + else: + value = value_node.text or "" + if cell.attrib.get("t") == "s": + value = shared_strings[int(value)] + values[col] = value + width = max(values) if values else 0 + rows.append([values[i] for i in range(1, width + 1)]) + return rows + + +def excel_serial_to_date(value: str) -> str: + if not value: + return "" + try: + serial = float(value) + except ValueError: + return value + base = datetime(1899, 12, 30) + return (base + timedelta(days=serial)).strftime("%Y-%m-%d") + + +def parse_amount(value: str) -> str: + value = (value or "").strip() + if not value or value == "-": + return "" + normalized = value.replace(",", "") + return normalized + + +def normalize_transaction_type(in_out: str, account_name: str) -> str: + in_out = (in_out or "").strip() + account_name = (account_name or "").strip() + if in_out == "입금": + return "revenue" + if in_out == "출금": + if "수입" in account_name or "매출" in account_name: + return "revenue" + return "cost_expense" + return "" + + +def export_csv(input_path: Path, output_path: Path, batch_name: str) -> None: + with ZipFile(input_path) as book: + shared_strings = read_shared_strings(book) + rows = read_sheet_rows(book, shared_strings, "xl/worksheets/sheet1.xml") + + if not rows: + raise ValueError("No rows found in workbook") + + headers = rows[0] + if headers != EXPECTED_HEADERS: + raise ValueError(f"Unexpected headers: {headers}") + + data_rows = rows[1:] + width = len(EXPECTED_HEADERS) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8-sig") as fp: + writer = csv.DictWriter( + fp, + fieldnames=[ + "import_batch", + "source_file_name", + "source_sheet_name", + "source_row_no", + "transaction_date_raw", + "transaction_date", + "in_out", + "account_code_raw", + "account_name_raw", + "department_name_raw", + "vendor_name_raw", + "project_code_raw", + "project_type_raw", + "project_name_raw", + "description_raw", + "supply_amount_raw", + "vat_amount_raw", + "total_amount_raw", + "remarks_raw", + "supply_amount", + "vat_amount", + "total_amount", + "normalized_transaction_type", + "load_status", + "load_error", + ], + ) + writer.writeheader() + + for index, row in enumerate(data_rows, start=2): + current = row + [""] * (width - len(row)) if len(row) < width else row[:width] + writer.writerow( + { + "import_batch": batch_name, + "source_file_name": input_path.name, + "source_sheet_name": "Sheet1", + "source_row_no": index, + "transaction_date_raw": current[0], + "transaction_date": excel_serial_to_date(current[0]), + "in_out": current[1], + "account_code_raw": current[2], + "account_name_raw": current[3], + "department_name_raw": current[4], + "vendor_name_raw": current[5], + "project_code_raw": current[6], + "project_type_raw": current[7], + "project_name_raw": current[8], + "description_raw": current[9], + "supply_amount_raw": current[10], + "vat_amount_raw": current[11], + "total_amount_raw": current[12], + "remarks_raw": current[13], + "supply_amount": parse_amount(current[10]), + "vat_amount": parse_amount(current[11]), + "total_amount": parse_amount(current[12]), + "normalized_transaction_type": normalize_transaction_type(current[1], current[3]), + "load_status": "loaded", + "load_error": "", + } + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, help="Path to the xlsx file") + parser.add_argument("--output", required=True, help="Path to the output CSV file") + parser.add_argument("--batch", required=True, help="Import batch name") + args = parser.parse_args() + + export_csv(Path(args.input), Path(args.output), args.batch) + print(f"CSV exported to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/db/migrations/20260323_add_staging_ptc.sql b/db/migrations/20260323_add_staging_ptc.sql new file mode 100644 index 0000000..2b962d6 --- /dev/null +++ b/db/migrations/20260323_add_staging_ptc.sql @@ -0,0 +1,39 @@ +set search_path = budget_app, public; + +create table if not exists staging_ptc_transactions ( + id uuid primary key default gen_random_uuid(), + import_batch varchar(50) not null, + source_file_name varchar(255) not null, + source_sheet_name varchar(100) not null default 'Sheet1', + source_row_no integer not null, + transaction_date_raw varchar(50), + transaction_date date, + in_out varchar(20), + account_code_raw varchar(30), + account_name_raw varchar(100), + department_name_raw varchar(100), + vendor_name_raw varchar(200), + project_code_raw varchar(50), + project_type_raw varchar(50), + project_name_raw varchar(200), + description_raw text, + supply_amount_raw varchar(50), + vat_amount_raw varchar(50), + total_amount_raw varchar(50), + remarks_raw text, + supply_amount numeric(18, 2), + vat_amount numeric(18, 2), + total_amount numeric(18, 2), + normalized_transaction_type varchar(20), + load_status varchar(20) not null default 'loaded', + load_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_staging_ptc_row unique (import_batch, source_row_no), + constraint chk_staging_load_status check (load_status in ('loaded', 'mapped', 'error')) +); + +create index if not exists idx_staging_ptc_batch on staging_ptc_transactions(import_batch, source_row_no); +create index if not exists idx_staging_ptc_project on staging_ptc_transactions(project_code_raw); +create index if not exists idx_staging_ptc_account on staging_ptc_transactions(account_code_raw); +create index if not exists idx_staging_ptc_department on staging_ptc_transactions(department_name_raw); diff --git a/db/sample_queries.sql b/db/sample_queries.sql new file mode 100644 index 0000000..7528dc3 --- /dev/null +++ b/db/sample_queries.sql @@ -0,0 +1,50 @@ +set search_path = budget_app, public; + +-- 1. 회사 목록 +select id, code, name from companies; + +-- 2. 프로젝트 목록 +select project_code, project_name, status, contract_amount +from projects +order by project_code; + +-- 3. 프로젝트별 월 예산 대비 실적 +select + p.project_code, + p.project_name, + v.month_no, + a.code as account_code, + a.name as account_name, + v.budget_amount, + v.actual_amount, + v.variance_amount, + v.execution_rate +from vw_budget_vs_actual_monthly v +join projects p on p.id = v.project_id +join accounts a on a.id = v.account_id +order by p.project_code, v.month_no, a.code; + +-- 4. 프로젝트 손익 요약 +select + project_code, + project_name, + revenue_amount, + cost_amount, + profit_amount, + profit_rate +from vw_project_profit_summary +order by project_code; + +-- 5. 발주 요청과 발주 현황 +select + pr.request_no, + po.order_no, + p.project_code, + v.name as vendor_name, + pr.total_amount as request_amount, + po.total_amount as order_amount +from purchase_requests pr +left join purchase_orders po on po.purchase_request_id = pr.id +left join projects p on p.id = pr.project_id +left join vendors v on v.id = pr.vendor_id +order by pr.request_no; diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..0bbcd93 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,421 @@ +-- Company execution budget analysis schema +-- Target database: PostgreSQL 14+ + +create extension if not exists pgcrypto; + +create schema if not exists budget_app; + +set search_path = budget_app, public; + +create table companies ( + id uuid primary key default gen_random_uuid(), + code varchar(30) not null unique, + name varchar(200) not null, + business_number varchar(30), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table fiscal_years ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + fiscal_year integer not null, + start_date date not null, + end_date date not null, + is_closed boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_fiscal_year unique (company_id, fiscal_year), + constraint chk_fiscal_year_dates check (start_date <= end_date) +); + +create table departments ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + parent_department_id uuid references departments(id), + code varchar(30) not null, + name varchar(100) not null, + manager_name varchar(100), + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_department_code unique (company_id, code) +); + +create table employees ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + department_id uuid references departments(id), + employee_no varchar(30) not null, + name varchar(100) not null, + title varchar(100), + email varchar(200), + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_employee_no unique (company_id, employee_no) +); + +create table business_units ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + code varchar(30) not null, + name varchar(100) not null, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_business_unit_code unique (company_id, code) +); + +create table vendors ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + code varchar(30) not null, + name varchar(200) not null, + business_number varchar(30), + contact_name varchar(100), + phone varchar(50), + email varchar(200), + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_vendor_code unique (company_id, code) +); + +create table clients ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + code varchar(30) not null, + name varchar(200) not null, + business_number varchar(30), + contact_name varchar(100), + phone varchar(50), + email varchar(200), + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_client_code unique (company_id, code) +); + +create table projects ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + business_unit_id uuid references business_units(id), + department_id uuid references departments(id), + client_id uuid references clients(id), + project_code varchar(40) not null, + project_name varchar(200) not null, + project_type varchar(50), + status varchar(30) not null default 'planning', + contract_amount numeric(18, 2) not null default 0, + start_date date, + end_date date, + manager_employee_id uuid references employees(id), + description text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_project_code unique (company_id, project_code), + constraint chk_project_status check (status in ('planning', 'active', 'on_hold', 'closed', 'cancelled')) +); + +create table account_categories ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + code varchar(30) not null, + name varchar(100) not null, + category_type varchar(20) not null, + sort_order integer not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_account_category_code unique (company_id, code), + constraint chk_account_category_type check (category_type in ('revenue', 'cost', 'expense')) +); + +create table accounts ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + category_id uuid not null references account_categories(id), + code varchar(30) not null, + name varchar(100) not null, + account_type varchar(20) not null, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_account_code unique (company_id, code), + constraint chk_account_type check (account_type in ('revenue', 'cost', 'expense')) +); + +create table budget_versions ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + fiscal_year_id uuid not null references fiscal_years(id), + version_name varchar(100) not null, + version_no integer not null, + status varchar(20) not null default 'draft', + created_by uuid references employees(id), + approved_by uuid references employees(id), + approved_at timestamptz, + notes text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_budget_version unique (company_id, fiscal_year_id, version_no), + constraint chk_budget_version_status check (status in ('draft', 'submitted', 'approved', 'archived')) +); + +create table budget_items ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + budget_version_id uuid not null references budget_versions(id), + project_id uuid references projects(id), + department_id uuid references departments(id), + account_id uuid not null references accounts(id), + month_no integer not null, + planned_amount numeric(18, 2) not null default 0, + memo text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_budget_item unique (budget_version_id, project_id, department_id, account_id, month_no), + constraint chk_budget_month check (month_no between 1 and 12) +); + +create table purchase_requests ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + project_id uuid references projects(id), + department_id uuid references departments(id), + vendor_id uuid references vendors(id), + requester_employee_id uuid references employees(id), + request_no varchar(40) not null, + request_date date not null, + status varchar(20) not null default 'requested', + description text, + total_amount numeric(18, 2) not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_purchase_request_no unique (company_id, request_no), + constraint chk_purchase_request_status check (status in ('requested', 'approved', 'rejected', 'ordered', 'cancelled')) +); + +create table purchase_request_items ( + id uuid primary key default gen_random_uuid(), + purchase_request_id uuid not null references purchase_requests(id) on delete cascade, + account_id uuid not null references accounts(id), + item_name varchar(200) not null, + quantity numeric(18, 3) not null default 1, + unit_price numeric(18, 2) not null default 0, + amount numeric(18, 2) not null default 0, + needed_date date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table purchase_orders ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + purchase_request_id uuid references purchase_requests(id), + project_id uuid references projects(id), + department_id uuid references departments(id), + vendor_id uuid references vendors(id), + order_no varchar(40) not null, + order_date date not null, + status varchar(20) not null default 'issued', + total_amount numeric(18, 2) not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_purchase_order_no unique (company_id, order_no), + constraint chk_purchase_order_status check (status in ('issued', 'partial_received', 'received', 'cancelled')) +); + +create table purchase_order_items ( + id uuid primary key default gen_random_uuid(), + purchase_order_id uuid not null references purchase_orders(id) on delete cascade, + account_id uuid not null references accounts(id), + item_name varchar(200) not null, + quantity numeric(18, 3) not null default 1, + unit_price numeric(18, 2) not null default 0, + amount numeric(18, 2) not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table invoices ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + project_id uuid references projects(id), + department_id uuid references departments(id), + vendor_id uuid references vendors(id), + client_id uuid references clients(id), + invoice_no varchar(40) not null, + invoice_type varchar(20) not null, + issue_date date not null, + due_date date, + supply_amount numeric(18, 2) not null default 0, + tax_amount numeric(18, 2) not null default 0, + total_amount numeric(18, 2) not null default 0, + status varchar(20) not null default 'issued', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_invoice_no unique (company_id, invoice_no), + constraint chk_invoice_type check (invoice_type in ('sales', 'purchase')), + constraint chk_invoice_status check (status in ('issued', 'paid', 'cancelled')) +); + +create table actual_transactions ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + fiscal_year_id uuid references fiscal_years(id), + project_id uuid references projects(id), + department_id uuid references departments(id), + account_id uuid not null references accounts(id), + vendor_id uuid references vendors(id), + client_id uuid references clients(id), + employee_id uuid references employees(id), + source_type varchar(30) not null, + source_id uuid, + transaction_date date not null, + month_no integer not null, + transaction_type varchar(20) not null, + amount numeric(18, 2) not null, + description text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint chk_actual_month check (month_no between 1 and 12), + constraint chk_transaction_type check (transaction_type in ('revenue', 'cost', 'expense')), + constraint chk_source_type check (source_type in ('manual', 'invoice', 'purchase_order', 'erp', 'excel_upload')) +); + +create table cashflow_transactions ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + project_id uuid references projects(id), + department_id uuid references departments(id), + transaction_date date not null, + cashflow_type varchar(20) not null, + amount numeric(18, 2) not null, + description text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint chk_cashflow_type check (cashflow_type in ('inflow', 'outflow')) +); + +create table file_import_logs ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references companies(id), + import_type varchar(30) not null, + file_name varchar(255) not null, + row_count integer not null default 0, + success_count integer not null default 0, + failure_count integer not null default 0, + imported_by uuid references employees(id), + imported_at timestamptz not null default now(), + notes text, + constraint chk_import_type check (import_type in ('budget', 'actual', 'project', 'vendor', 'client', 'account')) +); + +create table staging_ptc_transactions ( + id uuid primary key default gen_random_uuid(), + import_batch varchar(50) not null, + source_file_name varchar(255) not null, + source_sheet_name varchar(100) not null default 'Sheet1', + source_row_no integer not null, + transaction_date_raw varchar(50), + transaction_date date, + in_out varchar(20), + account_code_raw varchar(30), + account_name_raw varchar(100), + department_name_raw varchar(100), + vendor_name_raw varchar(200), + project_code_raw varchar(50), + project_type_raw varchar(50), + project_name_raw varchar(200), + description_raw text, + supply_amount_raw varchar(50), + vat_amount_raw varchar(50), + total_amount_raw varchar(50), + remarks_raw text, + supply_amount numeric(18, 2), + vat_amount numeric(18, 2), + total_amount numeric(18, 2), + normalized_transaction_type varchar(20), + load_status varchar(20) not null default 'loaded', + load_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_staging_ptc_row unique (import_batch, source_row_no), + constraint chk_staging_load_status check (load_status in ('loaded', 'mapped', 'error')) +); + +create index idx_projects_company_status on projects(company_id, status); +create index idx_projects_department on projects(department_id); +create index idx_budget_items_project on budget_items(project_id, month_no); +create index idx_budget_items_department on budget_items(department_id, month_no); +create index idx_actual_transactions_project on actual_transactions(project_id, transaction_date); +create index idx_actual_transactions_department on actual_transactions(department_id, transaction_date); +create index idx_actual_transactions_account on actual_transactions(account_id, transaction_date); +create index idx_purchase_orders_project on purchase_orders(project_id, order_date); +create index idx_invoices_project on invoices(project_id, issue_date); +create index idx_staging_ptc_batch on staging_ptc_transactions(import_batch, source_row_no); +create index idx_staging_ptc_project on staging_ptc_transactions(project_code_raw); +create index idx_staging_ptc_account on staging_ptc_transactions(account_code_raw); +create index idx_staging_ptc_department on staging_ptc_transactions(department_name_raw); + +create or replace view vw_budget_vs_actual_monthly as +select + bv.company_id, + fy.fiscal_year, + bi.project_id, + bi.department_id, + bi.account_id, + bi.month_no, + sum(bi.planned_amount) as budget_amount, + coalesce(sum(at.amount), 0) as actual_amount, + sum(bi.planned_amount) - coalesce(sum(at.amount), 0) as variance_amount, + case + when sum(bi.planned_amount) = 0 then 0 + else round((coalesce(sum(at.amount), 0) / sum(bi.planned_amount)) * 100, 2) + end as execution_rate +from budget_items bi +join budget_versions bv on bv.id = bi.budget_version_id +join fiscal_years fy on fy.id = bv.fiscal_year_id +left join actual_transactions at + on at.company_id = bv.company_id + and coalesce(at.project_id, '00000000-0000-0000-0000-000000000000'::uuid) = coalesce(bi.project_id, '00000000-0000-0000-0000-000000000000'::uuid) + and coalesce(at.department_id, '00000000-0000-0000-0000-000000000000'::uuid) = coalesce(bi.department_id, '00000000-0000-0000-0000-000000000000'::uuid) + and at.account_id = bi.account_id + and at.month_no = bi.month_no +group by + bv.company_id, + fy.fiscal_year, + bi.project_id, + bi.department_id, + bi.account_id, + bi.month_no; + +create or replace view vw_project_profit_summary as +select + p.id as project_id, + p.project_code, + p.project_name, + p.status, + p.contract_amount, + coalesce(sum(case when a.account_type = 'revenue' then at.amount else 0 end), 0) as revenue_amount, + coalesce(sum(case when a.account_type in ('cost', 'expense') then at.amount else 0 end), 0) as cost_amount, + coalesce(sum(case when a.account_type = 'revenue' then at.amount else 0 end), 0) + - coalesce(sum(case when a.account_type in ('cost', 'expense') then at.amount else 0 end), 0) as profit_amount, + case + when coalesce(sum(case when a.account_type = 'revenue' then at.amount else 0 end), 0) = 0 then 0 + else round( + ( + ( + coalesce(sum(case when a.account_type = 'revenue' then at.amount else 0 end), 0) + - coalesce(sum(case when a.account_type in ('cost', 'expense') then at.amount else 0 end), 0) + ) + / coalesce(sum(case when a.account_type = 'revenue' then at.amount else 0 end), 0) + ) * 100, + 2 + ) + end as profit_rate +from projects p +left join actual_transactions at on at.project_id = p.id +left join accounts a on a.id = at.account_id +group by p.id, p.project_code, p.project_name, p.status, p.contract_amount; diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..42d7c4e --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,584 @@ +set search_path = budget_app, public; + +insert into companies (code, name, business_number) +values ('JH001', '장헌건설', '123-45-67890') +on conflict (code) do nothing; + +insert into fiscal_years (company_id, fiscal_year, start_date, end_date, is_closed) +select c.id, 2026, date '2026-01-01', date '2026-12-31', false +from companies c +where c.code = 'JH001' +on conflict (company_id, fiscal_year) do nothing; + +insert into departments (company_id, parent_department_id, code, name, manager_name) +select c.id, null, x.code, x.name, x.manager_name +from companies c +cross join ( + values + ('HQ', '본사', '김대표'), + ('SALES', '영업본부', '박영업'), + ('EXEC', '실행본부', '이실행'), + ('FIN', '재무팀', '최재무') +) as x(code, name, manager_name) +where c.code = 'JH001' +on conflict (company_id, code) do nothing; + +insert into employees (company_id, department_id, employee_no, name, title, email) +select + c.id, + d.id, + x.employee_no, + x.name, + x.title, + x.email +from companies c +join departments d on d.company_id = c.id +join ( + values + ('E001', '김대표', '대표', 'ceo@jh.local', 'HQ'), + ('E002', '박영업', '영업이사', 'sales@jh.local', 'SALES'), + ('E003', '이실행', '실행이사', 'exec@jh.local', 'EXEC'), + ('E004', '최재무', '재무팀장', 'finance@jh.local', 'FIN'), + ('E005', '정현장', '현장소장', 'site1@jh.local', 'EXEC') +) as x(employee_no, name, title, email, dept_code) + on d.code = x.dept_code +where c.code = 'JH001' +on conflict (company_id, employee_no) do nothing; + +insert into business_units (company_id, code, name) +select c.id, x.code, x.name +from companies c +cross join ( + values + ('BU001', '건축사업부'), + ('BU002', '토목사업부') +) as x(code, name) +where c.code = 'JH001' +on conflict (company_id, code) do nothing; + +insert into clients (company_id, code, name, business_number, contact_name, phone, email) +select c.id, x.code, x.name, x.business_number, x.contact_name, x.phone, x.email +from companies c +cross join ( + values + ('CL001', '한맥개발', '210-81-11111', '김발주', '02-1111-1111', 'client1@hmac.local'), + ('CL002', '동해산업', '210-81-22222', '이발주', '02-2222-2222', 'client2@donghae.local') +) as x(code, name, business_number, contact_name, phone, email) +where c.code = 'JH001' +on conflict (company_id, code) do nothing; + +insert into vendors (company_id, code, name, business_number, contact_name, phone, email) +select c.id, x.code, x.name, x.business_number, x.contact_name, x.phone, x.email +from companies c +cross join ( + values + ('VD001', '성우외주', '301-86-11111', '오외주', '031-111-1111', 'vendor1@sw.local'), + ('VD002', '대한자재', '301-86-22222', '문자재', '031-222-2222', 'vendor2@dh.local'), + ('VD003', '정우장비', '301-86-33333', '최장비', '031-333-3333', 'vendor3@jw.local') +) as x(code, name, business_number, contact_name, phone, email) +where c.code = 'JH001' +on conflict (company_id, code) do nothing; + +insert into account_categories (company_id, code, name, category_type, sort_order) +select c.id, x.code, x.name, x.category_type, x.sort_order +from companies c +cross join ( + values + ('REV', '매출', 'revenue', 1), + ('COST', '공사원가', 'cost', 2), + ('EXP', '판관비', 'expense', 3) +) as x(code, name, category_type, sort_order) +where c.code = 'JH001' +on conflict (company_id, code) do nothing; + +insert into accounts (company_id, category_id, code, name, account_type) +select + c.id, + ac.id, + x.code, + x.name, + x.account_type +from companies c +join account_categories ac + on ac.company_id = c.id +join ( + values + ('REV001', '기성매출', 'revenue', 'REV'), + ('REV002', '추가공사매출', 'revenue', 'REV'), + ('COST001', '외주비', 'cost', 'COST'), + ('COST002', '자재비', 'cost', 'COST'), + ('COST003', '장비비', 'cost', 'COST'), + ('EXP001', '현장관리비', 'expense', 'EXP'), + ('EXP002', '본사관리비', 'expense', 'EXP') +) as x(code, name, account_type, category_code) + on ac.code = x.category_code +where c.code = 'JH001' +on conflict (company_id, code) do nothing; + +insert into projects ( + company_id, + business_unit_id, + department_id, + client_id, + project_code, + project_name, + project_type, + status, + contract_amount, + start_date, + end_date, + manager_employee_id, + description +) +select + c.id, + bu.id, + d.id, + cl.id, + x.project_code, + x.project_name, + x.project_type, + x.status, + x.contract_amount, + x.start_date, + x.end_date, + e.id, + x.description +from companies c +join business_units bu on bu.company_id = c.id +join departments d on d.company_id = c.id +join clients cl on cl.company_id = c.id +join employees e on e.company_id = c.id +join ( + values + ( + 'PJT-2026-001', + '장헌 오피스 신축공사', + '건축', + 'active', + 1500000000.00, + date '2026-01-10', + date '2026-12-20', + 'BU001', + 'EXEC', + 'CL001', + 'E005', + '오피스 신축 메인 프로젝트' + ), + ( + 'PJT-2026-002', + '동해 물류창고 증축공사', + '건축', + 'active', + 950000000.00, + date '2026-02-01', + date '2026-10-30', + 'BU001', + 'EXEC', + 'CL002', + 'E003', + '물류창고 증축 프로젝트' + ) +) as x( + project_code, + project_name, + project_type, + status, + contract_amount, + start_date, + end_date, + bu_code, + dept_code, + client_code, + employee_no, + description +) + on bu.code = x.bu_code + and d.code = x.dept_code + and cl.code = x.client_code + and e.employee_no = x.employee_no +where c.code = 'JH001' +on conflict (company_id, project_code) do nothing; + +insert into budget_versions ( + company_id, + fiscal_year_id, + version_name, + version_no, + status, + created_by, + approved_by, + approved_at, + notes +) +select + c.id, + fy.id, + '2026 본예산', + 1, + 'approved', + e1.id, + e2.id, + now(), + '초기 승인 예산' +from companies c +join fiscal_years fy on fy.company_id = c.id and fy.fiscal_year = 2026 +join employees e1 on e1.company_id = c.id and e1.employee_no = 'E004' +join employees e2 on e2.company_id = c.id and e2.employee_no = 'E001' +where c.code = 'JH001' +on conflict (company_id, fiscal_year_id, version_no) do nothing; + +insert into budget_items ( + company_id, + budget_version_id, + project_id, + department_id, + account_id, + month_no, + planned_amount, + memo +) +select + c.id, + bv.id, + p.id, + d.id, + a.id, + x.month_no, + x.planned_amount, + x.memo +from companies c +join budget_versions bv on bv.company_id = c.id and bv.version_no = 1 +join projects p on p.company_id = c.id +join departments d on d.id = p.department_id +join accounts a on a.company_id = c.id +join ( + values + ('PJT-2026-001', 'REV001', 1, 120000000.00, '1월 기성매출'), + ('PJT-2026-001', 'REV001', 2, 130000000.00, '2월 기성매출'), + ('PJT-2026-001', 'REV001', 3, 150000000.00, '3월 기성매출'), + ('PJT-2026-001', 'COST001', 1, 40000000.00, '1월 외주비'), + ('PJT-2026-001', 'COST001', 2, 45000000.00, '2월 외주비'), + ('PJT-2026-001', 'COST001', 3, 50000000.00, '3월 외주비'), + ('PJT-2026-001', 'COST002', 1, 25000000.00, '1월 자재비'), + ('PJT-2026-001', 'COST002', 2, 28000000.00, '2월 자재비'), + ('PJT-2026-001', 'EXP001', 1, 8000000.00, '1월 현장관리비'), + ('PJT-2026-001', 'EXP001', 2, 8000000.00, '2월 현장관리비'), + ('PJT-2026-002', 'REV001', 2, 90000000.00, '2월 기성매출'), + ('PJT-2026-002', 'REV001', 3, 110000000.00, '3월 기성매출'), + ('PJT-2026-002', 'COST001', 2, 30000000.00, '2월 외주비'), + ('PJT-2026-002', 'COST002', 2, 22000000.00, '2월 자재비'), + ('PJT-2026-002', 'EXP001', 2, 6000000.00, '2월 현장관리비') +) as x(project_code, account_code, month_no, planned_amount, memo) + on p.project_code = x.project_code + and a.code = x.account_code +where c.code = 'JH001' +on conflict (budget_version_id, project_id, department_id, account_id, month_no) do nothing; + +insert into purchase_requests ( + company_id, + project_id, + department_id, + vendor_id, + requester_employee_id, + request_no, + request_date, + status, + description, + total_amount +) +select + c.id, + p.id, + p.department_id, + v.id, + e.id, + x.request_no, + x.request_date, + x.status, + x.description, + x.total_amount +from companies c +join projects p on p.company_id = c.id +join vendors v on v.company_id = c.id +join employees e on e.company_id = c.id +join ( + values + ('PR-2026-0001', date '2026-01-12', 'approved', '1차 외주 발주 요청', 38000000.00, 'PJT-2026-001', 'VD001', 'E005'), + ('PR-2026-0002', date '2026-02-08', 'ordered', '자재 구매 요청', 21000000.00, 'PJT-2026-002', 'VD002', 'E003') +) as x(request_no, request_date, status, description, total_amount, project_code, vendor_code, employee_no) + on p.project_code = x.project_code + and v.code = x.vendor_code + and e.employee_no = x.employee_no +where c.code = 'JH001' +on conflict (company_id, request_no) do nothing; + +insert into purchase_request_items ( + purchase_request_id, + account_id, + item_name, + quantity, + unit_price, + amount, + needed_date +) +select + pr.id, + a.id, + x.item_name, + x.quantity, + x.unit_price, + x.amount, + x.needed_date +from purchase_requests pr +join companies c on c.id = pr.company_id +join accounts a on a.company_id = c.id +join ( + values + ('PR-2026-0001', 'COST001', '철근 가공 외주', 1.0, 38000000.00, 38000000.00, date '2026-01-20'), + ('PR-2026-0002', 'COST002', '철골 자재 구매', 1.0, 21000000.00, 21000000.00, date '2026-02-15') +) as x(request_no, account_code, item_name, quantity, unit_price, amount, needed_date) + on pr.request_no = x.request_no + and a.code = x.account_code +where c.code = 'JH001'; + +insert into purchase_orders ( + company_id, + purchase_request_id, + project_id, + department_id, + vendor_id, + order_no, + order_date, + status, + total_amount +) +select + c.id, + pr.id, + p.id, + p.department_id, + v.id, + x.order_no, + x.order_date, + x.status, + x.total_amount +from companies c +join purchase_requests pr on pr.company_id = c.id +join projects p on p.id = pr.project_id +join vendors v on v.id = pr.vendor_id +join ( + values + ('PO-2026-0001', date '2026-01-15', 'issued', 38000000.00, 'PR-2026-0001'), + ('PO-2026-0002', date '2026-02-10', 'received', 21000000.00, 'PR-2026-0002') +) as x(order_no, order_date, status, total_amount, request_no) + on pr.request_no = x.request_no +where c.code = 'JH001' +on conflict (company_id, order_no) do nothing; + +insert into purchase_order_items ( + purchase_order_id, + account_id, + item_name, + quantity, + unit_price, + amount +) +select + po.id, + a.id, + x.item_name, + x.quantity, + x.unit_price, + x.amount +from purchase_orders po +join companies c on c.id = po.company_id +join accounts a on a.company_id = c.id +join ( + values + ('PO-2026-0001', 'COST001', '철근 가공 외주', 1.0, 38000000.00, 38000000.00), + ('PO-2026-0002', 'COST002', '철골 자재 구매', 1.0, 21000000.00, 21000000.00) +) as x(order_no, account_code, item_name, quantity, unit_price, amount) + on po.order_no = x.order_no + and a.code = x.account_code +where c.code = 'JH001'; + +insert into invoices ( + company_id, + project_id, + department_id, + vendor_id, + client_id, + invoice_no, + invoice_type, + issue_date, + due_date, + supply_amount, + tax_amount, + total_amount, + status +) +select + c.id, + p.id, + p.department_id, + v.id, + cl.id, + x.invoice_no, + x.invoice_type, + x.issue_date, + x.due_date, + x.supply_amount, + x.tax_amount, + x.total_amount, + x.status +from companies c +join projects p on p.company_id = c.id +left join vendors v on v.company_id = c.id +left join clients cl on cl.company_id = c.id +join ( + values + ('INV-S-2026-0001', 'sales', date '2026-01-31', date '2026-02-28', 120000000.00, 12000000.00, 132000000.00, 'issued', 'PJT-2026-001', null, 'CL001'), + ('INV-P-2026-0001', 'purchase', date '2026-01-25', date '2026-02-15', 38000000.00, 3800000.00, 41800000.00, 'paid', 'PJT-2026-001', 'VD001', null), + ('INV-P-2026-0002', 'purchase', date '2026-02-18', date '2026-03-10', 21000000.00, 2100000.00, 23100000.00, 'paid', 'PJT-2026-002', 'VD002', null) +) as x( + invoice_no, + invoice_type, + issue_date, + due_date, + supply_amount, + tax_amount, + total_amount, + status, + project_code, + vendor_code, + client_code +) + on p.project_code = x.project_code + and (v.code = x.vendor_code or x.vendor_code is null) + and (cl.code = x.client_code or x.client_code is null) +where c.code = 'JH001' +on conflict (company_id, invoice_no) do nothing; + +insert into actual_transactions ( + company_id, + fiscal_year_id, + project_id, + department_id, + account_id, + vendor_id, + client_id, + employee_id, + source_type, + source_id, + transaction_date, + month_no, + transaction_type, + amount, + description +) +select + c.id, + fy.id, + p.id, + p.department_id, + a.id, + v.id, + cl.id, + e.id, + x.source_type, + null, + x.transaction_date, + extract(month from x.transaction_date)::integer, + x.transaction_type, + x.amount, + x.description +from companies c +join fiscal_years fy on fy.company_id = c.id and fy.fiscal_year = 2026 +join projects p on p.company_id = c.id +join accounts a on a.company_id = c.id +left join vendors v on v.company_id = c.id +left join clients cl on cl.company_id = c.id +left join employees e on e.company_id = c.id +join ( + values + ('PJT-2026-001', 'REV001', date '2026-01-31', 'revenue', 118000000.00, 'invoice', '1월 기성매출 실적', null, 'CL001', 'E002'), + ('PJT-2026-001', 'COST001', date '2026-01-25', 'cost', 38000000.00, 'purchase_order', '1월 외주비 실적', 'VD001', null, 'E005'), + ('PJT-2026-001', 'COST002', date '2026-01-27', 'cost', 24000000.00, 'manual', '1월 자재비 실적', 'VD002', null, 'E005'), + ('PJT-2026-001', 'EXP001', date '2026-01-31', 'expense', 7500000.00, 'manual', '1월 현장관리비', null, null, 'E005'), + ('PJT-2026-001', 'REV001', date '2026-02-28', 'revenue', 126000000.00, 'invoice', '2월 기성매출 실적', null, 'CL001', 'E002'), + ('PJT-2026-001', 'COST001', date '2026-02-26', 'cost', 47000000.00, 'manual', '2월 외주비 실적', 'VD001', null, 'E005'), + ('PJT-2026-001', 'EXP001', date '2026-02-28', 'expense', 8100000.00, 'manual', '2월 현장관리비', null, null, 'E005'), + ('PJT-2026-002', 'REV001', date '2026-02-28', 'revenue', 92000000.00, 'invoice', '2월 기성매출 실적', null, 'CL002', 'E002'), + ('PJT-2026-002', 'COST001', date '2026-02-18', 'cost', 29500000.00, 'purchase_order', '2월 외주비 실적', 'VD001', null, 'E003'), + ('PJT-2026-002', 'COST002', date '2026-02-20', 'cost', 21000000.00, 'invoice', '2월 자재비 실적', 'VD002', null, 'E003'), + ('PJT-2026-002', 'EXP001', date '2026-02-28', 'expense', 5900000.00, 'manual', '2월 현장관리비', null, null, 'E003') +) as x( + project_code, + account_code, + transaction_date, + transaction_type, + amount, + source_type, + description, + vendor_code, + client_code, + employee_no +) + on p.project_code = x.project_code + and a.code = x.account_code + and (v.code = x.vendor_code or x.vendor_code is null) + and (cl.code = x.client_code or x.client_code is null) + and (e.employee_no = x.employee_no or x.employee_no is null) +where c.code = 'JH001'; + +insert into cashflow_transactions ( + company_id, + project_id, + department_id, + transaction_date, + cashflow_type, + amount, + description +) +select + c.id, + p.id, + p.department_id, + x.transaction_date, + x.cashflow_type, + x.amount, + x.description +from companies c +join projects p on p.company_id = c.id +join ( + values + ('PJT-2026-001', date '2026-02-05', 'inflow', 132000000.00, '1월 매출 입금'), + ('PJT-2026-001', date '2026-02-15', 'outflow', 41800000.00, '외주비 지급'), + ('PJT-2026-002', date '2026-03-12', 'outflow', 23100000.00, '자재비 지급') +) as x(project_code, transaction_date, cashflow_type, amount, description) + on p.project_code = x.project_code +where c.code = 'JH001'; + +insert into file_import_logs ( + company_id, + import_type, + file_name, + row_count, + success_count, + failure_count, + imported_by, + notes +) +select + c.id, + 'budget', + 'budget_2026_sample.xlsx', + 15, + 15, + 0, + e.id, + '샘플 예산 데이터 적재' +from companies c +join employees e on e.company_id = c.id and e.employee_no = 'E004' +where c.code = 'JH001'; diff --git a/db/staging_queries.sql b/db/staging_queries.sql new file mode 100644 index 0000000..5cb8405 --- /dev/null +++ b/db/staging_queries.sql @@ -0,0 +1,54 @@ +set search_path = budget_app, public; + +-- 1. Batch row count +select import_batch, count(*) as row_count +from staging_ptc_transactions +group by import_batch +order by import_batch desc; + +-- 2. Account code master candidates +select + account_code_raw, + account_name_raw, + count(*) as txn_count +from staging_ptc_transactions +where coalesce(account_code_raw, '') <> '' +group by account_code_raw, account_name_raw +order by account_code_raw, account_name_raw; + +-- 3. Department master candidates +select + department_name_raw, + count(*) as txn_count +from staging_ptc_transactions +where coalesce(department_name_raw, '') <> '' +group by department_name_raw +order by txn_count desc, department_name_raw; + +-- 4. Project code consistency check +select + project_code_raw, + count(distinct project_name_raw) as distinct_project_names, + count(distinct project_type_raw) as distinct_project_types +from staging_ptc_transactions +where coalesce(project_code_raw, '') <> '' +group by project_code_raw +having count(distinct project_name_raw) > 1 + or count(distinct project_type_raw) > 1 +order by project_code_raw; + +-- 5. Missing key values +select + source_row_no, + transaction_date, + in_out, + account_code_raw, + project_code_raw, + project_name_raw, + description_raw +from staging_ptc_transactions +where coalesce(account_code_raw, '') = '' + or coalesce(project_code_raw, '') = '' + or coalesce(project_name_raw, '') = '' + or coalesce(description_raw, '') = '' +order by source_row_no; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..94ff29f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + postgres: + image: postgres:16 + container_name: budget-postgres + environment: + POSTGRES_DB: budgetdb + POSTGRES_USER: budget + POSTGRES_PASSWORD: budget123 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro + - ./db/seed.sql:/docker-entrypoint-initdb.d/02_seed.sql:ro + + adminer: + image: adminer:4 + container_name: budget-adminer + ports: + - "8080:8080" + depends_on: + - postgres + +volumes: + postgres_data: diff --git a/main_page.html b/main_page.html new file mode 100644 index 0000000..21f9c27 --- /dev/null +++ b/main_page.html @@ -0,0 +1,437 @@ + + + + + + Project Profit Main + + + + + + + +
+ + + diff --git a/ptc_page.html b/ptc_page.html new file mode 100644 index 0000000..66e8938 --- /dev/null +++ b/ptc_page.html @@ -0,0 +1,648 @@ + + + + + + PTC 거래 원장 분석 페이지 + + + + + + + + + +
+ + + + diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py new file mode 100644 index 0000000..ec9fe09 --- /dev/null +++ b/server/ptc_api_server.py @@ -0,0 +1,2070 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +import sqlite3 +from collections import defaultdict +from datetime import datetime, timedelta +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse +from xml.etree import ElementTree as ET +from zipfile import ZipFile + + +BASE_DIR = Path("/home/hyein/project") +XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx" +METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx" +DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3" +NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} +PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"] +METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"] +METHOD_FAMILY_MAP = { + "HCP": "복합말뚝", + "CFT": "복합말뚝", + "DDH": "복합말뚝", + "GC": "합성형라멘", + "PB": "합성형라멘", + "IT": "합성형라멘", + "DR": "합성형라멘", + "SGC": "합성형라멘", + "RSD": "강관거더", + "RSW": "가시설", +} +METHOD_OPTIONS = ["HCP", "CFT", "DDH", "GC", "PB", "IT", "DR", "SGC", "RSD", "RSW"] +ACCOUNT_MASTER = { + "711": {"project_type": "시공", "category": "자재비", "name": "강관"}, + "712": {"project_type": "시공", "category": "자재비", "name": "PHC"}, + "713": {"project_type": "시공", "category": "자재비", "name": "결합구"}, + "714": {"project_type": "시공", "category": "자재비", "name": "부자재"}, + "715": {"project_type": "시공", "category": "자재비", "name": "주자재"}, + "721": {"project_type": "시공", "category": "외주비", "name": "항타장비"}, + "722": {"project_type": "시공", "category": "외주비", "name": "두부보강"}, + "723": {"project_type": "시공", "category": "외주비", "name": "시험용역"}, + "725": {"project_type": "시공", "category": "외주비", "name": "외주비 등"}, + "726": {"project_type": "시공", "category": "외주비", "name": "제작"}, + "727": {"project_type": "시공", "category": "외주비", "name": "인장"}, + "728": {"project_type": "시공", "category": "외주비", "name": "가설"}, + "729": {"project_type": "시공", "category": "외주비", "name": "철근가공"}, + "730": {"project_type": "시공", "category": "외주비", "name": "공장제작"}, + "724": {"project_type": "시공", "category": "인건비", "name": "노무비"}, + "513": {"project_type": "시공", "category": "인건비", "name": "시공 퇴직금"}, + "731": {"project_type": "시공", "category": "장비비", "name": "장비비"}, + "733": {"project_type": "시공", "category": "운반비", "name": "운반비"}, + "732": {"project_type": "시공", "category": "운반비", "name": "유류비"}, + "744": {"project_type": "시공", "category": "안전관리비", "name": "안전관리비(현장)"}, + "734": {"project_type": "시공", "category": "경비", "name": "주재비"}, + "735": {"project_type": "시공", "category": "경비", "name": "기타경비"}, + "736": {"project_type": "시공", "category": "경비", "name": "복리후생비"}, + "737": {"project_type": "시공", "category": "경비", "name": "여비교통비"}, + "738": {"project_type": "시공", "category": "경비", "name": "지급임차료"}, + "739": {"project_type": "시공", "category": "경비", "name": "보증수수료"}, + "740": {"project_type": "시공", "category": "경비", "name": "소모자재비"}, + "741": {"project_type": "시공", "category": "경비", "name": "잡자재대"}, + "742": {"project_type": "시공", "category": "경비", "name": "가스수도료"}, + "743": {"project_type": "시공", "category": "경비", "name": "수선비"}, + "811": {"project_type": "관리", "category": "일반운영비", "name": "복리후생비"}, + "812": {"project_type": "관리", "category": "일반운영비", "name": "여비교통비"}, + "813": {"project_type": "관리", "category": "일반운영비", "name": "접대비"}, + "814": {"project_type": "관리", "category": "일반운영비", "name": "통신비"}, + "822": {"project_type": "관리", "category": "일반운영비", "name": "차량유지비"}, + "823": {"project_type": "관리", "category": "일반운영비", "name": "연구개발비"}, + "825": {"project_type": "관리", "category": "일반운영비", "name": "교육훈련비"}, + "826": {"project_type": "관리", "category": "일반운영비", "name": "도서인쇄비"}, + "827": {"project_type": "관리", "category": "일반운영비", "name": "광고선전비"}, + "829": {"project_type": "관리", "category": "일반운영비", "name": "사무용품비"}, + "830": {"project_type": "관리", "category": "일반운영비", "name": "소모품비"}, + "843": {"project_type": "관리", "category": "일반운영비", "name": "부서비"}, + "817": {"project_type": "관리", "category": "법정,의무", "name": "세금과공과금"}, + "819": {"project_type": "관리", "category": "법정,의무", "name": "지급임차료"}, + "821": {"project_type": "관리", "category": "법정,의무", "name": "보험료"}, + "831": {"project_type": "관리", "category": "외부전문,전략", "name": "지급수수료"}, + "849": {"project_type": "관리", "category": "외부전문,전략", "name": "지원서비스"}, + "850": {"project_type": "관리", "category": "안전관리비", "name": "안전관리비(본사)"}, + "501": {"project_type": "관리", "category": "인건비", "name": "관리 임금"}, + "502": {"project_type": "관리", "category": "인건비", "name": "공무 임금"}, + "503": {"project_type": "관리", "category": "인건비", "name": "시공 임금"}, + "504": {"project_type": "관리", "category": "인건비", "name": "설계 임금"}, + "505": {"project_type": "관리", "category": "인건비", "name": "지원 임금"}, + "511": {"project_type": "관리", "category": "인건비", "name": "관리 퇴직금"}, + "512": {"project_type": "관리", "category": "인건비", "name": "공무 퇴직금"}, + "514": {"project_type": "관리", "category": "인건비", "name": "설계 퇴직금"}, + "515": {"project_type": "관리", "category": "인건비", "name": "지원 퇴직금"}, + "521": {"project_type": "관리", "category": "인건비", "name": "소득세"}, + "522": {"project_type": "관리", "category": "인건비", "name": "주민세"}, + "523": {"project_type": "관리", "category": "인건비", "name": "4대보험"}, + "524": {"project_type": "관리", "category": "인건비", "name": "퇴직급여"}, +} +ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE = { + project_type: {code for code, item in ACCOUNT_MASTER.items() if item["project_type"] == project_type} + for project_type in ("시공", "관리") +} +SUGGESTED_ACCOUNT_REMAP = { + ("시공", "811"): "736", + ("시공", "812"): "737", + ("시공", "819"): "738", + ("시공", "850"): "744", + ("관리", "736"): "811", + ("관리", "737"): "812", + ("관리", "738"): "819", + ("관리", "744"): "850", +} +INCOME_ACCOUNT_CATEGORY_MAP = { + "401": "공사수입", + "402": "용역수입", + "403": "기타수입", + "110": "당좌자산", +} +INCOME_ACCOUNT_NAME_MAP = { + "401": "공사수입", + "402": "용역수입", + "403": "기타수입", + "110": "받을어음", +} +SPECIAL_ACCOUNT_MASTER = { + "901": {"section": "영업외 수지", "group": "영업외수익", "category": "이자수입", "name": "이자수입"}, + "903": {"section": "영업외 수지", "group": "영업외수익", "category": "잡이익", "name": "잡이익"}, + "904": {"section": "영업외 수지", "group": "영업외수익", "category": "배당수익", "name": "배당수익"}, + "961": {"section": "영업외 수지", "group": "영업외비용", "category": "이자비용", "name": "이자비용"}, + "962": {"section": "영업외 수지", "group": "영업외비용", "category": "잡손실", "name": "잡손실"}, + "963": {"section": "영업외 수지", "group": "영업외비용", "category": "가지급금", "name": "가지급금"}, + "999": {"section": "영업외 수지", "group": "영업외비용", "category": "법인세등", "name": "법인세등"}, + "103": {"section": "자산", "group": "당좌자산", "category": "보통예금", "name": "보통예금"}, + "124": {"section": "자산", "group": "당좌자산", "category": "매도가능증권", "name": "매도가능증권"}, + "135": {"section": "자산", "group": "당좌자산", "category": "매입부가세", "name": "매입부가세"}, + "178": {"section": "자산", "group": "투자자산", "category": "회원권", "name": "회원권"}, + "191": {"section": "자산", "group": "투자자산", "category": "출자금", "name": "출자금"}, + "192": {"section": "자산", "group": "투자자산", "category": "임차보증금", "name": "임차보증금"}, + "194": {"section": "자산", "group": "기타비유동자산", "category": "전도금", "name": "전도금"}, + "195": {"section": "자산", "group": "기타비유동자산", "category": "보증금", "name": "보증금"}, + "196": {"section": "자산", "group": "기타비유동자산", "category": "대여금", "name": "대여금"}, + "206": {"section": "자산", "group": "유형자산", "category": "기계장치", "name": "기계장치"}, + "208": {"section": "자산", "group": "유형자산", "category": "차량운반구", "name": "차량운반구"}, + "210": {"section": "자산", "group": "유형자산", "category": "공구기구", "name": "공구기구"}, + "212": {"section": "자산", "group": "유형자산", "category": "비품", "name": "비품"}, + "219": {"section": "자산", "group": "유형자산", "category": "시설장치", "name": "시설장치"}, + "231": {"section": "자산", "group": "무형자산", "category": "영업권", "name": "영업권"}, + "241": {"section": "자산", "group": "무형자산", "category": "사용수익기부자산", "name": "사용수익기부자산"}, + "257": {"section": "부채", "group": "유동부채", "category": "가수금", "name": "가수금"}, + "258": {"section": "부채", "group": "유동부채", "category": "매출부가세", "name": "매출부가세"}, + "259": {"section": "부채", "group": "유동부채", "category": "선수금", "name": "선수금"}, + "260": {"section": "부채", "group": "유동부채", "category": "단기차입금", "name": "단기차입금"}, + "293": {"section": "부채", "group": "비유동부채", "category": "장기차입금", "name": "장기차입금"}, + "294": {"section": "부채", "group": "비유동부채", "category": "임대보증금", "name": "임대보증금"}, +} +ACCOUNT_STRUCTURE_TEMPLATE = [ + {"section": "수입", "group": "수입", "categories": ["공사수입", "용역수입", "기타수입", "당좌자산"]}, + {"section": "영업외 수지", "group": "영업외수익", "categories": ["이자수입", "잡이익", "배당수익"]}, + {"section": "영업외 수지", "group": "영업외비용", "categories": ["이자비용", "잡손실", "가지급금", "법인세등"]}, + {"section": "자산", "group": "당좌자산", "categories": ["보통예금", "매도가능증권", "매입부가세"]}, + {"section": "자산", "group": "투자자산", "categories": ["회원권", "출자금", "임차보증금"]}, + {"section": "자산", "group": "기타비유동자산", "categories": ["전도금", "보증금", "대여금"]}, + {"section": "자산", "group": "유형자산", "categories": ["기계장치", "차량운반구", "공구기구", "비품", "시설장치"]}, + {"section": "자산", "group": "무형자산", "categories": ["영업권", "사용수익기부자산"]}, + {"section": "부채", "group": "유동부채", "categories": ["가수금", "매출부가세", "선수금", "단기차입금"]}, + {"section": "부채", "group": "비유동부채", "categories": ["장기차입금", "임대보증금"]}, + {"section": "지출", "group": "시공", "categories": ["자재비", "외주비", "인건비", "장비비", "운반비", "안전관리비", "경비"]}, + {"section": "지출", "group": "관리", "categories": ["일반운영비", "법정,의무", "외부전문,전략", "안전관리비", "인건비"]}, +] + + +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 "" + + +def resolve_project_type(project_code: str, raw_project_type: str, master_project_type: str = "") -> str: + inferred = infer_project_type_from_code(project_code) + raw = (raw_project_type or "").strip() + master = (master_project_type or "").strip() + + # Project codes like "23-설계-13" are more stable than mixed transaction + # labels, so when the code clearly encodes the type, trust it first. + if inferred: + return inferred + + if master: + return master + return raw + + +def resolve_construction_family(construction_method: str, stored_family: str = "") -> str: + method = (construction_method or "").strip().upper() + family = (stored_family or "").strip() + return METHOD_FAMILY_MAP.get(method, family) + + +def resolve_account_name(account_code: str, fallback_name: str = "") -> str: + code = (account_code or "").strip() + if code in INCOME_ACCOUNT_NAME_MAP: + return INCOME_ACCOUNT_NAME_MAP[code] + if code in SPECIAL_ACCOUNT_MASTER: + return SPECIAL_ACCOUNT_MASTER[code]["name"] + return ACCOUNT_MASTER.get(code, {}).get("name", fallback_name) + + +def suggest_account_code(project_type: str, account_code: str, account_name: str) -> str: + explicit = SUGGESTED_ACCOUNT_REMAP.get((project_type, account_code)) + if explicit: + return explicit + normalized_name = (account_name or "").strip() + for code, item in ACCOUNT_MASTER.items(): + if item["project_type"] == project_type and item["name"] == normalized_name: + return code + return "" + + +def get_category_account_items(section: str, group: str, category: str) -> list[dict]: + items = [] + if section == "수입": + for code, target_category in INCOME_ACCOUNT_CATEGORY_MAP.items(): + if target_category == category: + items.append({"account_code": code, "account_name": resolve_account_name(code, category)}) + return items + + if section in {"영업외 수지", "자산", "부채"}: + for code, meta in SPECIAL_ACCOUNT_MASTER.items(): + if meta["section"] == section and meta["group"] == group and meta["category"] == category: + items.append({"account_code": code, "account_name": meta["name"]}) + return items + + for code, meta in ACCOUNT_MASTER.items(): + if meta["project_type"] == group and meta["category"] == category: + items.append({"account_code": code, "account_name": meta["name"]}) + return items + + +def col_to_num(col: str) -> int: + value = 0 + for ch in col: + if ch.isalpha(): + value = value * 26 + ord(ch.upper()) - 64 + return value + + +def excel_serial_to_date(value: str) -> str: + if not value: + return "" + try: + number = float(value) + except ValueError: + return value + base = datetime(1899, 12, 30) + return (base + timedelta(days=number)).strftime("%Y-%m-%d") + + +def parse_amount(value: str) -> float: + text = (value or "").strip() + if not text or text == "-": + return 0.0 + return float(text.replace(",", "")) + + +def normalize_transaction_type(in_out: str, account_name: str) -> str: + if "입" in in_out: + return "revenue" + if "출" in in_out: + if "수입" in account_name or "매출" in account_name: + return "revenue" + return "cost_expense" + return "unknown" + + +def read_xlsx_rows(path: Path) -> list[dict]: + with ZipFile(path) as book: + shared_strings = [] + root = ET.fromstring(book.read("xl/sharedStrings.xml")) + for si in root.findall("a:si", NS): + text = "".join(node.text or "" for node in si.iterfind(".//a:t", NS)) + shared_strings.append(text) + + sheet = ET.fromstring(book.read("xl/worksheets/sheet1.xml")) + rows = [] + for row in sheet.find("a:sheetData", NS).findall("a:row", NS): + values = defaultdict(str) + for cell in row.findall("a:c", NS): + ref = cell.attrib.get("r", "") + match = re.match(r"([A-Z]+)(\d+)", ref) + col = col_to_num(match.group(1)) if match else None + node = cell.find("a:v", NS) + if node is None: + value = "" + else: + value = node.text or "" + if cell.attrib.get("t") == "s": + value = shared_strings[int(value)] + values[col] = value + width = max(values) if values else 0 + rows.append([values[i] for i in range(1, width + 1)]) + + if not rows: + return [] + + headers = rows[0] + data_rows = rows[1:] + width = len(headers) + items = [] + for source_row_no, row in enumerate(data_rows, start=2): + current = row + [""] * (width - len(row)) if len(row) < width else row[:width] + payload = dict(zip(headers, current)) + items.append( + { + "source_row_no": source_row_no, + "transaction_date_raw": payload.get("거래일", ""), + "transaction_date": excel_serial_to_date(payload.get("거래일", "")), + "in_out": payload.get("입/출금", ""), + "account_code": payload.get("계정코드", ""), + "account_name": payload.get("구분", ""), + "department_name": payload.get("부서", ""), + "vendor_name": payload.get("거래처", ""), + "project_code": payload.get("프로젝트코드", ""), + "project_type": payload.get("프로젝트 구분(안)", ""), + "project_name": payload.get("프로젝트명", ""), + "description": payload.get("적요", ""), + "supply_amount_raw": payload.get("공급가액", ""), + "vat_amount_raw": payload.get("부가세", ""), + "total_amount_raw": payload.get("합계금액", ""), + "remarks": payload.get("비고", ""), + "supply_amount": parse_amount(payload.get("공급가액", "")), + "vat_amount": parse_amount(payload.get("부가세", "")), + "total_amount": parse_amount(payload.get("합계금액", "")), + "normalized_type": normalize_transaction_type( + payload.get("입/출금", ""), payload.get("구분", "") + ), + } + ) + return items + + +def normalize_name(value: str) -> str: + return re.sub(r"\s+|,|\(|\)|~|-", "", (value or "").strip().lower()) + + +def read_method_rows(path: Path) -> list[dict]: + if not path.exists(): + return [] + with ZipFile(path) as book: + shared_strings = [] + root = ET.fromstring(book.read("xl/sharedStrings.xml")) + for si in root.findall("a:si", NS): + text = "".join(node.text or "" for node in si.iterfind(".//a:t", NS)) + shared_strings.append(text) + + sheet = ET.fromstring(book.read("xl/worksheets/sheet1.xml")) + rows = [] + for row in sheet.find("a:sheetData", NS).findall("a:row", NS)[1:]: + values = defaultdict(str) + for cell in row.findall("a:c", NS): + ref = cell.attrib.get("r", "") + match = re.match(r"([A-Z]+)(\d+)", ref) + col = col_to_num(match.group(1)) if match else None + node = cell.find("a:v", NS) + if node is None: + value = "" + else: + value = node.text or "" + if cell.attrib.get("t") == "s": + value = shared_strings[int(value)] + values[col] = value + rows.append( + { + "project_code": values[1].strip(), + "project_name": values[2].strip(), + "construction_method": values[3].strip().upper(), + } + ) + return rows + + +def get_conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + create table if not exists ptc_transactions ( + id integer primary key autoincrement, + source_row_no integer not null, + transaction_date_raw text, + transaction_date text, + in_out text, + account_code text, + account_name text, + account_code_final text, + account_name_final text, + department_name text, + vendor_name text, + project_code text, + project_type text, + project_name text, + description text, + supply_amount_raw text, + vat_amount_raw text, + total_amount_raw text, + remarks text, + supply_amount real not null default 0, + vat_amount real not null default 0, + total_amount real not null default 0, + normalized_type text, + imported_at text not null + ) + """ + ) + cur.execute( + """ + create table if not exists meta ( + key text primary key, + value text not null + ) + """ + ) + cur.execute( + """ + create table if not exists project_master ( + project_code text primary key, + project_name text, + project_type text, + construction_family text, + construction_method text, + note text, + updated_at text not null + ) + """ + ) + cur.execute( + """ + create table if not exists project_budget_lines ( + project_code text not null, + section text not null, + group_name text not null, + category text not null, + budget_amount real not null default 0, + updated_at text not null, + primary key (project_code, section, group_name, category) + ) + """ + ) + cur.execute( + """ + create table if not exists project_progress ( + project_code text primary key, + progress_rate real not null default 0, + updated_at text not null + ) + """ + ) + cur.execute( + """ + create table if not exists project_budget_account_lines ( + project_code text not null, + section text not null, + group_name text not null, + category text not null, + account_code text not null, + account_name text, + budget_amount real not null default 0, + updated_at text not null, + primary key (project_code, section, group_name, category, account_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") + 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") + if "account_name_final" not in txn_cols: + cur.execute("alter table ptc_transactions add column account_name_final text") + cur.execute( + """ + update ptc_transactions + set + account_code_final = coalesce(nullif(account_code_final, ''), account_code), + account_name_final = coalesce(nullif(account_name_final, ''), account_name) + """ + ) + 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 + if needs_refresh: + rows = read_xlsx_rows(XLSX_PATH) + cur.execute("delete from ptc_transactions") + cur.executemany( + """ + insert into ptc_transactions ( + source_row_no, transaction_date_raw, transaction_date, in_out, account_code, + account_name, account_code_final, account_name_final, department_name, vendor_name, project_code, project_type, + project_name, description, supply_amount_raw, vat_amount_raw, total_amount_raw, + remarks, supply_amount, vat_amount, total_amount, normalized_type, imported_at + ) values ( + :source_row_no, :transaction_date_raw, :transaction_date, :in_out, :account_code, + :account_name, :account_code, :account_name, :department_name, :vendor_name, :project_code, :project_type, + :project_name, :description, :supply_amount_raw, :vat_amount_raw, :total_amount_raw, + :remarks, :supply_amount, :vat_amount, :total_amount, :normalized_type, :imported_at + ) + """, + [{**item, "imported_at": datetime.utcnow().isoformat()} for item in rows], + ) + cur.execute( + "insert into meta(key, value) values('xlsx_mtime', ?) " + "on conflict(key) do update set value = excluded.value", + (xlsx_mtime,), + ) + conn.commit() + + method_mtime = str(int(METHOD_XLSX_PATH.stat().st_mtime)) if METHOD_XLSX_PATH.exists() else "" + row = cur.execute("select value from meta where key = 'method_xlsx_mtime'").fetchone() + needs_method_refresh = METHOD_XLSX_PATH.exists() and (row is None or row["value"] != method_mtime) + if needs_method_refresh: + method_rows = read_method_rows(METHOD_XLSX_PATH) + project_rows = cur.execute( + "select distinct project_code, project_name, project_type from ptc_transactions where coalesce(project_code,'') <> ''" + ).fetchall() + by_code = {row["project_code"]: row for row in project_rows} + by_name = {normalize_name(row["project_name"]): row for row in project_rows if row["project_name"]} + for item in method_rows: + target = None + if item["project_code"] and item["project_code"] in by_code: + target = by_code[item["project_code"]] + elif item["project_name"] and normalize_name(item["project_name"]) in by_name: + target = by_name[normalize_name(item["project_name"])] + if not target: + continue + method = item["construction_method"] + family = METHOD_FAMILY_MAP.get(method, "") + updated_at = datetime.now().isoformat() + cur.execute( + """ + insert into project_master ( + project_code, project_name, project_type, construction_family, construction_method, note, updated_at + ) values (?, ?, ?, ?, ?, ?, ?) + on conflict(project_code) do update set + project_name = coalesce(project_master.project_name, excluded.project_name), + project_type = coalesce(project_master.project_type, excluded.project_type), + construction_family = excluded.construction_family, + construction_method = excluded.construction_method, + updated_at = excluded.updated_at + """, + ( + target["project_code"], + target["project_name"], + resolve_project_type(target["project_code"], target["project_type"]), + family, + method, + "", + updated_at, + ), + ) + cur.execute( + "insert into meta(key, value) values('method_xlsx_mtime', ?) " + "on conflict(key) do update set value = excluded.value", + (method_mtime,), + ) + conn.commit() + conn.close() + + +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 + from project_master + where project_code = ? + """, + (project_code,), + ).fetchone() + return dict(row) if row else None + + +def fetch_project_defaults(conn: sqlite3.Connection, project_code: str) -> dict: + row = conn.execute( + """ + select project_code, max(project_name) as project_name, max(project_type) as project_type + from ptc_transactions + where project_code = ? + group by project_code + """, + (project_code,), + ).fetchone() + return dict(row) if row else {"project_code": project_code, "project_name": "", "project_type": ""} + + +def get_project_account_issues(conn: sqlite3.Connection, project_code: str, resolved_project_type: str) -> list[dict]: + allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type) + if not allowed_codes: + return [] + + rows = conn.execute( + """ + select + account_code_final as account_code, + account_name_final as account_name, + count(*) as txn_count, + coalesce(sum(supply_amount), 0) as supply_sum + from ptc_transactions + where project_code = ? + group by account_code_final, account_name_final + order by supply_sum desc, account_code_final + """, + (project_code,), + ).fetchall() + + items = [] + for row in rows: + code = (row["account_code"] or "").strip() + if not code or code not in ACCOUNT_MASTER or code in allowed_codes: + continue + suggested_code = suggest_account_code(resolved_project_type, code, row["account_name"] or "") + items.append( + { + "account_code": code, + "account_name": row["account_name"] or "", + "txn_count": row["txn_count"], + "supply_sum": row["supply_sum"], + "suggested_code": suggested_code, + "suggested_name": resolve_account_name(suggested_code, ""), + "is_invalid": True, + } + ) + return items + + +def build_account_structure_rows(account_rows: list[sqlite3.Row]) -> list[dict]: + aggregated: dict[tuple[str, str, str], dict] = {} + extra_rows: list[dict] = [] + category_account_labels = defaultdict(list) + + for code, category in INCOME_ACCOUNT_CATEGORY_MAP.items(): + category_account_labels[("수입", category)].append(f"{code} {resolve_account_name(code, category)}") + for code, meta in SPECIAL_ACCOUNT_MASTER.items(): + category_account_labels[(meta["group"], meta["category"])].append(f"{code} {meta['name']}") + for code, meta in ACCOUNT_MASTER.items(): + category_account_labels[(meta["project_type"], meta["category"])].append(f"{code} {meta['name']}") + + for row in account_rows: + code = (row["code"] or "").strip() + name = (row["name"] or "").strip() + count = row["count"] or 0 + total = row["total"] or 0 + + if code in INCOME_ACCOUNT_CATEGORY_MAP: + key = ("수입", "수입", INCOME_ACCOUNT_CATEGORY_MAP[code]) + elif code in SPECIAL_ACCOUNT_MASTER: + meta = SPECIAL_ACCOUNT_MASTER[code] + key = (meta["section"], meta["group"], meta["category"]) + elif code in ACCOUNT_MASTER: + meta = ACCOUNT_MASTER[code] + key = ("지출", meta["project_type"], meta["category"]) + else: + extra_rows.append( + { + "section": "기타", + "group": "미분류", + "category": f"{code} {name}".strip(), + "account_items": [{"account_code": code, "account_name": name, "actual_amount": total, "budget_amount": 0}], + "count": count, + "total": total, + } + ) + continue + + current = aggregated.setdefault( + key, + { + "section": key[0], + "group": key[1], + "category": key[2], + "account_labels": " / ".join(category_account_labels.get((key[1], key[2]), [])), + "account_items": [ + {**item, "actual_amount": 0, "budget_amount": 0} + for item in get_category_account_items(key[0], key[1], key[2]) + ], + "count": 0, + "total": 0, + }, + ) + current["count"] += count + current["total"] += total + for account_item in current["account_items"]: + if account_item["account_code"] == code: + account_item["actual_amount"] = total + break + + rows = [] + for block in ACCOUNT_STRUCTURE_TEMPLATE: + for category in block["categories"]: + key = (block["section"], block["group"], category) + item = aggregated.get( + key, + { + "section": block["section"], + "group": block["group"], + "category": category, + "account_labels": " / ".join(category_account_labels.get((block["group"], category), [])), + "account_items": [ + {**item, "actual_amount": 0, "budget_amount": 0} + for item in get_category_account_items(block["section"], block["group"], category) + ], + "count": 0, + "total": 0, + }, + ) + if "account_labels" not in item: + item["account_labels"] = " / ".join(category_account_labels.get((block["group"], category), [])) + rows.append(item) + + rows.extend(extra_rows) + return rows + + +def build_budget_analysis(conn: sqlite3.Connection, project_code: str, account_structure_rows: list[dict]) -> dict: + item_budget_rows = conn.execute( + """ + select section, group_name, category, budget_amount + from project_budget_lines + where project_code = ? + """, + (project_code,), + ).fetchall() + item_budget_map = { + (row["section"], row["group_name"], row["category"]): row["budget_amount"] or 0 + for row in item_budget_rows + } + budget_rows = conn.execute( + """ + select section, group_name, category, account_code, budget_amount + from project_budget_account_lines + where project_code = ? + """, + (project_code,), + ).fetchall() + budget_map = { + (row["section"], row["group_name"], row["category"], row["account_code"]): row["budget_amount"] or 0 + for row in budget_rows + } + progress_row = conn.execute( + "select progress_rate from project_progress where project_code = ?", + (project_code,), + ).fetchone() + progress_rate = progress_row["progress_rate"] if progress_row else 0 + + rows = [] + expense_budget_total = 0.0 + expense_actual_total = 0.0 + revenue_budget_total = 0.0 + revenue_actual_total = 0.0 + + for item in account_structure_rows: + account_items = [] + for account_item in item.get("account_items", []): + budget_amount_item = float( + budget_map.get((item["section"], item["group"], item["category"], account_item["account_code"]), 0) or 0 + ) + account_items.append( + { + **account_item, + "budget_amount": budget_amount_item, + } + ) + account_budget_total = sum(account_item["budget_amount"] for account_item in account_items) + budget_amount = float( + item_budget_map.get((item["section"], item["group"], item["category"]), account_budget_total) or 0 + ) + actual_amount = float(item["total"] or 0) + execution_rate = (actual_amount / budget_amount * 100) if budget_amount > 0 else 0 + row = { + **item, + "account_items": account_items, + "budget_amount": budget_amount, + "account_budget_total": account_budget_total, + "actual_amount": actual_amount, + "execution_rate": execution_rate, + } + rows.append(row) + if item["section"] == "지출": + expense_budget_total += budget_amount + expense_actual_total += actual_amount + if item["section"] == "수입": + revenue_budget_total += budget_amount + revenue_actual_total += actual_amount + + execution_rate_total = (expense_actual_total / expense_budget_total * 100) if expense_budget_total > 0 else 0 + return { + "progress_rate": progress_rate, + "execution_rate_total": execution_rate_total, + "expense_budget_total": expense_budget_total, + "expense_actual_total": expense_actual_total, + "revenue_budget_total": revenue_budget_total, + "revenue_actual_total": revenue_actual_total, + "rows": rows, + } + + +def build_where(params: dict[str, list[str]]) -> tuple[str, list]: + clauses = [] + values = [] + keyword = params.get("keyword", [""])[0].strip().lower() + project_type = params.get("project_type", ["전체"])[0] + in_out = params.get("in_out", ["전체"])[0] + + if keyword: + like = f"%{keyword}%" + clauses.append( + """ + ( + lower(coalesce(account_code_final, '')) like ? + or lower(coalesce(account_name_final, '')) like ? + or lower(coalesce(department_name, '')) like ? + or lower(coalesce(vendor_name, '')) like ? + or lower(coalesce(project_code, '')) like ? + or lower(coalesce(project_type, '')) like ? + or lower(coalesce(project_name, '')) like ? + or lower(coalesce(description, '')) like ? + ) + """ + ) + values.extend([like] * 8) + + if project_type and project_type != "전체": + clauses.append("project_type = ?") + values.append(project_type) + + if in_out and in_out != "전체": + clauses.append("in_out = ?") + values.append(in_out) + + where = " where " + " and ".join(clauses) if clauses else "" + return where, values + + +def build_project_where(project_code: str, keyword: str = "", in_out: str = "전체") -> tuple[str, list]: + clauses = ["coalesce(project_code, '') = ?"] + values = [project_code] + + if keyword.strip(): + like = f"%{keyword.strip().lower()}%" + clauses.append( + """ + ( + lower(coalesce(account_code_final, '')) like ? + or lower(coalesce(account_name_final, '')) like ? + or lower(coalesce(department_name, '')) like ? + or lower(coalesce(vendor_name, '')) like ? + or lower(coalesce(project_name, '')) like ? + or lower(coalesce(description, '')) like ? + ) + """ + ) + values.extend([like] * 6) + + if in_out and in_out != "전체": + clauses.append("in_out = ?") + values.append(in_out) + + return " where " + " and ".join(clauses), values + + +def query_summary(conn: sqlite3.Connection, params: dict[str, list[str]]) -> dict: + where, values = build_where(params) + row = conn.execute( + f""" + select + count(*) as 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(supply_amount), 0) as supply_sum, + coalesce(sum(vat_amount), 0) as vat_sum, + coalesce(sum(total_amount), 0) as total_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + {where} + """, + values, + ).fetchone() + + missing_row = conn.execute( + f""" + select count(*) as missing_critical + from ptc_transactions + {where} + {" and " if where else " where "} + ( + coalesce(account_code_final, '') = '' + or coalesce(account_name_final, '') = '' + or coalesce(transaction_date, '') = '' + or coalesce(description, '') = '' + ) + """, + values, + ).fetchone() + + return { + "count": row["count"], + "income_count": row["income_count"], + "expense_count": row["expense_count"], + "supply_sum": row["supply_sum"], + "vat_sum": row["vat_sum"], + "total_sum": row["total_sum"], + "min_date": row["min_date"] or "", + "max_date": row["max_date"] or "", + "missing_critical": missing_row["missing_critical"], + } + + +def rows_to_dicts(rows) -> list[dict]: + return [dict(row) for row in rows] + + +class Handler(BaseHTTPRequestHandler): + def _read_json(self) -> dict: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length > 0 else b"{}" + return json.loads(raw.decode("utf-8")) + + def _send_html(self, status: int, html: str) -> None: + body = html.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _send(self, status: int, payload: dict) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self._send(200, {"ok": True}) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + conn = get_conn() + try: + if parsed.path == "/api/project-master/upsert": + payload = self._read_json() + project_code = str(payload.get("project_code", "")).strip() + if not project_code: + self._send(400, {"ok": False, "message": "project_code is required"}) + return + + project_name = str(payload.get("project_name", "")).strip() + project_type = str(payload.get("project_type", "")).strip() + if project_type and project_type not in PROJECT_TYPE_OPTIONS: + self._send(400, {"ok": False, "message": "invalid project_type"}) + return + construction_family = str(payload.get("construction_family", "")).strip() + construction_method = str(payload.get("construction_method", "")).strip() + if construction_method and construction_method not in METHOD_OPTIONS: + self._send(400, {"ok": False, "message": "invalid construction_method"}) + return + construction_family = resolve_construction_family(construction_method, construction_family) + 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 (?, ?, ?, ?, ?, ?, ?) + 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, + note = excluded.note, + updated_at = excluded.updated_at + """, + (project_code, project_name, project_type, construction_family, construction_method, note, updated_at), + ) + conn.commit() + self._send(200, {"ok": True, "item": fetch_project_master(conn, project_code)}) + return + + if parsed.path == "/api/project-master/batch-update-method": + payload = self._read_json() + project_codes = payload.get("project_codes", []) + construction_method = str(payload.get("construction_method", "")).strip() + note = str(payload.get("note", "")).strip() + + if not isinstance(project_codes, list) or not project_codes: + self._send(400, {"ok": False, "message": "project_codes is required"}) + return + if construction_method and construction_method not in METHOD_OPTIONS: + self._send(400, {"ok": False, "message": "invalid construction_method"}) + return + + construction_family = resolve_construction_family(construction_method, "") + updated_at = datetime.now().isoformat() + updated_items = [] + + for raw_code in project_codes: + project_code = str(raw_code).strip() + if not project_code: + continue + default_item = fetch_project_defaults(conn, project_code) + existing = fetch_project_master(conn, project_code) or {} + project_name = existing.get("project_name") or default_item.get("project_name") or "" + project_type = resolve_project_type( + project_code, + default_item.get("project_type", ""), + existing.get("project_type", ""), + ) + merged_note = note if note else (existing.get("note") or "") + + conn.execute( + """ + insert into project_master ( + project_code, project_name, project_type, construction_family, construction_method, 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, + note = excluded.note, + updated_at = excluded.updated_at + """, + ( + project_code, + project_name, + project_type, + construction_family, + construction_method, + merged_note, + updated_at, + ), + ) + updated_items.append(project_code) + + conn.commit() + self._send( + 200, + { + "ok": True, + "updated_count": len(updated_items), + "project_codes": updated_items, + }, + ) + return + + if parsed.path == "/api/project-account-remap": + payload = self._read_json() + project_code = str(payload.get("project_code", "")).strip() + from_account_code = str(payload.get("from_account_code", "")).strip() + to_account_code = str(payload.get("to_account_code", "")).strip() + if not project_code or not from_account_code or not to_account_code: + self._send(400, {"ok": False, "message": "project_code, from_account_code, to_account_code are required"}) + return + if to_account_code not in ACCOUNT_MASTER: + self._send(400, {"ok": False, "message": "invalid to_account_code"}) + return + + project_default = fetch_project_defaults(conn, project_code) + project_master = fetch_project_master(conn, project_code) or {} + resolved_project_type = resolve_project_type( + project_code, + project_default.get("project_type", ""), + project_master.get("project_type", ""), + ) + allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type, set()) + if allowed_codes and to_account_code not in allowed_codes: + self._send(400, {"ok": False, "message": "target account is not allowed for project type"}) + return + + to_account_name = resolve_account_name(to_account_code, "") + updated_at = datetime.now().isoformat() + cur = conn.cursor() + cur.execute( + """ + update ptc_transactions + set + account_code_final = ?, + account_name_final = ?, + imported_at = imported_at + where project_code = ? + and account_code_final = ? + """, + (to_account_code, to_account_name, project_code, from_account_code), + ) + changed_rows = cur.rowcount + conn.commit() + self._send( + 200, + { + "ok": True, + "updated_count": changed_rows, + "project_code": project_code, + "from_account_code": from_account_code, + "to_account_code": to_account_code, + "to_account_name": to_account_name, + "updated_at": updated_at, + }, + ) + return + + if parsed.path == "/api/project-account-remap-rows": + payload = self._read_json() + project_code = str(payload.get("project_code", "")).strip() + rows = payload.get("rows", []) + if not project_code or not isinstance(rows, list): + self._send(400, {"ok": False, "message": "project_code and rows are required"}) + return + + project_default = fetch_project_defaults(conn, project_code) + project_master = fetch_project_master(conn, project_code) or {} + resolved_project_type = resolve_project_type( + project_code, + project_default.get("project_type", ""), + project_master.get("project_type", ""), + ) + allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type, set()) + + cur = conn.cursor() + updated_count = 0 + for item in rows: + source_row_no = int(item.get("source_row_no", 0) or 0) + to_account_code = str(item.get("to_account_code", "")).strip() + if source_row_no <= 0 or not to_account_code: + continue + if to_account_code not in ACCOUNT_MASTER: + continue + if allowed_codes and to_account_code not in allowed_codes: + continue + to_account_name = resolve_account_name(to_account_code, "") + cur.execute( + """ + update ptc_transactions + set + account_code_final = ?, + account_name_final = ?, + imported_at = imported_at + where project_code = ? + and source_row_no = ? + """, + (to_account_code, to_account_name, project_code, source_row_no), + ) + updated_count += cur.rowcount + + conn.commit() + self._send( + 200, + { + "ok": True, + "updated_count": updated_count, + "project_code": project_code, + }, + ) + return + + if parsed.path == "/api/project-budget/upsert": + payload = self._read_json() + project_code = str(payload.get("project_code", "")).strip() + item_rows = payload.get("item_rows", []) + account_rows = payload.get("account_rows", []) + progress_rate = float(payload.get("progress_rate", 0) or 0) + if not project_code: + self._send(400, {"ok": False, "message": "project_code is required"}) + return + if not isinstance(item_rows, list): + self._send(400, {"ok": False, "message": "item_rows must be a list"}) + return + if not isinstance(account_rows, list): + self._send(400, {"ok": False, "message": "account_rows must be a list"}) + return + + updated_at = datetime.now().isoformat() + conn.execute("delete from project_budget_lines where project_code = ?", (project_code,)) + conn.execute("delete from project_budget_account_lines where project_code = ?", (project_code,)) + for item in item_rows: + section = str(item.get("section", "")).strip() + group_name = str(item.get("group", "")).strip() + category = str(item.get("category", "")).strip() + budget_amount = float(item.get("budget_amount", 0) or 0) + if not section or not group_name or not category: + continue + conn.execute( + """ + insert into project_budget_lines ( + project_code, section, group_name, category, budget_amount, updated_at + ) values (?, ?, ?, ?, ?, ?) + """, + (project_code, section, group_name, category, budget_amount, updated_at), + ) + for item in account_rows: + section = str(item.get("section", "")).strip() + group_name = str(item.get("group", "")).strip() + category = str(item.get("category", "")).strip() + account_code = str(item.get("account_code", "")).strip() + account_name = str(item.get("account_name", "")).strip() + budget_amount = float(item.get("budget_amount", 0) or 0) + if not section or not group_name or not category or not account_code: + continue + conn.execute( + """ + insert into project_budget_account_lines ( + project_code, section, group_name, category, account_code, account_name, budget_amount, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + """, + (project_code, section, group_name, category, account_code, account_name, budget_amount, updated_at), + ) + conn.execute( + """ + insert into project_progress (project_code, progress_rate, updated_at) + values (?, ?, ?) + on conflict(project_code) do update set + progress_rate = excluded.progress_rate, + updated_at = excluded.updated_at + """, + (project_code, progress_rate, updated_at), + ) + conn.commit() + self._send(200, {"ok": True, "project_code": project_code, "updated_at": updated_at}) + return + + self._send(404, {"ok": False, "message": "Not found"}) + finally: + conn.close() + + def do_GET(self) -> None: + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + conn = get_conn() + try: + if parsed.path == "/": + count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"] + html = f""" + + + + + PTC API Server + + + +
+
+
+
+
PTC Data API
+

PTC 원장 데이터 서버

+

+ 이 서버는 `PTC(2023-2026.02).xlsx`를 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다. + 메인 화면은 http://localhost:8000/PTC 에서 확인할 수 있습니다. +

+
+
+
현재 적재 건수
+
{count:,}
+
원본 파일 기준 전체 거래 행 수
+
+
+ +
+
+
메인 화면
+
localhost:8000/PTC
+
사용자가 보는 메인 대시보드
+
+
+
헬스체크
+
/api/health
+
서버 상태와 row count 확인
+
+
+
요약
+
/api/summary
+
건수, 기간, 공급가액, 누락값
+
+
+
+ +
+

주요 API

+
+ GET/api/health +
API 서버가 정상 동작하는지와 적재 건수를 반환합니다.
+
+
+ GET/api/summary +
건수, 입금/출금, 공급가액, 부가세, 기간, 누락값 요약을 반환합니다.
+
+
+ GET/api/top-accounts +
계정코드별 상위 집계를 반환합니다.
+
+
+ GET/api/top-projects +
프로젝트별 상위 집계를 반환합니다.
+
+
+ GET/api/project-mismatches +
프로젝트코드 대비 프로젝트명/구분 불일치를 반환합니다.
+
+
+ GET/api/transactions?limit=30 +
원본 거래 미리보기를 반환합니다.
+
+
+ +
+

필터 예시

+
+ /api/summary?project_type=시공 +
+
+ /api/top-accounts?in_out=출금 +
+
+ /api/transactions?keyword=여비교통비&limit=20 +
+
+
+ +""" + self._send_html(200, html) + return + + if parsed.path == "/api/health": + count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"] + self._send(200, {"ok": True, "row_count": count}) + return + + if parsed.path == "/api/project-master-options": + self._send( + 200, + { + "project_type_options": PROJECT_TYPE_OPTIONS, + "method_family_options": METHOD_FAMILY_OPTIONS, + "method_options": METHOD_OPTIONS, + "method_family_map": METHOD_FAMILY_MAP, + "account_master": ACCOUNT_MASTER, + "allowed_account_codes_by_project_type": { + key: sorted(value) for key, value in ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.items() + }, + }, + ) + return + + if parsed.path == "/api/summary": + summary = query_summary(conn, params) + self._send(200, summary) + return + + if parsed.path == "/api/project-types": + rows = conn.execute( + "select distinct project_type from ptc_transactions where coalesce(project_type,'') <> '' order by project_type" + ).fetchall() + self._send(200, {"items": [row["project_type"] for row in rows]}) + return + + if parsed.path == "/api/projects": + keyword = params.get("keyword", [""])[0].strip().lower() + project_type = params.get("project_type", ["전체"])[0] + clauses = ["coalesce(project_code, '') <> ''"] + values = [] + if keyword: + like = f"%{keyword}%" + clauses.append( + """ + ( + lower(coalesce(project_code, '')) like ? + or lower(coalesce(project_name, '')) like ? + or lower(coalesce(project_type, '')) like ? + ) + """ + ) + values.extend([like, like, like]) + if project_type and project_type != "전체": + clauses.append("project_type = ?") + values.append(project_type) + where = " where " + " and ".join(clauses) + rows = conn.execute( + f""" + select + project_code, + max(project_name) as project_name, + max(project_type) as project_type, + count(*) as txn_count, + coalesce(sum(supply_amount), 0) as supply_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + {where} + group by project_code + order by supply_sum desc, project_code + """, + values, + ).fetchall() + items = rows_to_dicts(rows) + for item in items: + master = fetch_project_master(conn, item["project_code"]) + item["project_type"] = resolve_project_type( + item["project_code"], + item["project_type"], + master.get("project_type") if master else "", + ) + if master: + item["project_name"] = master.get("project_name") or item["project_name"] + item["construction_family"] = resolve_construction_family( + master.get("construction_method"), + master.get("construction_family"), + ) + item["construction_method"] = master.get("construction_method") or "" + item["note"] = master.get("note") or "" + else: + item["construction_family"] = "" + item["construction_method"] = "" + item["note"] = "" + self._send(200, {"items": items}) + return + + if parsed.path == "/api/vendors": + keyword = params.get("keyword", [""])[0].strip().lower() + clauses = ["coalesce(vendor_name, '') <> ''"] + values: list[str] = [] + if keyword: + clauses.append( + """ + ( + lower(coalesce(vendor_name, '')) like ? + or lower(coalesce(account_code_final, '')) like ? + or lower(coalesce(account_name_final, '')) like ? + ) + """ + ) + like = f"%{keyword}%" + values.extend([like, like, like]) + where = f"where {' and '.join(clauses)}" + + rows = conn.execute( + f""" + select + vendor_name, + count(*) as txn_count, + coalesce(sum(supply_amount), 0) as supply_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + {where} + group by vendor_name + order by supply_sum desc, vendor_name + """, + values, + ).fetchall() + self._send(200, {"items": rows_to_dicts(rows)}) + return + + if parsed.path == "/api/accounts": + keyword = params.get("keyword", [""])[0].strip().lower() + clauses = ["coalesce(account_code_final, '') <> ''"] + values: list[str] = [] + if keyword: + clauses.append( + """ + ( + lower(coalesce(account_code_final, '')) like ? + or lower(coalesce(account_name_final, '')) like ? + or lower(coalesce(vendor_name, '')) like ? + ) + """ + ) + like = f"%{keyword}%" + values.extend([like, like, like]) + where = f"where {' and '.join(clauses)}" + + rows = conn.execute( + f""" + select + account_code_final as account_code, + account_name_final as account_name, + count(*) as txn_count, + coalesce(sum(supply_amount), 0) as supply_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + {where} + group by account_code_final, account_name_final + order by cast(account_code_final as integer) asc, account_code_final asc + """, + values, + ).fetchall() + self._send(200, {"items": rows_to_dicts(rows)}) + return + + if parsed.path == "/api/account-detail": + account_code = params.get("account_code", [""])[0].strip() + project_code = params.get("project_code", [""])[0].strip() + if not account_code: + self._send(400, {"ok": False, "message": "account_code is required"}) + return + + detail_clauses = ["account_code_final = ?"] + detail_values: list[str] = [account_code] + if project_code: + detail_clauses.append("project_code = ?") + detail_values.append(project_code) + detail_where = " where " + " and ".join(detail_clauses) + + summary = conn.execute( + f""" + select + account_code_final as account_code, + account_name_final as account_name, + 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_supply_sum, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum, + coalesce(sum(supply_amount), 0) as supply_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + {detail_where} + group by account_code_final, account_name_final + """, + detail_values, + ).fetchone() + + project_rows = conn.execute( + """ + select + project_code, + max(project_name) as project_name, + 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_supply_sum, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum, + coalesce(sum(supply_amount), 0) as supply_sum + from ptc_transactions + where account_code_final = ? + group by project_code + order by supply_sum desc, project_code + limit 30 + """, + (account_code,), + ).fetchall() + + vendor_rows = conn.execute( + f""" + select + vendor_name, + 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_supply_sum, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum, + coalesce(sum(supply_amount), 0) as supply_sum + from ptc_transactions + {detail_where} + group by vendor_name + order by supply_sum desc, vendor_name + limit 30 + """, + detail_values, + ).fetchall() + + transaction_rows = conn.execute( + f""" + select + source_row_no, + transaction_date, + in_out, + project_code, + project_name, + vendor_name, + department_name, + description, + supply_amount + from ptc_transactions + {detail_where} + order by transaction_date desc, source_row_no desc + limit 100 + """, + detail_values, + ).fetchall() + + self._send( + 200, + { + "summary": dict(summary) if summary else None, + "projects": rows_to_dicts(project_rows), + "vendors": rows_to_dicts(vendor_rows), + "transactions": rows_to_dicts(transaction_rows), + }, + ) + return + + if parsed.path == "/api/vendor-detail": + vendor_name = params.get("vendor_name", [""])[0].strip() + project_code = params.get("project_code", [""])[0].strip() + account_code = params.get("account_code", [""])[0].strip() + if not vendor_name: + self._send(400, {"ok": False, "message": "vendor_name is required"}) + return + + detail_clauses = ["vendor_name = ?"] + detail_values: list[str] = [vendor_name] + if project_code: + detail_clauses.append("project_code = ?") + detail_values.append(project_code) + if account_code: + detail_clauses.append("account_code_final = ?") + detail_values.append(account_code) + detail_where = " where " + " and ".join(detail_clauses) + + summary = conn.execute( + """ + select + vendor_name, + 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_supply_sum, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum, + coalesce(sum(supply_amount), 0) as supply_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + where vendor_name = ? + group by vendor_name + """, + (vendor_name,), + ).fetchone() + + project_rows = conn.execute( + """ + select + project_code, + max(project_name) as project_name, + 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_supply_sum, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum, + coalesce(sum(supply_amount), 0) as supply_sum + from ptc_transactions + where vendor_name = ? + group by project_code + order by supply_sum desc, project_code + limit 20 + """, + (vendor_name,), + ).fetchall() + + account_rows = conn.execute( + f""" + select + account_code_final as account_code, + account_name_final as account_name, + 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_supply_sum, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply_sum, + coalesce(sum(supply_amount), 0) as supply_sum + from ptc_transactions + {detail_where} + group by account_code_final, account_name_final + order by supply_sum desc, account_code_final + limit 30 + """, + detail_values, + ).fetchall() + + transaction_rows = conn.execute( + f""" + select + source_row_no, + transaction_date, + in_out, + project_code, + project_name, + account_code_final as account_code, + account_name_final as account_name, + department_name, + description, + supply_amount + from ptc_transactions + {detail_where} + order by transaction_date desc, source_row_no desc + limit 100 + """, + detail_values, + ).fetchall() + + self._send( + 200, + { + "summary": dict(summary) if summary else None, + "projects": rows_to_dicts(project_rows), + "accounts": rows_to_dicts(account_rows), + "transactions": rows_to_dicts(transaction_rows), + }, + ) + return + + if parsed.path == "/api/project-detail": + project_code = params.get("project_code", [""])[0].strip() + if not project_code: + self._send(400, {"ok": False, "message": "project_code is required"}) + return + + keyword = params.get("keyword", [""])[0] + in_out = params.get("in_out", ["전체"])[0] + where, values = build_project_where(project_code, keyword, in_out) + + summary = conn.execute( + f""" + select + project_code, + max(project_name) as project_name, + max(project_type) as project_type, + 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(supply_amount), 0) as supply_sum, + coalesce(sum(vat_amount), 0) as vat_sum, + coalesce(sum(total_amount), 0) as total_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + {where} + """, + values, + ).fetchone() + + account_rows = conn.execute( + f""" + select + account_code_final as code, + account_name_final as name, + count(*) as count, + coalesce(sum(supply_amount), 0) as total + from ptc_transactions + {where} + group by account_code_final, account_name_final + order by total desc + limit 12 + """, + 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} + order by transaction_date desc, source_row_no desc + limit 20 + """, + values, + ).fetchall() + + summary_dict = dict(summary) if summary else None + master = fetch_project_master(conn, project_code) + if summary_dict and master: + summary_dict["project_name"] = master.get("project_name") or summary_dict["project_name"] + summary_dict["project_type"] = resolve_project_type( + project_code, + summary_dict["project_type"], + master.get("project_type"), + ) + summary_dict["construction_family"] = resolve_construction_family( + master.get("construction_method"), + master.get("construction_family"), + ) + summary_dict["construction_method"] = master.get("construction_method") or "" + summary_dict["note"] = master.get("note") or "" + elif summary_dict: + summary_dict["project_type"] = resolve_project_type(project_code, summary_dict["project_type"]) + summary_dict["construction_family"] = resolve_construction_family("") + summary_dict["construction_method"] = "" + summary_dict["note"] = "" + account_issues = get_project_account_issues( + conn, + project_code, + summary_dict["project_type"] if summary_dict else "", + ) + budget_analysis = build_budget_analysis(conn, project_code, build_account_structure_rows(account_rows)) + + self._send( + 200, + { + "summary": summary_dict, + "project_master": master, + "account_structure": build_account_structure_rows(account_rows), + "budget_analysis": budget_analysis, + "accounts": rows_to_dicts(account_rows), + "account_issues": account_issues, + "transactions": rows_to_dicts(transaction_rows), + }, + ) + return + + if parsed.path == "/api/project-account-issue-detail": + project_code = params.get("project_code", [""])[0].strip() + account_code = params.get("account_code", [""])[0].strip() + if not project_code or not account_code: + self._send(400, {"ok": False, "message": "project_code and account_code are required"}) + return + + rows = conn.execute( + """ + 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 = ? + order by transaction_date desc, source_row_no desc + limit 100 + """, + (project_code, account_code), + ).fetchall() + + summary = conn.execute( + """ + select + count(*) as txn_count, + coalesce(sum(supply_amount), 0) as supply_sum, + min(transaction_date) as min_date, + max(transaction_date) as max_date + from ptc_transactions + where project_code = ? + and account_code_final = ? + """, + (project_code, account_code), + ).fetchone() + + self._send( + 200, + { + "project_code": project_code, + "account_code": account_code, + "account_name": resolve_account_name(account_code, rows[0]["account_name"] if rows else ""), + "summary": dict(summary) if summary else None, + "items": rows_to_dicts(rows), + }, + ) + return + + if parsed.path == "/api/top-accounts": + where, values = build_where(params) + rows = conn.execute( + f""" + select + account_code_final as code, + account_name_final as name, + count(*) as count, + coalesce(sum(supply_amount), 0) as total + from ptc_transactions + {where} + group by account_code_final, account_name_final + order by total desc + limit 10 + """, + values, + ).fetchall() + self._send(200, {"items": rows_to_dicts(rows)}) + return + + if parsed.path == "/api/top-projects": + where, values = build_where(params) + rows = conn.execute( + f""" + select + coalesce(project_code, '(없음)') as project_code, + coalesce(project_name, '(없음)') as project_name, + coalesce(project_type, '(없음)') as project_type, + count(*) as count, + coalesce(sum(supply_amount), 0) as total + from ptc_transactions + {where} + group by project_code, project_name, project_type + order by total desc + limit 10 + """, + values, + ).fetchall() + self._send(200, {"items": rows_to_dicts(rows)}) + return + + if parsed.path == "/api/project-mismatches": + rows = conn.execute( + """ + select project_code, count(distinct project_name) as name_count, count(distinct project_type) as type_count + from ptc_transactions + where coalesce(project_code, '') <> '' + group by project_code + having count(distinct project_name) > 1 or count(distinct project_type) > 1 + order by project_code + limit 20 + """ + ).fetchall() + self._send(200, {"items": rows_to_dicts(rows)}) + return + + if parsed.path == "/api/transactions": + where, values = build_where(params) + limit = int(params.get("limit", ["30"])[0]) + 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, + project_code, + project_name, + project_type, + description, + supply_amount, + vat_amount, + total_amount + from ptc_transactions + {where} + order by source_row_no + limit ? + """, + values + [limit], + ).fetchall() + self._send(200, {"items": rows_to_dicts(rows)}) + return + + self._send(404, {"ok": False, "message": "Not found"}) + finally: + conn.close() + + +def main() -> None: + init_db() + server = ThreadingHTTPServer(("0.0.0.0", 4000), Handler) + print("PTC API server listening on http://0.0.0.0:4000") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/windows/README.txt b/windows/README.txt new file mode 100644 index 0000000..f4cd523 --- /dev/null +++ b/windows/README.txt @@ -0,0 +1,14 @@ +사용 파일 +- start_ptc_share.bat : 관리자 권한으로 실행되며, WSL 서버 시작 + portproxy + 방화벽까지 자동 설정 +- 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 실행 + +주의 +- PC가 켜져 있어야 합니다. +- WSL이 재시작되어 IP가 바뀌면 start_ptc_share.bat 를 다시 실행하세요. +- 관리자 권한이 필요합니다. diff --git a/windows/check_ptc_share.bat b/windows/check_ptc_share.bat new file mode 100644 index 0000000..1b4f67d --- /dev/null +++ b/windows/check_ptc_share.bat @@ -0,0 +1,20 @@ +@echo off +setlocal EnableExtensions + +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 [Office LAN web] +powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:8000/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 }" +echo. +pause + diff --git a/windows/start_ptc_share.bat b/windows/start_ptc_share.bat new file mode 100644 index 0000000..ff0719d --- /dev/null +++ b/windows/start_ptc_share.bat @@ -0,0 +1,70 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +set "PROJECT_DIR=/home/hyein/project" +set "WEB_PORT=8000" +set "API_PORT=4000" + +net session >nul 2>&1 +if not "%errorlevel%"=="0" ( + echo 관리자 권한으로 다시 실행합니다... + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b +) + +echo WSL IP 확인 중... +for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | cut -d' ' -f1"`) do ( + set "WSL_IP=%%i" +) + +if "%WSL_IP%"=="" ( + echo WSL IP를 확인하지 못했습니다. + pause + exit /b 1 +) + +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 +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 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 + +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 +) + +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 + diff --git a/windows/stop_ptc_share.bat b/windows/stop_ptc_share.bat new file mode 100644 index 0000000..0b3e234 --- /dev/null +++ b/windows/stop_ptc_share.bat @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +net session >nul 2>&1 +if not "%errorlevel%"=="0" ( + echo 관리자 권한으로 다시 실행합니다... + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b +) + +echo 포트포워딩 제거 중... +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 >nul 2>&1 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=4000 >nul 2>&1 + +echo 방화벽 규칙 제거 중... +netsh advfirewall firewall delete rule name="PTC 8000" >nul 2>&1 +netsh advfirewall firewall delete rule name="PTC 4000" >nul 2>&1 + +echo WSL 서버 종료 중... +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" + +echo 종료 완료 +pause +