feat: 엑셀 원본 파일 선택 기능 및 프론트엔드/백엔드 최적화
- PTC(2023-2026.02).xlsx 최신화 - PTC/index.html: 에러 핸들링, 동적 API 베이스, 예산 계산 로직 개선 및 UI 최적화 - server/ptc_api_server.py: 4000 포트에서 프론트엔드 직접 서빙, 원본 엑셀 경로 설정 기능, DB 인덱스 추가 및 성능 최적화 - windows/: 원본 파일 선택을 위한 set_ptc_source.bat 추가 및 기존 스크립트 수정
This commit is contained in:
Binary file not shown.
474
PTC/index.html
474
PTC/index.html
@@ -4,9 +4,21 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PTC 프로젝트 관리</title>
|
<title>PTC 프로젝트 관리</title>
|
||||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
<script>
|
||||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
window.__ptcBootStatus = { failed: false, reason: "" };
|
||||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
function ptcBootFail(reason) {
|
||||||
|
window.__ptcBootStatus = { failed: true, reason: reason || "필수 스크립트를 불러오지 못했습니다." };
|
||||||
|
const fallback = document.getElementById("ptc-boot-fallback");
|
||||||
|
if (fallback) {
|
||||||
|
fallback.style.display = "block";
|
||||||
|
const detail = document.getElementById("ptc-boot-detail");
|
||||||
|
if (detail) detail.textContent = window.__ptcBootStatus.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" onerror="ptcBootFail('React CDN을 불러오지 못했습니다. 네트워크 또는 사내망 차단 여부를 확인해 주세요.')"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" onerror="ptcBootFail('ReactDOM CDN을 불러오지 못했습니다. 네트워크 또는 사내망 차단 여부를 확인해 주세요.')"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js" onerror="ptcBootFail('Babel CDN을 불러오지 못했습니다. 네트워크 또는 사내망 차단 여부를 확인해 주세요.')"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -337,7 +349,7 @@
|
|||||||
}
|
}
|
||||||
.progress-compare-row {
|
.progress-compare-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 130px 90px minmax(260px, 1fr) 110px;
|
grid-template-columns: 180px minmax(260px, 1fr) 110px;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@@ -693,10 +705,50 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div id="ptc-boot-fallback" style="display:block;max-width:760px;margin:48px auto;padding:24px;border:1px solid #d8e2ec;border-radius:20px;background:#ffffff;color:#0f1c2e;font-family:'IBM Plex Sans KR',sans-serif;box-shadow:0 18px 42px rgba(15, 28, 46, 0.07);">
|
||||||
|
<div style="font-size:28px;font-weight:700;line-height:1.25;">PTC 화면을 준비하는 중입니다.</div>
|
||||||
|
<div style="margin-top:12px;color:#66788f;font-size:15px;line-height:1.7;">
|
||||||
|
이 메시지가 계속 보이면 브라우저에서 필수 스크립트나 API 서버 연결이 막힌 상태입니다.
|
||||||
|
</div>
|
||||||
|
<div id="ptc-boot-detail" style="margin-top:16px;padding:12px 14px;border-radius:14px;background:#eef5fb;color:#113f67;font-size:14px;line-height:1.6;">
|
||||||
|
초기 스크립트를 불러오는 중입니다.
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;color:#66788f;font-size:14px;line-height:1.7;">
|
||||||
|
접속 주소 예시: `http://localhost:4000/PTC/` 또는 `PTC/index.html?apiBase=http://localhost:4000`
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="text/babel">
|
<script type="text/babel">
|
||||||
const { useEffect, useMemo, useState } = React;
|
if (window.__ptcBootStatus.failed || !window.React || !window.ReactDOM) {
|
||||||
const API_BASE = `${window.location.protocol}//${window.location.hostname}:4000`;
|
ptcBootFail(window.__ptcBootStatus.reason || "React 실행 환경을 준비하지 못했습니다.");
|
||||||
|
throw new Error(window.__ptcBootStatus.reason || "PTC boot failed");
|
||||||
|
}
|
||||||
|
const { useDeferredValue, useEffect, useMemo, useState } = React;
|
||||||
|
function resolveApiBase() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const override = (params.get("apiBase") || "").trim();
|
||||||
|
if (override) {
|
||||||
|
return override.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { protocol, hostname, port } = window.location;
|
||||||
|
if (protocol === "file:") {
|
||||||
|
return "http://127.0.0.1:4000";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostname) {
|
||||||
|
return "http://127.0.0.1:4000";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port === "4000") {
|
||||||
|
return `${protocol}//${hostname}:4000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}//${hostname}:4000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = resolveApiBase();
|
||||||
const fmt = (value) => new Intl.NumberFormat("ko-KR").format(Math.round(value || 0));
|
const fmt = (value) => new Intl.NumberFormat("ko-KR").format(Math.round(value || 0));
|
||||||
const summarizeInOutTransactions = (rows) => {
|
const summarizeInOutTransactions = (rows) => {
|
||||||
const items = rows || [];
|
const items = rows || [];
|
||||||
@@ -842,6 +894,43 @@
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRevenueBudgetItem(item) {
|
||||||
|
return item?.section === "수입";
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateBudgetDiff(item) {
|
||||||
|
const budgetAmount = Number(item?.budget_amount) || 0;
|
||||||
|
const actualAmount = Number(item?.actual_amount) || 0;
|
||||||
|
return isRevenueBudgetItem(item) ? actualAmount - budgetAmount : budgetAmount - actualAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBudgetDiffColor(item, diff) {
|
||||||
|
if (isRevenueBudgetItem(item)) {
|
||||||
|
return diff > 0 ? "var(--good)" : diff < 0 ? "#b42318" : "var(--ink)";
|
||||||
|
}
|
||||||
|
return diff < 0 ? "#b42318" : "var(--ink)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBudgetRateColor(item, rate) {
|
||||||
|
if (isRevenueBudgetItem(item)) {
|
||||||
|
return rate > 100 ? "var(--good)" : "var(--blue)";
|
||||||
|
}
|
||||||
|
return rate > 100 ? "#b42318" : "var(--blue)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDebouncedValue(value, delay) {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
|
|
||||||
function buildBudgetCompareGroups(rows) {
|
function buildBudgetCompareGroups(rows) {
|
||||||
const groups = [
|
const groups = [
|
||||||
{ key: "수입", label: "수입", matcher: (item) => item.section === "수입" },
|
{ key: "수입", label: "수입", matcher: (item) => item.section === "수입" },
|
||||||
@@ -965,6 +1054,8 @@
|
|||||||
const [budgetAccountDetailLoading, setBudgetAccountDetailLoading] = useState(false);
|
const [budgetAccountDetailLoading, setBudgetAccountDetailLoading] = useState(false);
|
||||||
const [budgetAccountDetailMap, setBudgetAccountDetailMap] = useState({});
|
const [budgetAccountDetailMap, setBudgetAccountDetailMap] = useState({});
|
||||||
const [actualModalItem, setActualModalItem] = useState(null);
|
const [actualModalItem, setActualModalItem] = useState(null);
|
||||||
|
const [actualModalLoading, setActualModalLoading] = useState(false);
|
||||||
|
const [actualModalDetail, setActualModalDetail] = useState(null);
|
||||||
const [issueDetailModal, setIssueDetailModal] = useState(null);
|
const [issueDetailModal, setIssueDetailModal] = useState(null);
|
||||||
const [issueDetailLoading, setIssueDetailLoading] = useState(false);
|
const [issueDetailLoading, setIssueDetailLoading] = useState(false);
|
||||||
const [issueRowSelections, setIssueRowSelections] = useState({});
|
const [issueRowSelections, setIssueRowSelections] = useState({});
|
||||||
@@ -995,6 +1086,7 @@
|
|||||||
const [accountVendorDateTo, setAccountVendorDateTo] = useState("");
|
const [accountVendorDateTo, setAccountVendorDateTo] = useState("");
|
||||||
const [projectEditModalOpen, setProjectEditModalOpen] = useState(false);
|
const [projectEditModalOpen, setProjectEditModalOpen] = useState(false);
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [overallSummary, setOverallSummary] = useState(null);
|
||||||
const [editor, setEditor] = useState({
|
const [editor, setEditor] = useState({
|
||||||
project_name: "",
|
project_name: "",
|
||||||
project_type: "",
|
project_type: "",
|
||||||
@@ -1004,13 +1096,17 @@
|
|||||||
end_date: "",
|
end_date: "",
|
||||||
note: ""
|
note: ""
|
||||||
});
|
});
|
||||||
|
const deferredProjectKeyword = useDeferredValue(projectKeyword);
|
||||||
|
const deferredVendorKeyword = useDeferredValue(vendorKeyword);
|
||||||
|
const debouncedProjectKeyword = useDebouncedValue(deferredProjectKeyword, 250);
|
||||||
|
const debouncedVendorKeyword = useDebouncedValue(deferredVendorKeyword, 250);
|
||||||
|
|
||||||
const projectQuery = useMemo(() => {
|
const projectQuery = useMemo(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (projectKeyword.trim()) params.set("keyword", projectKeyword.trim());
|
if (debouncedProjectKeyword.trim()) params.set("keyword", debouncedProjectKeyword.trim());
|
||||||
if (projectType) params.set("project_type", projectType);
|
if (projectType) params.set("project_type", projectType);
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}, [projectKeyword, projectType]);
|
}, [debouncedProjectKeyword, projectType]);
|
||||||
|
|
||||||
const methodOptionsByFamily = useMemo(() => {
|
const methodOptionsByFamily = useMemo(() => {
|
||||||
if (projectMethodFamily === "전체") return methodOptions;
|
if (projectMethodFamily === "전체") return methodOptions;
|
||||||
@@ -1050,6 +1146,12 @@
|
|||||||
return items;
|
return items;
|
||||||
}, [projects, projectMethodFamily, projectMethod, projectSort]);
|
}, [projects, projectMethodFamily, projectMethod, projectSort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredProjects.length === 1 && filteredProjects[0]?.project_code && selectedProjectCode !== filteredProjects[0].project_code) {
|
||||||
|
setSelectedProjectCode(filteredProjects[0].project_code);
|
||||||
|
}
|
||||||
|
}, [filteredProjects, selectedProjectCode]);
|
||||||
|
|
||||||
const detailQuery = useMemo(() => {
|
const detailQuery = useMemo(() => {
|
||||||
if (!selectedProjectCode) return "";
|
if (!selectedProjectCode) return "";
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -1061,9 +1163,15 @@
|
|||||||
|
|
||||||
const vendorQuery = useMemo(() => {
|
const vendorQuery = useMemo(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (vendorKeyword.trim()) params.set("keyword", vendorKeyword.trim());
|
if (debouncedVendorKeyword.trim()) params.set("keyword", debouncedVendorKeyword.trim());
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}, [vendorKeyword]);
|
}, [debouncedVendorKeyword]);
|
||||||
|
|
||||||
|
const overallSummaryQuery = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (projectType && projectType !== "전체") params.set("project_type", projectType);
|
||||||
|
return params.toString();
|
||||||
|
}, [projectType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
@@ -1089,8 +1197,8 @@
|
|||||||
setAccountMaster(optionsData.account_master || {});
|
setAccountMaster(optionsData.account_master || {});
|
||||||
setAllowedAccountCodesByProjectType(optionsData.allowed_account_codes_by_project_type || {});
|
setAllowedAccountCodesByProjectType(optionsData.allowed_account_codes_by_project_type || {});
|
||||||
setProjects(projectData.items || []);
|
setProjects(projectData.items || []);
|
||||||
if (!selectedProjectCode || !(projectData.items || []).some(item => item.project_code === selectedProjectCode)) {
|
if (selectedProjectCode && !(projectData.items || []).some(item => item.project_code === selectedProjectCode)) {
|
||||||
setSelectedProjectCode(projectData.items?.[0]?.project_code || "");
|
setSelectedProjectCode("");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!ignore) setError("프로젝트 목록을 불러오지 못했습니다. API 서버를 확인해 주세요.");
|
if (!ignore) setError("프로젝트 목록을 불러오지 못했습니다. API 서버를 확인해 주세요.");
|
||||||
@@ -1102,6 +1210,23 @@
|
|||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [projectQuery]);
|
}, [projectQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
async function loadOverallSummary() {
|
||||||
|
try {
|
||||||
|
const querySuffix = overallSummaryQuery ? `?${overallSummaryQuery}` : "";
|
||||||
|
const res = await fetch(`${API_BASE}/api/summary${querySuffix}`);
|
||||||
|
if (!res.ok) throw new Error("overall summary load failed");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!ignore) setOverallSummary(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (!ignore) setOverallSummary(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadOverallSummary();
|
||||||
|
return () => { ignore = true; };
|
||||||
|
}, [overallSummaryQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
@@ -1156,6 +1281,7 @@
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
async function loadVendors() {
|
async function loadVendors() {
|
||||||
|
if (currentTab !== "vendor") return;
|
||||||
setVendorLoading(true);
|
setVendorLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/vendors?${vendorQuery}`);
|
const res = await fetch(`${API_BASE}/api/vendors?${vendorQuery}`);
|
||||||
@@ -1175,11 +1301,12 @@
|
|||||||
}
|
}
|
||||||
loadVendors();
|
loadVendors();
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [vendorQuery]);
|
}, [currentTab, vendorQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
async function loadAccounts() {
|
async function loadAccounts() {
|
||||||
|
if (currentTab !== "vendor") return;
|
||||||
setAccountLoading(true);
|
setAccountLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/accounts?${vendorQuery}`);
|
const res = await fetch(`${API_BASE}/api/accounts?${vendorQuery}`);
|
||||||
@@ -1199,11 +1326,12 @@
|
|||||||
}
|
}
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [vendorQuery]);
|
}, [currentTab, vendorQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
async function loadVendorDetail() {
|
async function loadVendorDetail() {
|
||||||
|
if (currentTab !== "vendor") return;
|
||||||
if (!selectedVendorName) {
|
if (!selectedVendorName) {
|
||||||
setVendorDetail(null);
|
setVendorDetail(null);
|
||||||
return;
|
return;
|
||||||
@@ -1225,11 +1353,12 @@
|
|||||||
}
|
}
|
||||||
loadVendorDetail();
|
loadVendorDetail();
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [selectedVendorName, selectedVendorProjectCode]);
|
}, [currentTab, selectedVendorName, selectedVendorProjectCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
async function loadAccountDetail() {
|
async function loadAccountDetail() {
|
||||||
|
if (currentTab !== "vendor") return;
|
||||||
if (!selectedAccountCode) {
|
if (!selectedAccountCode) {
|
||||||
setAccountDetail(null);
|
setAccountDetail(null);
|
||||||
return;
|
return;
|
||||||
@@ -1251,7 +1380,7 @@
|
|||||||
}
|
}
|
||||||
loadAccountDetail();
|
loadAccountDetail();
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [selectedAccountCode, selectedAccountProjectCode]);
|
}, [currentTab, selectedAccountCode, selectedAccountProjectCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedAccountProjectCode("");
|
setSelectedAccountProjectCode("");
|
||||||
@@ -1459,8 +1588,33 @@
|
|||||||
setBudgetAccountDetailLoading(false);
|
setBudgetAccountDetailLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openActualModal(item) {
|
async function openActualModal(item) {
|
||||||
|
if (!selectedProjectCode) return;
|
||||||
setActualModalItem(item);
|
setActualModalItem(item);
|
||||||
|
setActualModalDetail(null);
|
||||||
|
setActualModalLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
project_code: selectedProjectCode,
|
||||||
|
section: item.section || "",
|
||||||
|
group_name: item.group || "",
|
||||||
|
category: item.category || ""
|
||||||
|
});
|
||||||
|
const res = await fetch(`${API_BASE}/api/project-budget-actual-detail?${params.toString()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.message || "집행 상세내역을 불러오지 못했습니다.");
|
||||||
|
setActualModalDetail(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "집행 상세내역을 불러오지 못했습니다.");
|
||||||
|
setActualModalDetail({
|
||||||
|
summary: null,
|
||||||
|
accounts: [],
|
||||||
|
transactions: [],
|
||||||
|
error_message: err.message || "집행 상세내역을 불러오지 못했습니다."
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActualModalLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPileProgressModal() {
|
function openPileProgressModal() {
|
||||||
@@ -1972,6 +2126,46 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
<main style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||||
|
{!selectedProjectCode && overallSummary && (
|
||||||
|
<section className="panel" style={{ padding: 22 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 18, alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1.25 }}>전체 현황</div>
|
||||||
|
<div className="project-inline-meta" style={{ marginTop: 8 }}>
|
||||||
|
<div className="project-inline-meta-line">
|
||||||
|
{projectType && projectType !== "전체" ? `${projectType} 프로젝트 기준` : "전체 프로젝트 기준"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-card" style={{ minWidth: "min(720px, 100%)" }}>
|
||||||
|
<div className="summary-card-grid compact">
|
||||||
|
<div>
|
||||||
|
<div className="subtle">기간</div>
|
||||||
|
<div className="summary-value nowrap">
|
||||||
|
{overallSummary?.min_date || "-"} {overallSummary?.max_date ? `~ ${overallSummary?.max_date}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">입금 / 출금</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{fmt(overallSummary?.income_count || 0)} / {fmt(overallSummary?.expense_count || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">공급가액 합계</div>
|
||||||
|
<div className="summary-value">{fmt(overallSummary?.supply_sum || 0)}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">합계금액</div>
|
||||||
|
<div className="summary-value">{fmt(overallSummary?.total_sum || 0)}원</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{!selectedProject && !loading && (
|
{!selectedProject && !loading && (
|
||||||
<div className="panel empty">선택된 프로젝트가 없습니다.</div>
|
<div className="panel empty">선택된 프로젝트가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
@@ -2062,7 +2256,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div className="mini-card" style={{ marginTop: 14 }}>
|
<div className="mini-card" style={{ marginTop: 14 }}>
|
||||||
{isPileProject ? (
|
{isPileProject ? (
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 180px) minmax(0, 160px) minmax(0, 160px) 150px 120px", gap: 12, alignItems: "end", justifyContent: "start" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 180px) minmax(0, 160px) minmax(0, 160px) minmax(0, 160px) 150px 120px", gap: 12, alignItems: "end", justifyContent: "start" }}>
|
||||||
<label style={{ display: "grid", gap: 6 }}>
|
<label style={{ display: "grid", gap: 6 }}>
|
||||||
<div className="subtle">계약본수</div>
|
<div className="subtle">계약본수</div>
|
||||||
<input
|
<input
|
||||||
@@ -2078,6 +2272,12 @@
|
|||||||
<div className="subtle">누적 시공본수</div>
|
<div className="subtle">누적 시공본수</div>
|
||||||
<div className="summary-value">{fmt(constructedPileCount)}본</div>
|
<div className="summary-value">{fmt(constructedPileCount)}본</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
||||||
|
<div className="subtle">잔여수량</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{fmt(Math.max((Number(contractPileCount) || 0) - (Number(constructedPileCount) || 0), 0))}본
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
<div className="mini-card" style={{ padding: "10px 14px" }}>
|
||||||
<div className="subtle">공정률</div>
|
<div className="subtle">공정률</div>
|
||||||
<div className="summary-value">{effectiveProgressRate.toFixed(1)}%</div>
|
<div className="summary-value">{effectiveProgressRate.toFixed(1)}%</div>
|
||||||
@@ -2165,14 +2365,14 @@
|
|||||||
{budgetCompareRows.map((item) => (
|
{budgetCompareRows.map((item) => (
|
||||||
<div key={item.key} className="progress-compare-row">
|
<div key={item.key} className="progress-compare-row">
|
||||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{item.label}</div>
|
<div style={{ fontSize: 16, fontWeight: 700 }}>{item.label}</div>
|
||||||
<div style={{ display: "grid", gap: 4 }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#2454d3", fontWeight: 700 }}>집행 {item.executionRate.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="bar-track">
|
<div className="bar-track">
|
||||||
<div className="bar-fill" style={{ width: `${Math.min(item.executionRate, 100)}%`, background: "linear-gradient(90deg, #1c54d8, #325fe8)" }} />
|
<div className="bar-fill" style={{ width: `${Math.min(item.executionRate, 100)}%`, background: "linear-gradient(90deg, #1c54d8, #325fe8)" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ textAlign: "right", fontWeight: 700, color: "var(--blue)" }}>
|
||||||
|
{item.executionRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2261,7 +2461,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{group.items.map((item, idx) => {
|
{group.items.map((item, idx) => {
|
||||||
const diff = (Number(item.budget_amount) || 0) - (Number(item.actual_amount) || 0);
|
const diff = calculateBudgetDiff(item);
|
||||||
const rate = (Number(item.budget_amount) || 0) > 0 ? (Number(item.actual_amount) || 0) / (Number(item.budget_amount) || 0) * 100 : 0;
|
const rate = (Number(item.budget_amount) || 0) > 0 ? (Number(item.actual_amount) || 0) / (Number(item.budget_amount) || 0) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
@@ -2276,8 +2476,8 @@
|
|||||||
{fmt(item.actual_amount || 0)}원
|
{fmt(item.actual_amount || 0)}원
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 700, color: diff < 0 ? "#b42318" : "var(--ink)" }}>{fmt(diff)}원</td>
|
<td style={{ fontWeight: 700, color: getBudgetDiffColor(item, diff) }}>{fmt(diff)}원</td>
|
||||||
<td style={{ fontWeight: 700, color: rate > 100 ? "#b42318" : "var(--blue)" }}>{rate.toFixed(1)}%</td>
|
<td style={{ fontWeight: 700, color: getBudgetRateColor(item, rate) }}>{rate.toFixed(1)}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -3075,6 +3275,230 @@
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{actualModalItem && (
|
||||||
|
<div className="modal-backdrop" onClick={() => { setActualModalItem(null); setActualModalDetail(null); }}>
|
||||||
|
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||||||
|
{actualModalItem.section} / {actualModalItem.group} / {actualModalItem.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="button-muted" onClick={() => { setActualModalItem(null); setActualModalDetail(null); }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||||||
|
{actualModalLoading ? (
|
||||||
|
<div className="subtle">집행 상세내역을 불러오는 중입니다.</div>
|
||||||
|
) : actualModalDetail?.error_message ? (
|
||||||
|
<div style={{ color: "#b42318", fontSize: 15, fontWeight: 700 }}>
|
||||||
|
{actualModalDetail.error_message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">거래 건수</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||||||
|
{fmt(actualModalDetail?.summary?.txn_count || 0)}건
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">입금 / 출금</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||||||
|
{fmt(actualModalDetail?.summary?.income_count || 0)} / {fmt(actualModalDetail?.summary?.expense_count || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">입금액</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||||||
|
{fmt(actualModalDetail?.summary?.income_sum || 0)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">출금액</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>
|
||||||
|
{fmt(actualModalDetail?.summary?.expense_sum || 0)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!actualModalLoading && (
|
||||||
|
<>
|
||||||
|
<div className="table-wrap" style={{ marginBottom: 14 }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>계정번호 / 계정명</th>
|
||||||
|
<th>거래건수</th>
|
||||||
|
<th>집행금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(actualModalDetail?.accounts || []).map((account) => (
|
||||||
|
<tr key={account.account_code}>
|
||||||
|
<td>{account.account_code} {account.account_name ? `· ${account.account_name}` : ""}</td>
|
||||||
|
<td>{fmt(account.txn_count || 0)}건</td>
|
||||||
|
<td style={{ fontWeight: 700 }}>{fmt(account.supply_sum || 0)}원</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!actualModalDetail?.accounts?.length && (
|
||||||
|
<tr><td colSpan="3">계정 집계가 없습니다.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>거래일</th>
|
||||||
|
<th>입/출금</th>
|
||||||
|
<th>계정</th>
|
||||||
|
<th>부서</th>
|
||||||
|
<th>거래처</th>
|
||||||
|
<th>적요</th>
|
||||||
|
<th>공급가액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(actualModalDetail?.transactions || []).map((row) => (
|
||||||
|
<tr key={`${row.source_row_no}-${row.transaction_date}-${row.account_code || ""}`}>
|
||||||
|
<td>{row.transaction_date || "-"}</td>
|
||||||
|
<td>{row.in_out || "-"}</td>
|
||||||
|
<td>{row.account_code || "-"}{row.account_name ? ` · ${row.account_name}` : ""}</td>
|
||||||
|
<td>{row.department_name || "-"}</td>
|
||||||
|
<td>{row.vendor_name || "-"}</td>
|
||||||
|
<td>{row.description || "-"}</td>
|
||||||
|
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!actualModalDetail?.transactions?.length && (
|
||||||
|
<tr><td colSpan="7">표시할 집행 상세내역이 없습니다.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{issueDetailModal && (
|
||||||
|
<div className="modal-backdrop" onClick={() => { setIssueDetailModal(null); setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); }}>
|
||||||
|
<div className="modal-panel modal-panel-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700 }}>
|
||||||
|
{issueDetailModal.account_code} {issueDetailModal.account_name ? `· ${issueDetailModal.account_name}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="subtle" style={{ marginTop: 6 }}>
|
||||||
|
어떤 거래인지 먼저 확인한 뒤, 필요한 행만 골라 다른 계정으로 변경할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="button-muted" onClick={() => { setIssueDetailModal(null); setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">거래 건수</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(issueDetailModal.summary?.txn_count || 0)}건</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">공급가액 합계</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(issueDetailModal.summary?.supply_sum || 0)}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="subtle">기간</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 6 }}>
|
||||||
|
{issueDetailModal.summary?.min_date || "-"} {issueDetailModal.summary?.max_date ? `~ ${issueDetailModal.summary.max_date}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mini-card" style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 12, alignItems: "end" }}>
|
||||||
|
<label style={{ display: "grid", gap: 6 }}>
|
||||||
|
<div className="subtle">선택 행 일괄 변경 계정</div>
|
||||||
|
<select className="select" value={issueBulkTargetCode} onChange={(e) => setIssueBulkTargetCode(e.target.value)}>
|
||||||
|
<option value="">계정 선택</option>
|
||||||
|
{(allowedAccountCodesByProjectType[detail?.summary?.project_type] || []).map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code} · {accountMaster[code]?.name || ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="button-muted" onClick={toggleIssueAllChecked}>
|
||||||
|
{(issueDetailModal.items || []).length && (issueDetailModal.items || []).every((row) => issueCheckedRows.includes(row.source_row_no)) ? "전체 해제" : "전체 선택"}
|
||||||
|
</button>
|
||||||
|
<button className="button-primary" onClick={applyIssueBulkTarget} disabled={!issueBulkTargetCode || !issueCheckedRows.length}>
|
||||||
|
선택 행 일괄 적용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 56 }}>선택</th>
|
||||||
|
<th>거래일</th>
|
||||||
|
<th>입/출금</th>
|
||||||
|
<th>부서</th>
|
||||||
|
<th>거래처</th>
|
||||||
|
<th>적요</th>
|
||||||
|
<th>공급가액</th>
|
||||||
|
<th>변경 계정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(issueDetailModal.items || []).map((row) => (
|
||||||
|
<tr key={row.source_row_no}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={issueCheckedRows.includes(row.source_row_no)}
|
||||||
|
onChange={() => toggleIssueCheckedRow(row.source_row_no)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{row.transaction_date || "-"}</td>
|
||||||
|
<td>{row.in_out || "-"}</td>
|
||||||
|
<td>{row.department_name || "-"}</td>
|
||||||
|
<td>{row.vendor_name || "-"}</td>
|
||||||
|
<td>{row.description || "-"}</td>
|
||||||
|
<td style={{ fontWeight: 700 }}>{fmt(row.supply_amount || 0)}원</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={issueRowSelections[row.source_row_no] || ""}
|
||||||
|
onChange={(e) => setIssueRowSelections((prev) => ({ ...prev, [row.source_row_no]: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">유지</option>
|
||||||
|
{(allowedAccountCodesByProjectType[detail?.summary?.project_type] || []).map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code} · {accountMaster[code]?.name || ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!issueDetailModal.items?.length && (
|
||||||
|
<tr><td colSpan="8">표시할 거래가 없습니다.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="button-muted" onClick={() => { setIssueDetailModal(null); setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); }}>취소</button>
|
||||||
|
<button className="button-primary" onClick={saveIssueRowRemap} disabled={issueRowSaving}>
|
||||||
|
{issueRowSaving ? "저장 중..." : "선택 행 계정 저장"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{budgetModalItem && (
|
{budgetModalItem && (
|
||||||
<div className="modal-backdrop" onClick={() => { setBudgetModalItem(null); setBudgetModalAccounts([]); setBudgetModalTotalBudget(0); }}>
|
<div className="modal-backdrop" onClick={() => { setBudgetModalItem(null); setBudgetModalAccounts([]); setBudgetModalTotalBudget(0); }}>
|
||||||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -11,15 +11,15 @@
|
|||||||
|
|
||||||
## 실행 방법
|
## 실행 방법
|
||||||
|
|
||||||
### 1. API 서버 실행
|
### 1. 서버 실행
|
||||||
```bash
|
```bash
|
||||||
python3 server/ptc_api_server.py
|
python3 server/ptc_api_server.py
|
||||||
```
|
```
|
||||||
서버는 기본적으로 4000 포트에서 실행됩니다.
|
서버는 기본적으로 4000 포트에서 실행되며, API와 프론트엔드(`/PTC/`)를 함께 제공합니다.
|
||||||
|
|
||||||
### 2. 프론트엔드 접속
|
### 2. 프론트엔드 접속
|
||||||
`PTC/index.html` 파일을 브라우저로 열거나, 로컬 웹 서버(예: 8000 포트)를 통해 접속합니다.
|
브라우저에서 `http://localhost:4000/PTC/` 로 접속합니다.
|
||||||
API 서버 주소는 `index.html` 내의 `API_BASE` 변수에서 설정할 수 있습니다.
|
필요하면 `index.html`의 `apiBase` 쿼리 파라미터로 API 주소를 덮어쓸 수 있습니다.
|
||||||
|
|
||||||
## 데이터 업데이트
|
## 데이터 업데이트
|
||||||
`db/import_ptc_xlsx.py` 스크립트를 사용하여 엑셀 데이터를 DB로 변환할 수 있습니다. 자세한 내용은 `db/README.md`를 참조하세요.
|
`db/import_ptc_xlsx.py` 스크립트를 사용하여 엑셀 데이터를 DB로 변환할 수 있습니다. 자세한 내용은 `db/README.md`를 참조하세요.
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path("/home/hyein/project")
|
BASE_DIR = Path("/home/hyein/project")
|
||||||
XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx"
|
DEFAULT_XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx"
|
||||||
|
XLSX_SOURCE_CONFIG_PATH = BASE_DIR / "server" / "ptc_source_path.txt"
|
||||||
METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx"
|
METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx"
|
||||||
DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3"
|
DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3"
|
||||||
|
FRONTEND_INDEX_PATH = BASE_DIR / "PTC" / "index.html"
|
||||||
|
FRONTEND_CACHE: dict[str, str | int] = {"mtime": -1, "html": ""}
|
||||||
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||||||
PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"]
|
PROJECT_TYPE_OPTIONS = ["관리", "영업", "시공", "설계", "개발", "기술", "교휴", "기타"]
|
||||||
METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
|
METHOD_FAMILY_OPTIONS = ["복합말뚝", "합성형라멘", "강관거더", "가시설"]
|
||||||
@@ -169,6 +172,28 @@ ACCOUNT_STRUCTURE_TEMPLATE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_xlsx_path() -> Path:
|
||||||
|
if XLSX_SOURCE_CONFIG_PATH.exists():
|
||||||
|
configured = XLSX_SOURCE_CONFIG_PATH.read_text(encoding="utf-8").strip()
|
||||||
|
if configured:
|
||||||
|
path = Path(configured).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = (BASE_DIR / path).resolve()
|
||||||
|
return path
|
||||||
|
return DEFAULT_XLSX_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def get_frontend_html() -> str:
|
||||||
|
if not FRONTEND_INDEX_PATH.exists():
|
||||||
|
raise FileNotFoundError("PTC frontend not found")
|
||||||
|
|
||||||
|
mtime = int(FRONTEND_INDEX_PATH.stat().st_mtime)
|
||||||
|
if FRONTEND_CACHE["mtime"] != mtime:
|
||||||
|
FRONTEND_CACHE["mtime"] = mtime
|
||||||
|
FRONTEND_CACHE["html"] = FRONTEND_INDEX_PATH.read_text(encoding="utf-8")
|
||||||
|
return str(FRONTEND_CACHE["html"])
|
||||||
|
|
||||||
|
|
||||||
def infer_project_type_from_code(project_code: str) -> str:
|
def infer_project_type_from_code(project_code: str) -> str:
|
||||||
match = re.match(r"\d{2}-(.+?)-\d+", (project_code or "").strip())
|
match = re.match(r"\d{2}-(.+?)-\d+", (project_code or "").strip())
|
||||||
return match.group(1) if match else ""
|
return match.group(1) if match else ""
|
||||||
@@ -494,6 +519,15 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
cur.execute("create index if not exists idx_ptc_transactions_project_code on ptc_transactions(project_code)")
|
||||||
|
cur.execute("create index if not exists idx_ptc_transactions_project_code_date on ptc_transactions(project_code, transaction_date desc, source_row_no desc)")
|
||||||
|
cur.execute("create index if not exists idx_ptc_transactions_project_code_account on ptc_transactions(project_code, account_code_final)")
|
||||||
|
cur.execute("create index if not exists idx_ptc_transactions_project_code_in_out on ptc_transactions(project_code, in_out)")
|
||||||
|
cur.execute("create index if not exists idx_ptc_transactions_vendor_name on ptc_transactions(vendor_name)")
|
||||||
|
cur.execute("create index if not exists idx_ptc_transactions_account_code_final on ptc_transactions(account_code_final)")
|
||||||
|
cur.execute("create index if not exists idx_project_pile_progress_entries_project_code on project_pile_progress_entries(project_code)")
|
||||||
|
cur.execute("create index if not exists idx_project_budget_lines_project_code on project_budget_lines(project_code)")
|
||||||
|
cur.execute("create index if not exists idx_project_budget_account_lines_project_code on project_budget_account_lines(project_code)")
|
||||||
existing_cols = [row["name"] for row in cur.execute("pragma table_info(project_master)").fetchall()]
|
existing_cols = [row["name"] for row in cur.execute("pragma table_info(project_master)").fetchall()]
|
||||||
if "construction_family" not in existing_cols:
|
if "construction_family" not in existing_cols:
|
||||||
cur.execute("alter table project_master add column construction_family text")
|
cur.execute("alter table project_master add column construction_family text")
|
||||||
@@ -531,11 +565,15 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
xlsx_mtime = str(int(XLSX_PATH.stat().st_mtime))
|
xlsx_path = get_xlsx_path()
|
||||||
row = cur.execute("select value from meta where key = 'xlsx_mtime'").fetchone()
|
if not xlsx_path.exists():
|
||||||
needs_refresh = row is None or row["value"] != xlsx_mtime
|
raise FileNotFoundError(f"PTC source xlsx not found: {xlsx_path}")
|
||||||
|
|
||||||
|
xlsx_source_signature = f"{xlsx_path.resolve()}|{int(xlsx_path.stat().st_mtime)}"
|
||||||
|
row = cur.execute("select value from meta where key = 'xlsx_source_signature'").fetchone()
|
||||||
|
needs_refresh = row is None or row["value"] != xlsx_source_signature
|
||||||
if needs_refresh:
|
if needs_refresh:
|
||||||
rows = read_xlsx_rows(XLSX_PATH)
|
rows = read_xlsx_rows(xlsx_path)
|
||||||
cur.execute("delete from ptc_transactions")
|
cur.execute("delete from ptc_transactions")
|
||||||
cur.executemany(
|
cur.executemany(
|
||||||
"""
|
"""
|
||||||
@@ -554,9 +592,9 @@ def init_db() -> None:
|
|||||||
[{**item, "imported_at": datetime.utcnow().isoformat()} for item in rows],
|
[{**item, "imported_at": datetime.utcnow().isoformat()} for item in rows],
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"insert into meta(key, value) values('xlsx_mtime', ?) "
|
"insert into meta(key, value) values('xlsx_source_signature', ?) "
|
||||||
"on conflict(key) do update set value = excluded.value",
|
"on conflict(key) do update set value = excluded.value",
|
||||||
(xlsx_mtime,),
|
(xlsx_source_signature,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -911,7 +949,7 @@ def build_where(params: dict[str, list[str]]) -> tuple[str, list]:
|
|||||||
|
|
||||||
|
|
||||||
def build_project_where(project_code: str, keyword: str = "", in_out: str = "전체") -> tuple[str, list]:
|
def build_project_where(project_code: str, keyword: str = "", in_out: str = "전체") -> tuple[str, list]:
|
||||||
clauses = ["coalesce(project_code, '') = ?"]
|
clauses = ["project_code = ?"]
|
||||||
values = [project_code]
|
values = [project_code]
|
||||||
|
|
||||||
if keyword.strip():
|
if keyword.strip():
|
||||||
@@ -1004,6 +1042,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_frontend(self) -> None:
|
||||||
|
if not FRONTEND_INDEX_PATH.exists():
|
||||||
|
self._send_html(404, "<h1>PTC frontend not found</h1>")
|
||||||
|
return
|
||||||
|
self._send_html(200, get_frontend_html())
|
||||||
|
|
||||||
def _send(self, status: int, payload: dict) -> None:
|
def _send(self, status: int, payload: dict) -> None:
|
||||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
@@ -1391,8 +1435,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
|
if parsed.path in {"/PTC", "/PTC/", "/PTC/index.html"}:
|
||||||
|
self._send_frontend()
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/":
|
if parsed.path == "/":
|
||||||
count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"]
|
count = conn.execute("select count(*) as count from ptc_transactions").fetchone()["count"]
|
||||||
|
xlsx_path = get_xlsx_path()
|
||||||
html = f"""<!DOCTYPE html>
|
html = f"""<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
@@ -1489,22 +1538,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
<div style="display:inline-flex;padding:7px 12px;border-radius:999px;background:#eaf3fb;color:#124c7c;font-size:11px;font-weight:700;">PTC Data API</div>
|
<div style="display:inline-flex;padding:7px 12px;border-radius:999px;background:#eaf3fb;color:#124c7c;font-size:11px;font-weight:700;">PTC Data API</div>
|
||||||
<h1 style="font-size:36px;line-height:1.2;margin:16px 0 12px;">PTC 원장 데이터 서버</h1>
|
<h1 style="font-size:36px;line-height:1.2;margin:16px 0 12px;">PTC 원장 데이터 서버</h1>
|
||||||
<p class="subtle">
|
<p class="subtle">
|
||||||
이 서버는 `PTC(2023-2026.02).xlsx`를 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다.
|
이 서버는 선택된 PTC 원본 엑셀 파일을 읽어 요약, 프로젝트 집계, 계정 집계, 거래 미리보기를 JSON API로 제공합니다.
|
||||||
메인 화면은 <a href="http://localhost:8000/PTC">http://localhost:8000/PTC</a> 에서 확인할 수 있습니다.
|
메인 화면은 <a href="http://localhost:4000/PTC/">http://localhost:4000/PTC/</a> 에서 바로 확인할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="font-size:12px;color:var(--muted);font-weight:700;">현재 적재 건수</div>
|
<div style="font-size:12px;color:var(--muted);font-weight:700;">현재 적재 건수</div>
|
||||||
<div style="font-size:34px;font-weight:700;margin-top:10px;">{count:,}</div>
|
<div style="font-size:34px;font-weight:700;margin-top:10px;">{count:,}</div>
|
||||||
<div class="subtle" style="margin-top:10px;">원본 파일 기준 전체 거래 행 수</div>
|
<div class="subtle" style="margin-top:10px;">원본 파일 기준 전체 거래 행 수</div>
|
||||||
|
<div class="subtle" style="margin-top:10px;word-break:break-all;">원본 파일: {xlsx_path}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="font-size:12px;color:var(--muted);font-weight:700;">메인 화면</div>
|
<div style="font-size:12px;color:var(--muted);font-weight:700;">메인 화면</div>
|
||||||
<div style="font-size:20px;font-weight:700;margin-top:8px;">localhost:8000/PTC</div>
|
<div style="font-size:20px;font-weight:700;margin-top:8px;">localhost:4000/PTC/</div>
|
||||||
<div class="subtle" style="margin-top:10px;">사용자가 보는 메인 대시보드</div>
|
<div class="subtle" style="margin-top:10px;">사용자가 보는 메인 대시보드와 API를 같은 서버에서 제공합니다.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="font-size:12px;color:var(--muted);font-weight:700;">헬스체크</div>
|
<div style="font-size:12px;color:var(--muted);font-weight:700;">헬스체크</div>
|
||||||
@@ -1601,7 +1651,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path == "/api/projects":
|
if parsed.path == "/api/projects":
|
||||||
keyword = params.get("keyword", [""])[0].strip().lower()
|
keyword = params.get("keyword", [""])[0].strip().lower()
|
||||||
project_type = params.get("project_type", ["전체"])[0]
|
project_type = params.get("project_type", ["전체"])[0]
|
||||||
clauses = ["coalesce(project_code, '') <> ''"]
|
clauses = ["project_code is not null", "project_code <> ''"]
|
||||||
values = []
|
values = []
|
||||||
if keyword:
|
if keyword:
|
||||||
like = f"%{keyword}%"
|
like = f"%{keyword}%"
|
||||||
@@ -1637,8 +1687,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
values,
|
values,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = rows_to_dicts(rows)
|
items = rows_to_dicts(rows)
|
||||||
|
project_codes = [item["project_code"] for item in items if item.get("project_code")]
|
||||||
|
master_rows = {}
|
||||||
|
if project_codes:
|
||||||
|
placeholders = ",".join("?" for _ in project_codes)
|
||||||
|
master_rows = {
|
||||||
|
row["project_code"]: dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
f"""
|
||||||
|
select project_code, project_name, project_type, construction_family, construction_method, note
|
||||||
|
from project_master
|
||||||
|
where project_code in ({placeholders})
|
||||||
|
""",
|
||||||
|
project_codes,
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
for item in items:
|
for item in items:
|
||||||
master = fetch_project_master(conn, item["project_code"])
|
master = master_rows.get(item["project_code"])
|
||||||
item["project_type"] = resolve_project_type(
|
item["project_type"] = resolve_project_type(
|
||||||
item["project_code"],
|
item["project_code"],
|
||||||
item["project_type"],
|
item["project_type"],
|
||||||
@@ -2110,6 +2175,104 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/project-budget-actual-detail":
|
||||||
|
project_code = params.get("project_code", [""])[0].strip()
|
||||||
|
section = params.get("section", [""])[0].strip()
|
||||||
|
group_name = params.get("group_name", [""])[0].strip()
|
||||||
|
category = params.get("category", [""])[0].strip()
|
||||||
|
if not project_code or not section or not group_name or not category:
|
||||||
|
self._send(400, {"ok": False, "message": "project_code, section, group_name, category are required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
category_accounts = get_category_account_items(section, group_name, category)
|
||||||
|
account_codes = [item["account_code"] for item in category_accounts if item.get("account_code")]
|
||||||
|
if not account_codes:
|
||||||
|
self._send(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"project_code": project_code,
|
||||||
|
"section": section,
|
||||||
|
"group_name": group_name,
|
||||||
|
"category": category,
|
||||||
|
"summary": {"txn_count": 0, "income_count": 0, "expense_count": 0, "income_sum": 0, "expense_sum": 0, "supply_sum": 0},
|
||||||
|
"accounts": [],
|
||||||
|
"transactions": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in account_codes)
|
||||||
|
values = [project_code, *account_codes]
|
||||||
|
|
||||||
|
summary = conn.execute(
|
||||||
|
f"""
|
||||||
|
select
|
||||||
|
count(*) as txn_count,
|
||||||
|
sum(case when in_out = '입금' then 1 else 0 end) as income_count,
|
||||||
|
sum(case when in_out = '출금' then 1 else 0 end) as expense_count,
|
||||||
|
coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_sum,
|
||||||
|
coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_sum,
|
||||||
|
coalesce(sum(supply_amount), 0) as supply_sum
|
||||||
|
from ptc_transactions
|
||||||
|
where project_code = ?
|
||||||
|
and account_code_final in ({placeholders})
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
account_rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
select
|
||||||
|
account_code_final as account_code,
|
||||||
|
max(account_name_final) as account_name,
|
||||||
|
count(*) as txn_count,
|
||||||
|
coalesce(sum(supply_amount), 0) as supply_sum
|
||||||
|
from ptc_transactions
|
||||||
|
where project_code = ?
|
||||||
|
and account_code_final in ({placeholders})
|
||||||
|
group by account_code_final
|
||||||
|
order by account_code_final
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
transaction_rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
select
|
||||||
|
source_row_no,
|
||||||
|
transaction_date,
|
||||||
|
in_out,
|
||||||
|
account_code_final as account_code,
|
||||||
|
account_name_final as account_name,
|
||||||
|
department_name,
|
||||||
|
vendor_name,
|
||||||
|
description,
|
||||||
|
supply_amount,
|
||||||
|
vat_amount,
|
||||||
|
total_amount
|
||||||
|
from ptc_transactions
|
||||||
|
where project_code = ?
|
||||||
|
and account_code_final in ({placeholders})
|
||||||
|
order by transaction_date desc, source_row_no desc
|
||||||
|
limit 100
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
self._send(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"project_code": project_code,
|
||||||
|
"section": section,
|
||||||
|
"group_name": group_name,
|
||||||
|
"category": category,
|
||||||
|
"summary": dict(summary) if summary else None,
|
||||||
|
"accounts": rows_to_dicts(account_rows),
|
||||||
|
"transactions": rows_to_dicts(transaction_rows),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/top-accounts":
|
if parsed.path == "/api/top-accounts":
|
||||||
where, values = build_where(params)
|
where, values = build_where(params)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
1
server/ptc_source_path.txt
Normal file
1
server/ptc_source_path.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/home/hyein/project/PTC(2023-2026.02).xlsx
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
사용 파일
|
사용 파일
|
||||||
- start_ptc_share.bat : 관리자 권한으로 실행되며, WSL 서버 시작 + portproxy + 방화벽까지 자동 설정
|
- start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되어 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다.
|
||||||
|
- set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다.
|
||||||
- stop_ptc_share.bat : 공유 중지
|
- stop_ptc_share.bat : 공유 중지
|
||||||
- check_ptc_share.bat : 현재 공유 상태 확인
|
- check_ptc_share.bat : 현재 공유 상태 확인
|
||||||
|
|
||||||
사용 순서
|
사용 순서
|
||||||
1. start_ptc_share.bat 실행
|
1. 원본 파일을 바꾸려면 set_ptc_source.bat 실행
|
||||||
2. 브라우저에서 http://172.16.40.36:8000/PTC/ 확인
|
2. start_ptc_share.bat 실행
|
||||||
3. 안 되면 check_ptc_share.bat 실행
|
3. 같은 PC에서는 `http://localhost:4000/PTC/` 확인
|
||||||
|
4. 다른 사람에게는 배치파일이 출력한 `http://내PCIP:4000/PTC/` 주소 전달
|
||||||
|
4. 안 되면 check_ptc_share.bat 실행
|
||||||
|
|
||||||
주의
|
주의
|
||||||
- PC가 켜져 있어야 합니다.
|
- PC가 켜져 있어야 합니다.
|
||||||
- WSL이 재시작되어 IP가 바뀌면 start_ptc_share.bat 를 다시 실행하세요.
|
- WSL이 재시작되어 IP가 바뀌면 start_ptc_share.bat 를 다시 실행하세요.
|
||||||
- 관리자 권한이 필요합니다.
|
- 이제 프론트와 API를 모두 4000 포트 하나로 제공합니다.
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal EnableExtensions
|
setlocal EnableExtensions
|
||||||
|
set "HOST_IP="
|
||||||
|
|
||||||
|
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$ip = Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Sort-Object InterfaceMetric, SkipAsSource ^| Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do (
|
||||||
|
set "HOST_IP=%%i"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%HOST_IP%"=="" set "HOST_IP=localhost"
|
||||||
|
|
||||||
echo [Windows portproxy]
|
echo [Windows portproxy]
|
||||||
netsh interface portproxy show v4tov4
|
netsh interface portproxy show v4tov4
|
||||||
echo.
|
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]
|
echo [WSL api]
|
||||||
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health"
|
wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health"
|
||||||
echo.
|
echo.
|
||||||
|
echo [WSL web]
|
||||||
|
wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC/ | head -n 1"
|
||||||
|
echo.
|
||||||
echo [Office LAN web]
|
echo [Office LAN web]
|
||||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:8000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
|
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }"
|
||||||
echo.
|
echo.
|
||||||
echo [Office LAN api]
|
echo [Office LAN api]
|
||||||
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://172.16.40.36:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
|
powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }"
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
|
|
||||||
|
|||||||
42
windows/set_ptc_source.bat
Normal file
42
windows/set_ptc_source.bat
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set "CONFIG_PATH=/home/hyein/project/server/ptc_source_path.txt"
|
||||||
|
set "SELECTED_FILE="
|
||||||
|
set "WSL_SOURCE_PATH="
|
||||||
|
|
||||||
|
for /f "usebackq delims=" %%i in (`powershell -NoProfile -STA -Command "Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.OpenFileDialog; $dialog.Filter = 'Excel Files (*.xlsx)|*.xlsx'; $dialog.Title = 'PTC 원본 엑셀 파일 선택'; $dialog.InitialDirectory = [Environment]::GetFolderPath('Desktop'); if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $dialog.FileName }"`) do (
|
||||||
|
set "SELECTED_FILE=%%i"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%SELECTED_FILE%"=="" (
|
||||||
|
echo 파일 선택이 취소되었습니다.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 선택한 파일:
|
||||||
|
echo %SELECTED_FILE%
|
||||||
|
|
||||||
|
for /f "usebackq delims=" %%i in (`wsl.exe wslpath -a "%SELECTED_FILE%"`) do (
|
||||||
|
set "WSL_SOURCE_PATH=%%i"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%WSL_SOURCE_PATH%"=="" (
|
||||||
|
echo WSL 경로 변환에 실패했습니다.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
wsl.exe bash -lc "printf '%s\n' \"%WSL_SOURCE_PATH%\" > %CONFIG_PATH%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo 원본 파일 설정 저장에 실패했습니다.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 설정 저장 완료
|
||||||
|
echo 다음 실행부터 이 파일을 사용합니다.
|
||||||
|
echo.
|
||||||
|
echo 바로 서버를 다시 시작합니다...
|
||||||
|
call "%~dp0start_ptc_share.bat"
|
||||||
@@ -1,70 +1,77 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal EnableExtensions EnableDelayedExpansion
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set "PROJECT_DIR=/home/hyein/project"
|
set "PROJECT_DIR=/home/hyein/project"
|
||||||
set "WEB_PORT=8000"
|
|
||||||
set "API_PORT=4000"
|
set "API_PORT=4000"
|
||||||
|
set "LOCAL_URL=http://localhost:4000/PTC/"
|
||||||
|
set "SHARE_URL="
|
||||||
|
set "LAN_IP="
|
||||||
|
set "CURRENT_SOURCE="
|
||||||
|
|
||||||
net session >nul 2>&1
|
net session >nul 2>&1
|
||||||
if not "%errorlevel%"=="0" (
|
if not "%errorlevel%"=="0" (
|
||||||
echo 관리자 권한으로 다시 실행합니다...
|
echo 관리자 권한으로 다시 실행해 공유 설정까지 적용합니다...
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "if [ -f /home/hyein/project/server/ptc_source_path.txt ]; then cat /home/hyein/project/server/ptc_source_path.txt; else echo /home/hyein/project/PTC(2023-2026.02).xlsx; fi"`) do (
|
||||||
|
set "CURRENT_SOURCE=%%i"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo PTC 서버 시작 중...
|
||||||
|
echo 원본 파일: %CURRENT_SOURCE%
|
||||||
|
wsl.exe bash -lc "pkill -f '/home/hyein/project/server/ptc_api_server.py' >/dev/null 2>&1 || true; nohup python3 /home/hyein/project/server/ptc_api_server.py >/tmp/ptc_api.log 2>&1 & sleep 3"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo WSL에서 서버 시작 명령 실행에 실패했습니다.
|
||||||
|
echo WSL이 실행 가능한지 확인해 주세요.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 로컬 서버 상태 확인 중...
|
||||||
|
wsl.exe bash -lc "curl -fsS http://127.0.0.1:4000/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:4000/PTC/ >/tmp/ptc_web_health.txt"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo 로컬 서버 확인에 실패했습니다.
|
||||||
|
echo 아래 로그를 확인해 주세요.
|
||||||
|
wsl.exe bash -lc "tail -n 80 /tmp/ptc_api.log"
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 브라우저를 엽니다...
|
||||||
|
start "" "%LOCAL_URL%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo 로컬 실행 완료
|
||||||
|
echo 메인 화면: %LOCAL_URL%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo 사내망 공유용 Windows IP 확인 중...
|
||||||
|
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$ip = Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -ExpandProperty IPAddress -First 1; if ($ip) { $ip }"`) do (
|
||||||
|
set "LAN_IP=%%i"
|
||||||
|
)
|
||||||
|
|
||||||
echo WSL IP 확인 중...
|
echo WSL IP 확인 중...
|
||||||
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | cut -d' ' -f1"`) do (
|
for /f "usebackq delims=" %%i in (`wsl.exe bash -lc "hostname -I | awk '{print $1}'"`) do (
|
||||||
set "WSL_IP=%%i"
|
set "WSL_IP=%%i"
|
||||||
)
|
)
|
||||||
|
|
||||||
if "%WSL_IP%"=="" (
|
if "%LAN_IP%"=="" goto :share_done
|
||||||
echo WSL IP를 확인하지 못했습니다.
|
if "%WSL_IP%"=="" goto :share_done
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo WSL IP: %WSL_IP%
|
echo 사내망 공유 설정 중...
|
||||||
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 delete v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% >nul 2>&1
|
||||||
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=%API_PORT% connectaddress=%WSL_IP% connectport=%API_PORT% >nul 2>&1
|
||||||
netsh 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 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 2>&1
|
||||||
netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=%API_PORT% >nul
|
|
||||||
|
|
||||||
echo 서버 상태 확인 중...
|
:share_done
|
||||||
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 not "%LAN_IP%"=="" (
|
||||||
if errorlevel 1 (
|
set "SHARE_URL=http://%LAN_IP%:%API_PORT%/PTC/"
|
||||||
echo WSL 내부 서버 확인에 실패했습니다.
|
echo 사내망 접속 주소: %SHARE_URL%
|
||||||
echo /tmp/ptc_api.log 와 /tmp/ptc_web.log 를 확인해 주세요.
|
echo %SHARE_URL%| clip
|
||||||
pause
|
echo 공유 주소를 클립보드에 복사했습니다.
|
||||||
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
|
pause
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user