Initial commit: Organized PTC project structure with .gitignore and README
This commit is contained in:
160
.gitignore
vendored
Normal file
160
.gitignore
vendored
Normal 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
BIN
PTC(2023-2026.02).xlsx
Normal file
Binary file not shown.
3100
PTC/index.html
Normal file
3100
PTC/index.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
PTC공법.xlsx
Normal file
BIN
PTC공법.xlsx
Normal file
Binary file not shown.
25
README.md
Normal file
25
README.md
Normal 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
648
combine.html
Normal 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
324
db/README.md
Normal 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
208
db/import_ptc_xlsx.py
Normal 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()
|
||||
39
db/migrations/20260323_add_staging_ptc.sql
Normal file
39
db/migrations/20260323_add_staging_ptc.sql
Normal 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
50
db/sample_queries.sql
Normal 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
421
db/schema.sql
Normal 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
584
db/seed.sql
Normal 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
54
db/staging_queries.sql
Normal 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
25
docker-compose.yml
Normal 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
437
main_page.html
Normal 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
648
ptc_page.html
Normal 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
2070
server/ptc_api_server.py
Normal file
File diff suppressed because it is too large
Load Diff
14
windows/README.txt
Normal file
14
windows/README.txt
Normal 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 를 다시 실행하세요.
|
||||
- 관리자 권한이 필요합니다.
|
||||
20
windows/check_ptc_share.bat
Normal file
20
windows/check_ptc_share.bat
Normal 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
|
||||
|
||||
70
windows/start_ptc_share.bat
Normal file
70
windows/start_ptc_share.bat
Normal 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
|
||||
|
||||
24
windows/stop_ptc_share.bat
Normal file
24
windows/stop_ptc_share.bat
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user