Initial commit: Organized PTC project structure with .gitignore and README
This commit is contained in:
+160
@@ -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
|
||||
Binary file not shown.
+3100
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
@@ -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
@@ -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
@@ -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 확인 방법"으로 이해하면 맞습니다
|
||||
- 실제 엑셀 적재 절차는 다음 단계에서 추가 구현이 필요합니다
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 를 다시 실행하세요.
|
||||
- 관리자 권한이 필요합니다.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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