Initial commit: Organized PTC project structure with .gitignore and README

This commit is contained in:
2026-03-23 14:44:39 +09:00
commit 35ababe236
21 changed files with 8921 additions and 0 deletions

160
.gitignore vendored Normal file
View File

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

BIN
PTC(2023-2026.02).xlsx Normal file

Binary file not shown.

3100
PTC/index.html Normal file

File diff suppressed because it is too large Load Diff

BIN
PTC공법.xlsx Normal file

Binary file not shown.

25
README.md Normal file
View File

@@ -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`를 참조하세요.

648
combine.html Normal file
View File

@@ -0,0 +1,648 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PTC 거래 원장 분석 페이지</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #132238;
--muted: #64748b;
--line: #dbe4ef;
--soft: #eef4fb;
--card: rgba(255,255,255,0.92);
--blue: #0f4c81;
--cyan: #118ab2;
--red: #c44536;
--gold: #b88917;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'IBM Plex Sans KR', sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(17,138,178,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(15,76,129,0.14), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, #eef3f8 100%);
}
.shell {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 60px;
}
.panel {
background: var(--card);
border: 1px solid rgba(219,228,239,0.95);
border-radius: 24px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(10px);
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,250,253,0.96));
}
.table-wrap {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--line);
background: white;
}
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th {
position: sticky;
top: 0;
background: #eff5fb;
color: #35506b;
font-size: 12px;
font-weight: 700;
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
td {
padding: 11px 14px;
border-bottom: 1px solid #edf2f7;
font-size: 13px;
vertical-align: top;
}
tr:hover td { background: #f9fbfd; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.badge-blue { background: #e6f3fb; color: var(--blue); }
.badge-red { background: #fdeceb; color: var(--red); }
.badge-gold { background: #fff6de; color: var(--gold); }
.badge-slate { background: #edf2f7; color: #475569; }
.pill {
border: 1px solid var(--line);
background: white;
border-radius: 999px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: #46627d;
}
.field {
width: 100%;
height: 42px;
border-radius: 12px;
border: 1px solid var(--line);
background: white;
padding: 0 12px;
font-size: 13px;
color: var(--ink);
outline: none;
}
.field:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(17,138,178,0.14); }
.upload {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 46px;
padding: 0 18px;
border-radius: 14px;
border: 1px solid rgba(17,138,178,0.18);
background: linear-gradient(135deg, #0f4c81, #118ab2);
color: white;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.subtle {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.hero-grid {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 20px;
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.split {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
}
@media (max-width: 1080px) {
.hero-grid, .split, .cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useEffect, useMemo, useRef, useState } = React;
const MASTER_PTC = {
'103':'보통예금','110':'받을어음','124':'매도가능증권','135':'매입부가세','178':'회원권','191':'출자금','192':'임차보증금','193':'주임종대여금','194':'전도금','195':'보증금','196':'대여금','206':'기계장치','208':'차량운반구','210':'공구와기구','212':'비품','219':'시설장치','231':'영업권','241':'사용수익기부자산','257':'가수금','258':'매출부가세','259':'선수금','260':'단기차입금','290':'주임종차입금','293':'장기차입금','294':'임대보증금','401':'공사수입','402':'용역수입','403':'기타수입','501':'관리 임금','502':'공무 임금','503':'시공 임금','504':'설계 임금','505':'지원 임금','511':'관리 퇴직금','512':'공무 퇴직금','513':'시공 퇴직금','514':'설계 퇴직금','515':'지원 퇴직금','521':'소득세','522':'주민세','523':'4대보험','524':'퇴직급여','711':'강관','712':'PHC','713':'결합구','714':'부자재','715':'주자재','721':'항타장비','722':'두부보강','723':'시험용역','724':'노무비','725':'외주비 등','726':'제작','727':'인장','728':'가설','729':'철근가공','730':'공장제작','731':'장비비','732':'유류비','733':'운반비','734':'주재비','735':'기타경비','736':'복리후생비','737':'여비교통비','738':'지급임차료','739':'보증수수료','740':'소모자재비','741':'잡자재대','742':'가스수도료','743':'수선비','744':'안전관리비(현장)','801':'감가상각비(자산)','811':'복리후생비','812':'여비교통비','813':'접대비','814':'통신비','817':'세금과공과금','819':'지급임차료','821':'보험료','822':'차량유지비','823':'연구개발비','825':'교육훈련비','826':'도서인쇄비','827':'광고선전비','829':'사무용품비','830':'소모품비','831':'지급수수료','843':'부서비','849':'지원서비스','850':'안전관리비(본사)','901':'이자수입','902':'국고보조금','903':'잡이익','904':'배당수익','961':'이자비용','962':'잡손실','963':'가지급금','999':'법인세등'
};
const SOURCE_HEADERS = ['거래일','입/출금','계정코드','구분','부서','거래처','프로젝트코드','프로젝트 구분(안)','프로젝트명','적요','공급가액','부가세','합계금액','비고'];
const numberFmt = (value) => new Intl.NumberFormat('ko-KR').format(Math.round(value || 0));
function excelDateToText(value) {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString().slice(0, 10);
if (typeof value === 'number') {
const d = new Date((value - 25569) * 86400 * 1000);
return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
}
const text = String(value).trim();
if (/^\d+$/.test(text)) {
const n = Number(text);
const d = new Date((n - 25569) * 86400 * 1000);
return isNaN(d.getTime()) ? text : d.toISOString().slice(0, 10);
}
return text.replace(/[./]/g, '-').slice(0, 10);
}
function toAmount(value) {
const text = String(value ?? '').trim();
if (!text || text === '-') return 0;
return parseFloat(text.replace(/,/g, '')) || 0;
}
function normalizeType(inOut, accountName) {
if (String(inOut).includes('입')) return 'revenue';
if (String(inOut).includes('출')) {
if (String(accountName).includes('수입') || String(accountName).includes('매출')) return 'revenue';
return 'cost_expense';
}
return 'unknown';
}
function parseWorkbook(file) {
return new Promise(async (resolve, reject) => {
try {
const arr = await file.arrayBuffer();
const wb = XLSX.read(arr, { type: 'array', cellDates: true });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { raw: true, defval: '' });
const items = rows.map((row, index) => {
const accountCode = String(row['계정코드'] || '').trim();
const accountName = String(row['구분'] || '').trim() || MASTER_PTC[accountCode] || '';
const supplyAmount = toAmount(row['공급가액']);
const vatAmount = toAmount(row['부가세']);
const totalAmount = toAmount(row['합계금액']);
return {
id: index + 1,
transactionDate: excelDateToText(row['거래일']),
inOut: String(row['입/출금'] || '').trim(),
accountCode,
accountName,
department: String(row['부서'] || '').trim(),
vendor: String(row['거래처'] || '').trim(),
projectCode: String(row['프로젝트코드'] || '').trim(),
projectType: String(row['프로젝트 구분(안)'] || '').trim(),
projectName: String(row['프로젝트명'] || '').trim(),
description: String(row['적요'] || '').trim(),
supplyAmount,
vatAmount,
totalAmount,
remarks: String(row['비고'] || '').trim(),
normalizedType: normalizeType(row['입/출금'], accountName)
};
});
resolve(items);
} catch (error) {
reject(error);
}
});
}
function App() {
const inputRef = useRef(null);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [projectTypeFilter, setProjectTypeFilter] = useState('전체');
const [inOutFilter, setInOutFilter] = useState('전체');
const loadFile = async (file) => {
if (!file) return;
setLoading(true);
try {
const items = await parseWorkbook(file);
setRows(items);
} catch (error) {
console.error(error);
window.alert('PTC 엑셀을 읽는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const tryAutoLoad = async () => {
try {
const res = await fetch('./PTC(2023-2026.02).xlsx');
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], 'PTC(2023-2026.02).xlsx');
await loadFile(file);
} catch (error) {
console.log('Auto load skipped');
}
};
useEffect(() => {
tryAutoLoad();
}, []);
const projectTypes = useMemo(() => (
['전체', ...Array.from(new Set(rows.map(item => item.projectType).filter(Boolean))).sort()]
), [rows]);
const filteredRows = useMemo(() => {
const q = keyword.trim().toLowerCase();
return rows.filter((item) => {
const matchKeyword = !q || [
item.accountCode,
item.accountName,
item.department,
item.vendor,
item.projectCode,
item.projectType,
item.projectName,
item.description
].some(value => String(value || '').toLowerCase().includes(q));
const matchType = projectTypeFilter === '전체' || item.projectType === projectTypeFilter;
const matchInOut = inOutFilter === '전체' || item.inOut === inOutFilter;
return matchKeyword && matchType && matchInOut;
});
}, [rows, keyword, projectTypeFilter, inOutFilter]);
const summary = useMemo(() => {
const result = {
count: filteredRows.length,
incomeCount: 0,
expenseCount: 0,
supplySum: 0,
vatSum: 0,
totalSum: 0,
minDate: '',
maxDate: ''
};
const dates = filteredRows.map(item => item.transactionDate).filter(Boolean).sort();
filteredRows.forEach((item) => {
if (item.inOut === '입금') result.incomeCount += 1;
if (item.inOut === '출금') result.expenseCount += 1;
result.supplySum += item.supplyAmount;
result.vatSum += item.vatAmount;
result.totalSum += item.totalAmount;
});
result.minDate = dates[0] || '';
result.maxDate = dates[dates.length - 1] || '';
return result;
}, [filteredRows]);
const topAccounts = useMemo(() => {
const map = new Map();
filteredRows.forEach((item) => {
const key = `${item.accountCode}__${item.accountName}`;
if (!map.has(key)) {
map.set(key, { code: item.accountCode, name: item.accountName, total: 0, count: 0 });
}
const current = map.get(key);
current.total += item.supplyAmount;
current.count += 1;
});
return Array.from(map.values())
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredRows]);
const topProjects = useMemo(() => {
const map = new Map();
filteredRows.forEach((item) => {
const key = `${item.projectCode}__${item.projectName}`;
if (!map.has(key)) {
map.set(key, {
projectCode: item.projectCode || '(없음)',
projectName: item.projectName || '(없음)',
projectType: item.projectType || '(없음)',
total: 0,
count: 0
});
}
const current = map.get(key);
current.total += item.supplyAmount;
current.count += 1;
});
return Array.from(map.values())
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredRows]);
const dataIssues = useMemo(() => {
const missingCritical = filteredRows.filter(item =>
!item.accountCode || !item.accountName || !item.transactionDate || !item.description
).length;
const inconsistentProjectNames = {};
const inconsistentProjectTypes = {};
filteredRows.forEach((item) => {
if (!item.projectCode) return;
inconsistentProjectNames[item.projectCode] = inconsistentProjectNames[item.projectCode] || new Set();
inconsistentProjectTypes[item.projectCode] = inconsistentProjectTypes[item.projectCode] || new Set();
if (item.projectName) inconsistentProjectNames[item.projectCode].add(item.projectName);
if (item.projectType) inconsistentProjectTypes[item.projectCode].add(item.projectType);
});
const projectNameMismatch = Object.entries(inconsistentProjectNames)
.filter(([, set]) => set.size > 1)
.map(([projectCode, set]) => ({ projectCode, values: Array.from(set) }))
.slice(0, 8);
const projectTypeMismatch = Object.entries(inconsistentProjectTypes)
.filter(([, set]) => set.size > 1)
.map(([projectCode, set]) => ({ projectCode, values: Array.from(set) }))
.slice(0, 8);
return {
missingCritical,
projectNameMismatch,
projectTypeMismatch
};
}, [filteredRows]);
return (
<div className="shell">
<section className="panel p-6 md:p-8">
<div className="hero-grid">
<div>
<div className="badge badge-blue mb-4">PTC Only View</div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight leading-tight mb-3">
PTC 거래 원장 중심으로 다시 구성한
<br />
실행 데이터 분석 페이지
</h1>
<p className="subtle max-w-2xl">
페이지는 `combine.html`에서 PTC 관련 흐름만 남긴 버전입니다.
현재 기준으로는 예산보다 실적 원장 확인에 초점을 맞추고,
`PTC(2023-2026.02).xlsx` 헤더 구조와 거래 패턴을 바로 있게 구성했습니다.
</p>
<div className="flex flex-wrap gap-2 mt-5">
<span className="pill">헤더 14 기준</span>
<span className="pill">입금/출금 분리</span>
<span className="pill">계정코드/프로젝트코드 검토</span>
<span className="pill">데이터 품질 확인</span>
</div>
</div>
<div className="panel p-5 md:p-6" style={{ background: 'linear-gradient(180deg, rgba(244,249,255,0.95), rgba(255,255,255,0.96))' }}>
<div className="text-sm font-semibold text-slate-600 mb-2">파일 상태</div>
<div className="text-2xl font-bold mb-3">{rows.length ? 'PTC 데이터 로드 완료' : '엑셀 파일 대기 중'}</div>
<div className="subtle mb-4">
자동 로드가 되지 않으면 아래 버튼으로 직접 엑셀을 선택하면 됩니다.
</div>
<div className="flex flex-wrap gap-3 items-center">
<button className="upload" onClick={() => inputRef.current?.click()}>
{loading ? '불러오는 중...' : 'PTC 엑셀 업로드'}
</button>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
style={{ display: 'none' }}
onChange={(e) => loadFile(e.target.files?.[0])}
/>
<div className="badge badge-slate">
{rows.length ? `${numberFmt(rows.length)}건 로드` : '미로드'}
</div>
</div>
<div className="mt-5 text-xs text-slate-500 leading-6">
대상 파일: <strong>PTC(2023-2026.02).xlsx</strong><br />
기준 컬럼: {SOURCE_HEADERS.join(' / ')}
</div>
</div>
</div>
</section>
<section className="cards mt-5">
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">조회 건수</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.count)}</div>
<div className="subtle mt-2">필터 적용 남은 </div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">기간</div>
<div className="text-xl font-bold mt-2">{summary.minDate || '-'} {summary.maxDate ? `~ ${summary.maxDate}` : ''}</div>
<div className="subtle mt-2">거래일 기준 범위</div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">공급가액 합계</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.supplySum)}</div>
<div className="subtle mt-2">현재 필터 기준 공급가액 총합</div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">입금 / 출금</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.incomeCount)} / {numberFmt(summary.expenseCount)}</div>
<div className="subtle mt-2"> 개수 기준</div>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex-1">
<div className="text-sm font-semibold mb-2">검색</div>
<input
className="field"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="계정코드, 계정명, 부서, 거래처, 프로젝트명, 적요로 검색"
/>
</div>
<div className="w-full md:w-52">
<div className="text-sm font-semibold mb-2">프로젝트 구분</div>
<select className="field" value={projectTypeFilter} onChange={(e) => setProjectTypeFilter(e.target.value)}>
{projectTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="w-full md:w-44">
<div className="text-sm font-semibold mb-2">입출금</div>
<select className="field" value={inOutFilter} onChange={(e) => setInOutFilter(e.target.value)}>
<option value="전체">전체</option>
<option value="입금">입금</option>
<option value="출금">출금</option>
</select>
</div>
</div>
</section>
<section className="split mt-5">
<div className="panel p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">계정코드 상위 집계</div>
<div className="subtle">공급가액 기준으로 PTC 계정 사용량을 우선 확인합니다.</div>
</div>
<span className="badge badge-blue">Top 10</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>계정코드</th>
<th>계정명</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topAccounts.map((item) => (
<tr key={`${item.code}-${item.name}`}>
<td>{item.code}</td>
<td>{item.name || MASTER_PTC[item.code] || '-'}</td>
<td>{numberFmt(item.count)}</td>
<td>{numberFmt(item.total)}</td>
</tr>
))}
{!topAccounts.length && (
<tr><td colSpan="4">데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div className="panel p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">데이터 품질 체크</div>
<div className="subtle">staging 적재 전에 먼저 봐야 하는 불일치 포인트입니다.</div>
</div>
<span className="badge badge-gold">검토 필요</span>
</div>
<div className="space-y-4">
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">핵심 누락값</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.missingCritical)}</div>
<div className="subtle mt-2">계정코드, 계정명, 거래일, 적요 일부가 비어 있는 </div>
</div>
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">프로젝트코드-프로젝트명 불일치</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.projectNameMismatch.length)} 코드</div>
<div className="subtle mt-2">같은 프로젝트코드에 이름이 여러 개인 경우</div>
</div>
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">프로젝트코드-프로젝트구분 불일치</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.projectTypeMismatch.length)} 코드</div>
<div className="subtle mt-2">같은 코드가 관리/시공/설계 등으로 섞이는 경우</div>
</div>
</div>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">프로젝트 상위 집계</div>
<div className="subtle">공급가액 기준으로 PTC 파일에서 많이 움직인 프로젝트를 먼저 봅니다.</div>
</div>
<span className="badge badge-red">Project Focus</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>구분</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topProjects.map((item) => (
<tr key={`${item.projectCode}-${item.projectName}`}>
<td>{item.projectCode}</td>
<td>{item.projectName}</td>
<td>{item.projectType}</td>
<td>{numberFmt(item.count)}</td>
<td>{numberFmt(item.total)}</td>
</tr>
))}
{!topProjects.length && (
<tr><td colSpan="5">데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">원본 미리보기</div>
<div className="subtle">PTC 원본에서 실제로 어떤 행이 들어오는지 바로 확인할 있습니다.</div>
</div>
<span className="badge badge-slate">Preview 30</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>거래일</th>
<th>/출금</th>
<th>계정코드</th>
<th>구분</th>
<th>부서</th>
<th>거래처</th>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>적요</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{filteredRows.slice(0, 30).map((item) => (
<tr key={item.id}>
<td>{item.transactionDate || '-'}</td>
<td>{item.inOut || '-'}</td>
<td>{item.accountCode || '-'}</td>
<td>{item.accountName || '-'}</td>
<td>{item.department || '-'}</td>
<td>{item.vendor || '-'}</td>
<td>{item.projectCode || '-'}</td>
<td>{item.projectName || '-'}</td>
<td>{item.description || '-'}</td>
<td>{numberFmt(item.supplyAmount)}</td>
</tr>
))}
{!filteredRows.length && (
<tr><td colSpan="10">표시할 데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

324
db/README.md Normal file
View File

@@ -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 확인 방법"으로 이해하면 맞습니다
- 실제 엑셀 적재 절차는 다음 단계에서 추가 구현이 필요합니다

208
db/import_ptc_xlsx.py Normal file
View File

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

View File

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

50
db/sample_queries.sql Normal file
View File

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

421
db/schema.sql Normal file
View File

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

584
db/seed.sql Normal file
View File

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

54
db/staging_queries.sql Normal file
View File

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

25
docker-compose.yml Normal file
View File

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

437
main_page.html Normal file
View File

@@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Profit Main</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #0f1c2e;
--muted: #66788f;
--line: #d8e2ec;
--blue: #113f67;
--cyan: #1f7a8c;
--mist: #eff5fb;
--soft: rgba(255,255,255,0.88);
--warn: #b85c38;
--good: #1b7f5a;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'IBM Plex Sans KR', sans-serif;
color: var(--ink);
background:
linear-gradient(125deg, rgba(17,63,103,0.10), transparent 30%),
radial-gradient(circle at 85% 10%, rgba(31,122,140,0.12), transparent 22%),
linear-gradient(180deg, #f8fbff 0%, #edf2f7 100%);
}
.page { width: min(1460px, calc(100vw - 28px)); margin: 0 auto; padding: 26px 0 60px; }
.panel {
background: var(--soft);
border: 1px solid rgba(216,226,236,0.95);
border-radius: 26px;
box-shadow: 0 18px 42px rgba(15, 28, 46, 0.07);
backdrop-filter: blur(10px);
}
.hero {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 18px;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.two-col {
display: grid;
grid-template-columns: 1.08fr 0.92fr;
gap: 18px;
}
.three-col {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
gap: 18px;
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.97), rgba(244,248,252,0.98));
padding: 18px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: #e6f1fb;
color: var(--blue);
font-size: 11px;
font-weight: 700;
}
.subtle { color: var(--muted); font-size: 12px; line-height: 1.65; }
.field, .select {
width: 100%;
height: 42px;
border: 1px solid var(--line);
border-radius: 12px;
background: white;
padding: 0 12px;
font-size: 13px;
color: var(--ink);
outline: none;
}
.field:focus, .select:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(31,122,140,0.12); }
.toolbar {
display: grid;
grid-template-columns: 1.2fr 220px 180px;
gap: 12px;
}
.card-title { font-size: 18px; font-weight: 700; margin: 0 0 6px; }
.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 18px; background: white; }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--mist);
color: #38506b;
text-align: left;
padding: 12px 14px;
font-size: 12px;
font-weight: 700;
border-bottom: 1px solid var(--line);
}
td {
padding: 11px 14px;
font-size: 13px;
border-bottom: 1px solid #edf2f7;
vertical-align: top;
}
tr:hover td { background: #f9fbfd; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
font-weight: 700;
}
.badge-good { background: #e8f8f0; color: var(--good); }
.badge-warn { background: #fff0e8; color: var(--warn); }
.badge-blue { background: #eaf3fb; color: var(--blue); }
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px 14px;
background: white;
}
@media (max-width: 1100px) {
.hero, .two-col, .three-col, .kpis, .toolbar { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useEffect, useMemo, useState } = React;
const API_BASE = "http://localhost:4000";
const fmt = (value) => new Intl.NumberFormat("ko-KR").format(Math.round(value || 0));
function App() {
const [keyword, setKeyword] = useState("");
const [projectType, setProjectType] = useState("전체");
const [inOut, setInOut] = useState("전체");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [summary, setSummary] = useState(null);
const [projectTypes, setProjectTypes] = useState([]);
const [topAccounts, setTopAccounts] = useState([]);
const [topProjects, setTopProjects] = useState([]);
const [mismatches, setMismatches] = useState([]);
const [transactions, setTransactions] = useState([]);
const query = useMemo(() => {
const params = new URLSearchParams();
if (keyword.trim()) params.set("keyword", keyword.trim());
if (projectType) params.set("project_type", projectType);
if (inOut) params.set("in_out", inOut);
return params.toString();
}, [keyword, projectType, inOut]);
useEffect(() => {
let ignore = false;
async function load() {
setLoading(true);
setError("");
try {
const [healthRes, summaryRes, typeRes, accountRes, projectRes, mismatchRes, txRes] = await Promise.all([
fetch(`${API_BASE}/api/health`),
fetch(`${API_BASE}/api/summary?${query}`),
fetch(`${API_BASE}/api/project-types`),
fetch(`${API_BASE}/api/top-accounts?${query}`),
fetch(`${API_BASE}/api/top-projects?${query}`),
fetch(`${API_BASE}/api/project-mismatches`),
fetch(`${API_BASE}/api/transactions?${query}&limit=30`)
]);
if (!healthRes.ok) throw new Error("API health check failed");
const health = await healthRes.json();
if (!health.ok) throw new Error("API server not ready");
const [summaryData, typeData, accountData, projectData, mismatchData, txData] = await Promise.all([
summaryRes.json(), typeRes.json(), accountRes.json(), projectRes.json(), mismatchRes.json(), txRes.json()
]);
if (ignore) return;
setSummary(summaryData);
setProjectTypes(typeData.items || []);
setTopAccounts(accountData.items || []);
setTopProjects(projectData.items || []);
setMismatches(mismatchData.items || []);
setTransactions(txData.items || []);
} catch (err) {
if (!ignore) setError("PTC 데이터 서버에 연결하지 못했습니다. API 서버를 먼저 실행해 주세요.");
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => { ignore = true; };
}, [query]);
return (
<div className="page">
<section className="panel" style={{ padding: 28 }}>
<div className="hero">
<div>
<div className="eyebrow">Project Profit Main</div>
<h1 style={{ fontSize: 38, lineHeight: 1.2, margin: "16px 0 12px", fontWeight: 700 }}>
PTC 실행 데이터 기반
<br />
메인 대시보드
</h1>
<p className="subtle" style={{ maxWidth: 760 }}>
`jhpps.hmac.kr`처럼 로그인 게이트 구조를 가정하되, 지금은 인증 없이 메인 페이지만 먼저 보이도록 구성했습니다.
별도 데이터 서버에서 PTC 원장을 읽고, 프론트는 메인 대시보드 역할만 하도록 분리했습니다.
</p>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 18 }}>
<span className="badge badge-blue">Main Only</span>
<span className="badge badge-blue">PTC Source</span>
<span className="badge badge-blue">Separate API Server</span>
</div>
</div>
<div className="metric">
<div className="card-title">연결 상태</div>
<div className="subtle" style={{ marginBottom: 14 }}>
메인 페이지는 `localhost:8000`, 데이터 서버는 `localhost:4000` 사용합니다.
</div>
{loading && <div className="badge badge-blue">불러오는 </div>}
{!loading && !error && <div className="badge badge-good">API 연결 정상</div>}
{error && <div className="badge badge-warn">{error}</div>}
<div className="subtle" style={{ marginTop: 14 }}>
원본 파일: `PTC(2023-2026.02).xlsx`
</div>
</div>
</div>
</section>
<section className="panel" style={{ padding: 22, marginTop: 18 }}>
<div className="toolbar">
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 8 }}>검색</div>
<input className="field" value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="계정코드, 계정명, 부서, 거래처, 프로젝트명, 적요 검색" />
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 8 }}>프로젝트 구분</div>
<select className="select" value={projectType} onChange={(e) => setProjectType(e.target.value)}>
<option value="전체">전체</option>
{projectTypes.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 8 }}>입출금</div>
<select className="select" value={inOut} onChange={(e) => setInOut(e.target.value)}>
<option value="전체">전체</option>
<option value="입금">입금</option>
<option value="출금">출금</option>
</select>
</div>
</div>
</section>
<section className="kpis" style={{ marginTop: 18 }}>
<div className="metric">
<div className="subtle">조회 건수</div>
<div style={{ fontSize: 34, fontWeight: 700, marginTop: 8 }}>{fmt(summary?.count || 0)}</div>
</div>
<div className="metric">
<div className="subtle">기간</div>
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 8 }}>{summary?.min_date || "-"} {summary?.max_date ? `~ ${summary?.max_date}` : ""}</div>
</div>
<div className="metric">
<div className="subtle">공급가액 합계</div>
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 8 }}>{fmt(summary?.supply_sum || 0)}</div>
</div>
<div className="metric">
<div className="subtle">핵심 누락값</div>
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 8 }}>{fmt(summary?.missing_critical || 0)}</div>
</div>
</section>
<section className="two-col" style={{ marginTop: 18 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="card-title">계정코드 상위 집계</div>
<div className="subtle" style={{ marginBottom: 14 }}>공급가액 기준 상위 계정입니다.</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>계정코드</th>
<th>계정명</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topAccounts.map((item) => (
<tr key={`${item.code}-${item.name}`}>
<td>{item.code}</td>
<td>{item.name}</td>
<td>{fmt(item.count)}</td>
<td>{fmt(item.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="panel" style={{ padding: 22 }}>
<div className="card-title">프로젝트 불일치 체크</div>
<div className="subtle" style={{ marginBottom: 14 }}>같은 프로젝트코드에 이름/구분이 여러 개인 경우입니다.</div>
<div className="list">
{mismatches.slice(0, 8).map((item) => (
<div className="list-item" key={item.project_code}>
<div style={{ fontWeight: 700 }}>{item.project_code}</div>
<div className="subtle" style={{ marginTop: 6 }}>
프로젝트명 {item.name_count}, 프로젝트구분 {item.type_count}
</div>
</div>
))}
</div>
</div>
</section>
<section className="three-col" style={{ marginTop: 18 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="card-title">입금 / 출금</div>
<div className="subtle" style={{ marginBottom: 12 }}>거래 방향별 건수</div>
<div className="metric">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}>
<span>입금</span>
<strong>{fmt(summary?.income_count || 0)}</strong>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>출금</span>
<strong>{fmt(summary?.expense_count || 0)}</strong>
</div>
</div>
</div>
<div className="panel" style={{ padding: 22 }}>
<div className="card-title">부가세 합계</div>
<div className="subtle" style={{ marginBottom: 12 }}>현재 필터 기준</div>
<div className="metric">
<div style={{ fontSize: 30, fontWeight: 700 }}>{fmt(summary?.vat_sum || 0)}</div>
</div>
</div>
<div className="panel" style={{ padding: 22 }}>
<div className="card-title">합계금액</div>
<div className="subtle" style={{ marginBottom: 12 }}>현재 필터 기준</div>
<div className="metric">
<div style={{ fontSize: 30, fontWeight: 700 }}>{fmt(summary?.total_sum || 0)}</div>
</div>
</div>
</section>
<section className="panel" style={{ padding: 22, marginTop: 18 }}>
<div className="card-title">프로젝트 상위 집계</div>
<div className="subtle" style={{ marginBottom: 14 }}>공급가액 기준 상위 프로젝트입니다.</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>구분</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topProjects.map((item) => (
<tr key={`${item.project_code}-${item.project_name}`}>
<td>{item.project_code}</td>
<td>{item.project_name}</td>
<td>{item.project_type}</td>
<td>{fmt(item.count)}</td>
<td>{fmt(item.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="panel" style={{ padding: 22, marginTop: 18 }}>
<div className="card-title">원본 거래 미리보기</div>
<div className="subtle" style={{ marginBottom: 14 }}>메인 페이지에서는 최근 구조 확인을 위해 30건만 노출합니다.</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>거래일</th>
<th>/출금</th>
<th>계정코드</th>
<th>계정명</th>
<th>부서</th>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>적요</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{transactions.map((item) => (
<tr key={item.source_row_no}>
<td>{item.transaction_date}</td>
<td>{item.in_out}</td>
<td>{item.account_code}</td>
<td>{item.account_name}</td>
<td>{item.department_name}</td>
<td>{item.project_code || "-"}</td>
<td>{item.project_name || "-"}</td>
<td>{item.description || "-"}</td>
<td>{fmt(item.supply_amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>

648
ptc_page.html Normal file
View File

@@ -0,0 +1,648 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PTC 거래 원장 분석 페이지</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #132238;
--muted: #64748b;
--line: #dbe4ef;
--soft: #eef4fb;
--card: rgba(255,255,255,0.92);
--blue: #0f4c81;
--cyan: #118ab2;
--red: #c44536;
--gold: #b88917;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'IBM Plex Sans KR', sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(17,138,178,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(15,76,129,0.14), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, #eef3f8 100%);
}
.shell {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 60px;
}
.panel {
background: var(--card);
border: 1px solid rgba(219,228,239,0.95);
border-radius: 24px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(10px);
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,250,253,0.96));
}
.table-wrap {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--line);
background: white;
}
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th {
position: sticky;
top: 0;
background: #eff5fb;
color: #35506b;
font-size: 12px;
font-weight: 700;
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
td {
padding: 11px 14px;
border-bottom: 1px solid #edf2f7;
font-size: 13px;
vertical-align: top;
}
tr:hover td { background: #f9fbfd; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.badge-blue { background: #e6f3fb; color: var(--blue); }
.badge-red { background: #fdeceb; color: var(--red); }
.badge-gold { background: #fff6de; color: var(--gold); }
.badge-slate { background: #edf2f7; color: #475569; }
.pill {
border: 1px solid var(--line);
background: white;
border-radius: 999px;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: #46627d;
}
.field {
width: 100%;
height: 42px;
border-radius: 12px;
border: 1px solid var(--line);
background: white;
padding: 0 12px;
font-size: 13px;
color: var(--ink);
outline: none;
}
.field:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(17,138,178,0.14); }
.upload {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 46px;
padding: 0 18px;
border-radius: 14px;
border: 1px solid rgba(17,138,178,0.18);
background: linear-gradient(135deg, #0f4c81, #118ab2);
color: white;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.subtle {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.hero-grid {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 20px;
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.split {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
}
@media (max-width: 1080px) {
.hero-grid, .split, .cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useEffect, useMemo, useRef, useState } = React;
const MASTER_PTC = {
'103':'보통예금','110':'받을어음','124':'매도가능증권','135':'매입부가세','178':'회원권','191':'출자금','192':'임차보증금','193':'주임종대여금','194':'전도금','195':'보증금','196':'대여금','206':'기계장치','208':'차량운반구','210':'공구와기구','212':'비품','219':'시설장치','231':'영업권','241':'사용수익기부자산','257':'가수금','258':'매출부가세','259':'선수금','260':'단기차입금','290':'주임종차입금','293':'장기차입금','294':'임대보증금','401':'공사수입','402':'용역수입','403':'기타수입','501':'관리 임금','502':'공무 임금','503':'시공 임금','504':'설계 임금','505':'지원 임금','511':'관리 퇴직금','512':'공무 퇴직금','513':'시공 퇴직금','514':'설계 퇴직금','515':'지원 퇴직금','521':'소득세','522':'주민세','523':'4대보험','524':'퇴직급여','711':'강관','712':'PHC','713':'결합구','714':'부자재','715':'주자재','721':'항타장비','722':'두부보강','723':'시험용역','724':'노무비','725':'외주비 등','726':'제작','727':'인장','728':'가설','729':'철근가공','730':'공장제작','731':'장비비','732':'유류비','733':'운반비','734':'주재비','735':'기타경비','736':'복리후생비','737':'여비교통비','738':'지급임차료','739':'보증수수료','740':'소모자재비','741':'잡자재대','742':'가스수도료','743':'수선비','744':'안전관리비(현장)','801':'감가상각비(자산)','811':'복리후생비','812':'여비교통비','813':'접대비','814':'통신비','817':'세금과공과금','819':'지급임차료','821':'보험료','822':'차량유지비','823':'연구개발비','825':'교육훈련비','826':'도서인쇄비','827':'광고선전비','829':'사무용품비','830':'소모품비','831':'지급수수료','843':'부서비','849':'지원서비스','850':'안전관리비(본사)','901':'이자수입','902':'국고보조금','903':'잡이익','904':'배당수익','961':'이자비용','962':'잡손실','963':'가지급금','999':'법인세등'
};
const SOURCE_HEADERS = ['거래일','입/출금','계정코드','구분','부서','거래처','프로젝트코드','프로젝트 구분(안)','프로젝트명','적요','공급가액','부가세','합계금액','비고'];
const numberFmt = (value) => new Intl.NumberFormat('ko-KR').format(Math.round(value || 0));
function excelDateToText(value) {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString().slice(0, 10);
if (typeof value === 'number') {
const d = new Date((value - 25569) * 86400 * 1000);
return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
}
const text = String(value).trim();
if (/^\d+$/.test(text)) {
const n = Number(text);
const d = new Date((n - 25569) * 86400 * 1000);
return isNaN(d.getTime()) ? text : d.toISOString().slice(0, 10);
}
return text.replace(/[./]/g, '-').slice(0, 10);
}
function toAmount(value) {
const text = String(value ?? '').trim();
if (!text || text === '-') return 0;
return parseFloat(text.replace(/,/g, '')) || 0;
}
function normalizeType(inOut, accountName) {
if (String(inOut).includes('입')) return 'revenue';
if (String(inOut).includes('출')) {
if (String(accountName).includes('수입') || String(accountName).includes('매출')) return 'revenue';
return 'cost_expense';
}
return 'unknown';
}
function parseWorkbook(file) {
return new Promise(async (resolve, reject) => {
try {
const arr = await file.arrayBuffer();
const wb = XLSX.read(arr, { type: 'array', cellDates: true });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { raw: true, defval: '' });
const items = rows.map((row, index) => {
const accountCode = String(row['계정코드'] || '').trim();
const accountName = String(row['구분'] || '').trim() || MASTER_PTC[accountCode] || '';
const supplyAmount = toAmount(row['공급가액']);
const vatAmount = toAmount(row['부가세']);
const totalAmount = toAmount(row['합계금액']);
return {
id: index + 1,
transactionDate: excelDateToText(row['거래일']),
inOut: String(row['입/출금'] || '').trim(),
accountCode,
accountName,
department: String(row['부서'] || '').trim(),
vendor: String(row['거래처'] || '').trim(),
projectCode: String(row['프로젝트코드'] || '').trim(),
projectType: String(row['프로젝트 구분(안)'] || '').trim(),
projectName: String(row['프로젝트명'] || '').trim(),
description: String(row['적요'] || '').trim(),
supplyAmount,
vatAmount,
totalAmount,
remarks: String(row['비고'] || '').trim(),
normalizedType: normalizeType(row['입/출금'], accountName)
};
});
resolve(items);
} catch (error) {
reject(error);
}
});
}
function App() {
const inputRef = useRef(null);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [projectTypeFilter, setProjectTypeFilter] = useState('전체');
const [inOutFilter, setInOutFilter] = useState('전체');
const loadFile = async (file) => {
if (!file) return;
setLoading(true);
try {
const items = await parseWorkbook(file);
setRows(items);
} catch (error) {
console.error(error);
window.alert('PTC 엑셀을 읽는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const tryAutoLoad = async () => {
try {
const res = await fetch('./PTC(2023-2026.02).xlsx');
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], 'PTC(2023-2026.02).xlsx');
await loadFile(file);
} catch (error) {
console.log('Auto load skipped');
}
};
useEffect(() => {
tryAutoLoad();
}, []);
const projectTypes = useMemo(() => (
['전체', ...Array.from(new Set(rows.map(item => item.projectType).filter(Boolean))).sort()]
), [rows]);
const filteredRows = useMemo(() => {
const q = keyword.trim().toLowerCase();
return rows.filter((item) => {
const matchKeyword = !q || [
item.accountCode,
item.accountName,
item.department,
item.vendor,
item.projectCode,
item.projectType,
item.projectName,
item.description
].some(value => String(value || '').toLowerCase().includes(q));
const matchType = projectTypeFilter === '전체' || item.projectType === projectTypeFilter;
const matchInOut = inOutFilter === '전체' || item.inOut === inOutFilter;
return matchKeyword && matchType && matchInOut;
});
}, [rows, keyword, projectTypeFilter, inOutFilter]);
const summary = useMemo(() => {
const result = {
count: filteredRows.length,
incomeCount: 0,
expenseCount: 0,
supplySum: 0,
vatSum: 0,
totalSum: 0,
minDate: '',
maxDate: ''
};
const dates = filteredRows.map(item => item.transactionDate).filter(Boolean).sort();
filteredRows.forEach((item) => {
if (item.inOut === '입금') result.incomeCount += 1;
if (item.inOut === '출금') result.expenseCount += 1;
result.supplySum += item.supplyAmount;
result.vatSum += item.vatAmount;
result.totalSum += item.totalAmount;
});
result.minDate = dates[0] || '';
result.maxDate = dates[dates.length - 1] || '';
return result;
}, [filteredRows]);
const topAccounts = useMemo(() => {
const map = new Map();
filteredRows.forEach((item) => {
const key = `${item.accountCode}__${item.accountName}`;
if (!map.has(key)) {
map.set(key, { code: item.accountCode, name: item.accountName, total: 0, count: 0 });
}
const current = map.get(key);
current.total += item.supplyAmount;
current.count += 1;
});
return Array.from(map.values())
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredRows]);
const topProjects = useMemo(() => {
const map = new Map();
filteredRows.forEach((item) => {
const key = `${item.projectCode}__${item.projectName}`;
if (!map.has(key)) {
map.set(key, {
projectCode: item.projectCode || '(없음)',
projectName: item.projectName || '(없음)',
projectType: item.projectType || '(없음)',
total: 0,
count: 0
});
}
const current = map.get(key);
current.total += item.supplyAmount;
current.count += 1;
});
return Array.from(map.values())
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredRows]);
const dataIssues = useMemo(() => {
const missingCritical = filteredRows.filter(item =>
!item.accountCode || !item.accountName || !item.transactionDate || !item.description
).length;
const inconsistentProjectNames = {};
const inconsistentProjectTypes = {};
filteredRows.forEach((item) => {
if (!item.projectCode) return;
inconsistentProjectNames[item.projectCode] = inconsistentProjectNames[item.projectCode] || new Set();
inconsistentProjectTypes[item.projectCode] = inconsistentProjectTypes[item.projectCode] || new Set();
if (item.projectName) inconsistentProjectNames[item.projectCode].add(item.projectName);
if (item.projectType) inconsistentProjectTypes[item.projectCode].add(item.projectType);
});
const projectNameMismatch = Object.entries(inconsistentProjectNames)
.filter(([, set]) => set.size > 1)
.map(([projectCode, set]) => ({ projectCode, values: Array.from(set) }))
.slice(0, 8);
const projectTypeMismatch = Object.entries(inconsistentProjectTypes)
.filter(([, set]) => set.size > 1)
.map(([projectCode, set]) => ({ projectCode, values: Array.from(set) }))
.slice(0, 8);
return {
missingCritical,
projectNameMismatch,
projectTypeMismatch
};
}, [filteredRows]);
return (
<div className="shell">
<section className="panel p-6 md:p-8">
<div className="hero-grid">
<div>
<div className="badge badge-blue mb-4">PTC Only View</div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight leading-tight mb-3">
PTC 거래 원장 중심으로 다시 구성한
<br />
실행 데이터 분석 페이지
</h1>
<p className="subtle max-w-2xl">
페이지는 `combine.html`에서 PTC 관련 흐름만 남긴 버전입니다.
현재 기준으로는 예산보다 실적 원장 확인에 초점을 맞추고,
`PTC(2023-2026.02).xlsx` 헤더 구조와 거래 패턴을 바로 있게 구성했습니다.
</p>
<div className="flex flex-wrap gap-2 mt-5">
<span className="pill">헤더 14 기준</span>
<span className="pill">입금/출금 분리</span>
<span className="pill">계정코드/프로젝트코드 검토</span>
<span className="pill">데이터 품질 확인</span>
</div>
</div>
<div className="panel p-5 md:p-6" style={{ background: 'linear-gradient(180deg, rgba(244,249,255,0.95), rgba(255,255,255,0.96))' }}>
<div className="text-sm font-semibold text-slate-600 mb-2">파일 상태</div>
<div className="text-2xl font-bold mb-3">{rows.length ? 'PTC 데이터 로드 완료' : '엑셀 파일 대기 중'}</div>
<div className="subtle mb-4">
자동 로드가 되지 않으면 아래 버튼으로 직접 엑셀을 선택하면 됩니다.
</div>
<div className="flex flex-wrap gap-3 items-center">
<button className="upload" onClick={() => inputRef.current?.click()}>
{loading ? '불러오는 중...' : 'PTC 엑셀 업로드'}
</button>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
style={{ display: 'none' }}
onChange={(e) => loadFile(e.target.files?.[0])}
/>
<div className="badge badge-slate">
{rows.length ? `${numberFmt(rows.length)}건 로드` : '미로드'}
</div>
</div>
<div className="mt-5 text-xs text-slate-500 leading-6">
대상 파일: <strong>PTC(2023-2026.02).xlsx</strong><br />
기준 컬럼: {SOURCE_HEADERS.join(' / ')}
</div>
</div>
</div>
</section>
<section className="cards mt-5">
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">조회 건수</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.count)}</div>
<div className="subtle mt-2">필터 적용 남은 </div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">기간</div>
<div className="text-xl font-bold mt-2">{summary.minDate || '-'} {summary.maxDate ? `~ ${summary.maxDate}` : ''}</div>
<div className="subtle mt-2">거래일 기준 범위</div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">공급가액 합계</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.supplySum)}</div>
<div className="subtle mt-2">현재 필터 기준 공급가액 총합</div>
</div>
<div className="metric p-5">
<div className="text-xs text-slate-500 font-semibold">입금 / 출금</div>
<div className="text-3xl font-bold mt-2">{numberFmt(summary.incomeCount)} / {numberFmt(summary.expenseCount)}</div>
<div className="subtle mt-2"> 개수 기준</div>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex-1">
<div className="text-sm font-semibold mb-2">검색</div>
<input
className="field"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="계정코드, 계정명, 부서, 거래처, 프로젝트명, 적요로 검색"
/>
</div>
<div className="w-full md:w-52">
<div className="text-sm font-semibold mb-2">프로젝트 구분</div>
<select className="field" value={projectTypeFilter} onChange={(e) => setProjectTypeFilter(e.target.value)}>
{projectTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="w-full md:w-44">
<div className="text-sm font-semibold mb-2">입출금</div>
<select className="field" value={inOutFilter} onChange={(e) => setInOutFilter(e.target.value)}>
<option value="전체">전체</option>
<option value="입금">입금</option>
<option value="출금">출금</option>
</select>
</div>
</div>
</section>
<section className="split mt-5">
<div className="panel p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">계정코드 상위 집계</div>
<div className="subtle">공급가액 기준으로 PTC 계정 사용량을 우선 확인합니다.</div>
</div>
<span className="badge badge-blue">Top 10</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>계정코드</th>
<th>계정명</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topAccounts.map((item) => (
<tr key={`${item.code}-${item.name}`}>
<td>{item.code}</td>
<td>{item.name || MASTER_PTC[item.code] || '-'}</td>
<td>{numberFmt(item.count)}</td>
<td>{numberFmt(item.total)}</td>
</tr>
))}
{!topAccounts.length && (
<tr><td colSpan="4">데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div className="panel p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">데이터 품질 체크</div>
<div className="subtle">staging 적재 전에 먼저 봐야 하는 불일치 포인트입니다.</div>
</div>
<span className="badge badge-gold">검토 필요</span>
</div>
<div className="space-y-4">
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">핵심 누락값</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.missingCritical)}</div>
<div className="subtle mt-2">계정코드, 계정명, 거래일, 적요 일부가 비어 있는 </div>
</div>
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">프로젝트코드-프로젝트명 불일치</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.projectNameMismatch.length)} 코드</div>
<div className="subtle mt-2">같은 프로젝트코드에 이름이 여러 개인 경우</div>
</div>
<div className="metric p-4">
<div className="text-xs text-slate-500 font-semibold">프로젝트코드-프로젝트구분 불일치</div>
<div className="text-2xl font-bold mt-1">{numberFmt(dataIssues.projectTypeMismatch.length)} 코드</div>
<div className="subtle mt-2">같은 코드가 관리/시공/설계 등으로 섞이는 경우</div>
</div>
</div>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">프로젝트 상위 집계</div>
<div className="subtle">공급가액 기준으로 PTC 파일에서 많이 움직인 프로젝트를 먼저 봅니다.</div>
</div>
<span className="badge badge-red">Project Focus</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>구분</th>
<th>건수</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{topProjects.map((item) => (
<tr key={`${item.projectCode}-${item.projectName}`}>
<td>{item.projectCode}</td>
<td>{item.projectName}</td>
<td>{item.projectType}</td>
<td>{numberFmt(item.count)}</td>
<td>{numberFmt(item.total)}</td>
</tr>
))}
{!topProjects.length && (
<tr><td colSpan="5">데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<section className="panel p-5 md:p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-lg font-bold">원본 미리보기</div>
<div className="subtle">PTC 원본에서 실제로 어떤 행이 들어오는지 바로 확인할 있습니다.</div>
</div>
<span className="badge badge-slate">Preview 30</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>거래일</th>
<th>/출금</th>
<th>계정코드</th>
<th>구분</th>
<th>부서</th>
<th>거래처</th>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>적요</th>
<th>공급가액</th>
</tr>
</thead>
<tbody>
{filteredRows.slice(0, 30).map((item) => (
<tr key={item.id}>
<td>{item.transactionDate || '-'}</td>
<td>{item.inOut || '-'}</td>
<td>{item.accountCode || '-'}</td>
<td>{item.accountName || '-'}</td>
<td>{item.department || '-'}</td>
<td>{item.vendor || '-'}</td>
<td>{item.projectCode || '-'}</td>
<td>{item.projectName || '-'}</td>
<td>{item.description || '-'}</td>
<td>{numberFmt(item.supplyAmount)}</td>
</tr>
))}
{!filteredRows.length && (
<tr><td colSpan="10">표시할 데이터가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

2070
server/ptc_api_server.py Normal file

File diff suppressed because it is too large Load Diff

14
windows/README.txt Normal file
View File

@@ -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 를 다시 실행하세요.
- 관리자 권한이 필요합니다.

View File

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

View File

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

View File

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