From 6e8f606591f09f039195ce6d0cf6a6c45dab55f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=98=9C=EC=9D=B8?= Date: Fri, 17 Apr 2026 10:32:12 +0900 Subject: [PATCH] feat: update manage dashboard flows --- PTC/management_dashboard_preview.html | 634 ++++++++++++++++++++------ server/ptc_api_server.py | 100 +++- server/ptc_source_path.txt | 2 +- windows/README.txt | 2 + windows/check_ptc_share.bat | 15 +- windows/start_ptc_share.ps1 | 66 ++- 6 files changed, 626 insertions(+), 193 deletions(-) diff --git a/PTC/management_dashboard_preview.html b/PTC/management_dashboard_preview.html index 0c4b1cf..040750c 100644 --- a/PTC/management_dashboard_preview.html +++ b/PTC/management_dashboard_preview.html @@ -102,11 +102,11 @@ box-shadow: 0 10px 24px rgba(17,63,103,0.18); } .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); + background: rgba(248, 251, 254, 0.82); + border: 1px solid rgba(232, 239, 246, 0.95); + border-radius: 18px; + box-shadow: 0 3px 10px rgba(15, 28, 46, 0.025); + backdrop-filter: blur(4px); } .layout { display: grid; @@ -219,9 +219,9 @@ gap: 12px; } .summary-card { - border: 1px solid var(--line); - border-radius: 18px; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); + border: 1px solid #eef3f8; + border-radius: 16px; + background: rgba(255,255,255,0.9); padding: 16px; } .summary-card-grid { @@ -243,10 +243,11 @@ text-overflow: clip; } .metric, .mini-card { - border: 1px solid var(--line); - border-radius: 18px; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); - padding: 16px; + border: 1px solid rgba(233, 240, 247, 0.68); + border-radius: 12px; + background: rgba(255,255,255,0.7); + box-shadow: none; + padding: 14px; } .metric-value { margin-top: 8px; @@ -280,41 +281,41 @@ .project-list { display: flex; flex-direction: column; - gap: 10px; + gap: 4px; max-height: calc(100vh - 220px); overflow: auto; padding-right: 4px; } .project-item { - border: 1px solid var(--line); - border-radius: 18px; - padding: 12px 13px; - background: white; + border: none; + border-bottom: 1px solid rgba(233, 240, 247, 0.78); + border-radius: 0; + padding: 12px 8px 12px 6px; + background: transparent; cursor: pointer; transition: 0.18s ease; } - .project-item:hover { transform: translateY(-1px); border-color: #bfd3e5; } + .project-item:hover { transform: none; background: rgba(248,251,254,0.68); } .project-item.active { - border-color: var(--blue); - box-shadow: inset 0 0 0 1px var(--blue); - background: linear-gradient(180deg, #f7fbff, #eef5fb); + box-shadow: inset 3px 0 0 var(--blue); + background: #f8fbfe; } .project-item.selected { box-shadow: inset 0 0 0 2px rgba(31,122,140,0.35); } .vendor-item { - border: 1px solid var(--line); - border-radius: 18px; - padding: 14px; - background: white; + border: none; + border-bottom: 1px solid rgba(233, 240, 247, 0.78); + border-radius: 0; + padding: 12px 8px 12px 6px; + background: transparent; cursor: pointer; transition: 0.18s ease; } - .vendor-item:hover { transform: translateY(-1px); border-color: #bfd3e5; } + .vendor-item:hover { transform: none; background: rgba(248,251,254,0.68); } .vendor-item.active { - border-color: var(--blue); - box-shadow: inset 0 0 0 1px var(--blue); - background: linear-gradient(180deg, #f7fbff, #eef5fb); + box-shadow: inset 3px 0 0 var(--blue); + background: #f8fbfe; } .list-mode-toggle { display: grid; @@ -535,10 +536,10 @@ width: 100%; } .dashboard-status-legend-item { - border: 1px solid #e6eef6; - border-radius: 14px; - padding: 10px 12px; - background: rgba(255,255,255,0.88); + border: 1px solid rgba(233, 240, 247, 0.5); + border-radius: 10px; + padding: 8px 10px; + background: rgba(255,255,255,0.46); } button.dashboard-status-legend-item { width: 100%; @@ -547,13 +548,14 @@ transition: 0.18s ease; } button.dashboard-status-legend-item:hover { - transform: translateY(-1px); - border-color: #bfd3e5; + transform: none; + border-color: #d2deea; + background: rgba(248,251,254,0.8); } button.dashboard-status-legend-item.active { - border-color: var(--blue); - box-shadow: inset 0 0 0 1px var(--blue); - background: linear-gradient(180deg, #f7fbff, #eef5fb); + border-color: #d8e4ef; + box-shadow: inset 3px 0 0 var(--blue); + background: #f8fbfe; } .dashboard-status-legend-item.dashboard-status-inline-item { display: flex; @@ -630,10 +632,10 @@ margin-top: 16px; } .dashboard-family-chart-row { - border: 1px solid var(--line); - border-radius: 18px; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); - padding: 14px 16px; + border: 1px solid rgba(233, 240, 247, 0.62); + border-radius: 12px; + background: rgba(255,255,255,0.5); + padding: 12px 14px; display: grid; grid-template-columns: minmax(0, 1fr) 120px; gap: 14px; @@ -642,13 +644,14 @@ transition: 0.18s ease; } .dashboard-family-chart-row:hover { - transform: translateY(-1px); - border-color: #bfd3e5; + transform: none; + border-color: #d8e4ef; + background: rgba(248,251,254,0.78); } .dashboard-family-chart-row.active { - border-color: var(--blue); - box-shadow: inset 0 0 0 1px var(--blue); - background: linear-gradient(180deg, #f7fbff, #eef5fb); + border-color: #d8e4ef; + box-shadow: inset 3px 0 0 var(--blue); + background: #f8fbfe; } .dashboard-family-row-main { display: grid; @@ -660,10 +663,10 @@ margin-top: 16px; } .dashboard-method-row { - border: 1px solid var(--line); - border-radius: 18px; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); - padding: 14px 16px; + border: 1px solid rgba(233, 240, 247, 0.62); + border-radius: 12px; + background: rgba(255,255,255,0.5); + padding: 12px 14px; display: grid; grid-template-columns: 92px minmax(0, 1fr) 180px; gap: 16px; @@ -672,13 +675,14 @@ transition: 0.18s ease; } .dashboard-method-row:hover { - transform: translateY(-1px); - border-color: #bfd3e5; + transform: none; + border-color: #d8e4ef; + background: rgba(248,251,254,0.78); } .dashboard-method-row.active { - border-color: var(--blue); - box-shadow: inset 0 0 0 1px var(--blue); - background: linear-gradient(180deg, #f7fbff, #eef5fb); + border-color: #d8e4ef; + box-shadow: inset 3px 0 0 var(--blue); + background: #f8fbfe; } .dashboard-method-row-main { display: grid; @@ -696,9 +700,9 @@ margin-top: 14px; } .dashboard-selection-card { - border: 1px solid var(--line); - border-radius: 18px; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); + border: 1px solid rgba(233, 240, 247, 0.62); + border-radius: 14px; + background: rgba(255,255,255,0.54); padding: 16px; } .dashboard-finance-bars { @@ -726,17 +730,25 @@ .dashboard-selection-kpis { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 10px; + gap: 0; margin-top: 14px; + border: 1px solid rgba(233, 240, 247, 0.72); + border-radius: 16px; + background: rgba(255,255,255,0.58); + overflow: hidden; } .dashboard-selection-kpis-wide { grid-template-columns: repeat(5, minmax(0, 1fr)); } .dashboard-selection-kpi { - border: 1px solid #e6eef6; - border-radius: 14px; + border: none; + border-radius: 0; padding: 10px 12px; - background: rgba(255,255,255,0.88); + background: transparent; + box-shadow: none; + } + .dashboard-selection-kpis > *:not(:last-child) { + border-right: 1px solid rgba(233, 240, 247, 0.72); } .dashboard-family-grid { display: grid; @@ -744,21 +756,22 @@ gap: 12px; } .dashboard-family-card { - border: 1px solid var(--line); - border-radius: 18px; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,252,0.98)); - padding: 16px; + border: 1px solid rgba(233, 240, 247, 0.62); + border-radius: 12px; + background: rgba(255,255,255,0.52); + padding: 14px; cursor: pointer; transition: 0.18s ease; } .dashboard-family-card:hover { - transform: translateY(-1px); - border-color: #bfd3e5; + transform: none; + border-color: #d8e4ef; + background: rgba(248,251,254,0.78); } .dashboard-family-card.active { - border-color: var(--blue); - box-shadow: inset 0 0 0 1px var(--blue); - background: linear-gradient(180deg, #f7fbff, #eef5fb); + border-color: #d8e4ef; + box-shadow: inset 3px 0 0 var(--blue); + background: #f8fbfe; } .dashboard-family-mini-track { display: flex; @@ -1841,8 +1854,11 @@ const [selectedManagementYear, setSelectedManagementYear] = useState(""); const [selectedManagementCategory, setSelectedManagementCategory] = useState(""); const [selectedManagementExcludedYear, setSelectedManagementExcludedYear] = useState(""); + const [managementYearWindowStart, setManagementYearWindowStart] = useState(0); + const MANAGEMENT_YEAR_WINDOW_SIZE = 4; const [managementOverviewAccounts, setManagementOverviewAccounts] = useState([]); const [managementOverviewAccountsLoading, setManagementOverviewAccountsLoading] = useState(false); + const [managementYearDetailModalOpen, setManagementYearDetailModalOpen] = useState(false); const [managementAccountModal, setManagementAccountModal] = useState(null); const [managementAccountModalLoading, setManagementAccountModalLoading] = useState(false); const [managementAccountModalView, setManagementAccountModalView] = useState("all"); @@ -1877,6 +1893,7 @@ : null ); const [dashboardOngoingProjectModalOpen, setDashboardOngoingProjectModalOpen] = useState(false); + const [dashboardYearDetailModalOpen, setDashboardYearDetailModalOpen] = useState(false); const [dashboardMarginGradeFilter, setDashboardMarginGradeFilter] = useState( ["all", "deficit", "caution", "good", "excellent"].includes(initialGradeParam) ? initialGradeParam : "all" ); @@ -1964,6 +1981,26 @@ return params.toString(); }, [selectedManagementYear, selectedManagementCategory, managementDateFrom, managementDateTo]); + const sortedManagementOverviewItems = useMemo( + () => + [...(managementOverview.items || [])].sort( + (a, b) => Number(a.year || 0) - Number(b.year || 0) + ), + [managementOverview.items] + ); + + const visibleManagementOverviewItems = useMemo( + () => sortedManagementOverviewItems.slice(managementYearWindowStart, managementYearWindowStart + MANAGEMENT_YEAR_WINDOW_SIZE), + [sortedManagementOverviewItems, managementYearWindowStart, MANAGEMENT_YEAR_WINDOW_SIZE] + ); + + const visibleManagementProfitItems = useMemo(() => { + const visibleYears = new Set(visibleManagementOverviewItems.map((item) => String(item.year))); + return [...(managementOverview.yearly_profit_items || [])] + .sort((a, b) => Number(a.year || 0) - Number(b.year || 0)) + .filter((item) => visibleYears.has(String(item.year))); + }, [managementOverview.yearly_profit_items, visibleManagementOverviewItems]); + const overallSummaryQuery = useMemo(() => { const params = new URLSearchParams(); if (projectType && projectType !== "전체") params.set("project_type", projectType); @@ -2335,6 +2372,21 @@ } }, [managementOverview, selectedManagementYear, selectedManagementCategory]); + useEffect(() => { + const total = sortedManagementOverviewItems.length; + if (!total) { + setManagementYearWindowStart(0); + return; + } + const maxStart = Math.max(0, total - MANAGEMENT_YEAR_WINDOW_SIZE); + setManagementYearWindowStart((prev) => { + if (prev > maxStart) return maxStart; + if (prev < 0) return 0; + if (prev === 0 && total > MANAGEMENT_YEAR_WINDOW_SIZE) return maxStart; + return prev; + }); + }, [sortedManagementOverviewItems.length, MANAGEMENT_YEAR_WINDOW_SIZE]); + useEffect(() => { const hasExcludedSelection = (managementOverview.items || []).some( (item) => item.year === selectedManagementExcludedYear && Number(item.excluded_total || 0) !== 0 @@ -2374,6 +2426,12 @@ }; }, [filteredManagementAccountTransactions]); + function getManagementCategoryAmount(yearItem, categoryName) { + return Number( + ((yearItem?.categories || []).find((category) => category.name === categoryName)?.amount) || 0 + ); + } + useEffect(() => { if (selectedManagementYear && selectedManagementCategory && managementCategorySectionRef.current) { managementCategorySectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); @@ -2798,17 +2856,37 @@ const companyGraphMaxValue = useMemo(() => { return companyGraphRows.reduce((max, row) => Math.max(max, Number(row.income_supply || 0), Number(row.expense_supply || 0)), 1); }, [companyGraphRows]); + const companyGraphLayout = useMemo(() => { + const chartLeft = 90; + const chartRight = 980; + const chartWidth = chartRight - chartLeft; + const pointCount = Math.max(companyGraphRows.length, 1); + const centerStartX = chartLeft + 60; + const centerEndX = chartRight - 60; + const availableCenterWidth = Math.max(centerEndX - centerStartX, 0); + const stepX = pointCount > 1 ? availableCenterWidth / (pointCount - 1) : 0; + const barWidth = pointCount > 1 + ? Math.max(22, Math.min(72, stepX * 0.68)) + : 94; + return { + chartLeft, + chartRight, + chartWidth, + centerStartX, + centerEndX, + stepX, + barWidth, + chartHeight: 250, + baseY: 320, + }; + }, [companyGraphRows.length]); const companyGraphLinePoints = useMemo(() => { - const startX = 150; - const stepX = companyGraphRows.length > 1 ? 260 : 0; - const chartHeight = 250; - const baseY = 320; return companyGraphRows.map((row, index) => { - const x = startX + index * stepX; - const y = baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * chartHeight; + const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX; + const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight; return `${x},${y}`; }).join(" "); - }, [companyGraphRows, companyGraphMaxValue]); + }, [companyGraphRows, companyGraphMaxValue, companyGraphLayout]); const visibleDashboardProjects = useMemo(() => { const filtered = selectedDashboardFamily === "전체" ? dashboardProjectsBase @@ -3901,9 +3979,9 @@ minHeight: 96, appearance: "none", WebkitAppearance: "none", - border: "1px solid #e6eef6", - borderRadius: 14, - background: "rgba(255,255,255,0.88)", + border: "none", + borderRadius: 0, + background: "transparent", width: "100%", textAlign: "left", boxSizing: "border-box", @@ -3977,25 +4055,20 @@ {!!(managementOverview.yearly_construction_margin_items || []).length && (
-
- {(managementOverview.yearly_construction_margin_items || []).map((yearItem) => ( -
-
{yearItem.year}년 시공 수익률
-
- {(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}% -
-
- 수익 {fmtEokManagement(yearItem.profit_supply || 0)} -
+
+
+
년도별 시공 상세
+
+ 연도별 시공 수익률과 수익 금액을 표로 비교해서 봅니다.
- ))} +
+
)} @@ -4214,6 +4287,69 @@ {dashboardOngoingProjectModalContent}
)} + {dashboardYearDetailModalOpen && ( +
setDashboardYearDetailModalOpen(false)}> +
e.stopPropagation()}> +
+
+
년도별 시공 상세
+
+ 연도별 시공 수익률과 수익을 표로 비교합니다. +
+
+ +
+
+ + + + {["연도", "시공 수익률", "수익", "수입", "지출"].map((label, index) => ( + + ))} + + + + {(managementOverview.yearly_construction_margin_items || []).map((yearItem) => ( + + + + + + + + ))} + +
+ {label} +
+ {yearItem.year}년 + + {(Number(yearItem.margin_rate || 0) || 0).toFixed(1)}% + + {fmtEokManagement(yearItem.profit_supply || 0)} + + {fmtEokManagement(yearItem.income_supply || 0)} + + {fmtEokManagement(yearItem.expense_supply || 0)} +
+
+
+
+ )} {currentTab === "project" && (
+ {!!sortedManagementOverviewItems.length && ( +
+ +
+ {visibleManagementOverviewItems[0]?.year || "-"}년 ~ {visibleManagementOverviewItems[visibleManagementOverviewItems.length - 1]?.year || "-"}년 +
+ +
+ )} - {!!(managementOverview.yearly_profit_items || []).length && ( + {!!visibleManagementProfitItems.length && (
- {(managementOverview.yearly_profit_items || []).map((yearItem) => ( -
-
{yearItem.year}년 수익
+ {visibleManagementProfitItems.map((yearItem, index) => ( +
+
{yearItem.year}년 수익
)} -
- {(managementOverview.items || []).map((yearItem) => ( -
+
+ {visibleManagementOverviewItems.map((yearItem) => ( +
{yearItem.year}년
{fmtEokManagement(yearItem.total_expense || 0)}
@@ -5183,14 +5380,14 @@ alignItems: "baseline", width: "100%", height: "auto", - minHeight: 44, + minHeight: 40, boxSizing: "border-box", - padding: "10px 12px", + padding: "8px 10px", borderColor: selectedManagementYear === yearItem.year && selectedManagementCategory === category.name - ? "var(--blue-700)" - : "var(--line)", + ? "#d8e4ef" + : "transparent", color: selectedManagementYear === yearItem.year && selectedManagementCategory === category.name @@ -5199,8 +5396,13 @@ background: selectedManagementYear === yearItem.year && selectedManagementCategory === category.name - ? "rgba(45, 106, 176, 0.08)" - : "white", + ? "#f8fbfe" + : "transparent", + boxShadow: + selectedManagementYear === yearItem.year && + selectedManagementCategory === category.name + ? "inset 3px 0 0 var(--blue)" + : "none", }} > {category.name} @@ -5208,11 +5410,11 @@ ))}
+
+ {sortedManagementOverviewItems.length ? ( +
+ + + + {[ + "연도", + "총 지출", + "일반운영비", + "법정,의무", + "외부전문,전략", + "안전관리비", + "인건비", + "기타 수지/자산", + ].map((label, index) => ( + + ))} + + + + {sortedManagementOverviewItems.map((yearItem) => { + const excludedDiff = + Number(yearItem.excluded_expense_total || 0) - + Number(yearItem.excluded_income_total || 0); + return ( + + + + {[ + "일반운영비", + "법정,의무", + "외부전문,전략", + "안전관리비", + "인건비", + ].map((categoryName) => ( + + ))} + + + ); + })} + +
+ {label} +
+ {yearItem.year}년 + + {fmtEokManagement(yearItem.total_expense || 0)} + + + + +
+ 출금 {fmtEokManagement(yearItem.excluded_expense_total || 0)} / 입금 {fmtEokManagement(yearItem.excluded_income_total || 0)} +
+
+
+ ) : ( +
표시할 연도별 관리 금액이 없습니다.
+ )} +
+
+ )} + {managementAccountModal && (
setManagementAccountModal(null)}>
e.stopPropagation()}> @@ -5674,10 +5994,10 @@ {[0.25, 0.5, 0.75, 1].map((ratio) => { - const y = 320 - ratio * 250; + const y = companyGraphLayout.baseY - ratio * companyGraphLayout.chartHeight; return ( - + {fmtEokManagement(companyGraphMaxValue * ratio)} @@ -5685,10 +6005,10 @@ ); })} {companyGraphRows.map((row, index) => { - const centerX = 150 + index * (companyGraphRows.length > 1 ? 260 : 0); - const barWidth = 94; - const stackBaseY = 320; - const chartHeight = 250; + const centerX = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX; + const barWidth = companyGraphLayout.barWidth; + const stackBaseY = companyGraphLayout.baseY; + const chartHeight = companyGraphLayout.chartHeight; const stackItems = [...row.typeItems].sort((a, b) => { const stackOrder = { "관리": 0, "시공": 999 }; const aFixed = Object.prototype.hasOwnProperty.call(stackOrder, a.project_type || "") ? stackOrder[a.project_type] : 100; @@ -5720,11 +6040,14 @@ ); })} - + {row.year} - - 지출 {fmtEokManagement(row.expense_supply || 0)} + + 지출 + + + {fmtEokManagement(row.expense_supply || 0)} ); @@ -5739,13 +6062,26 @@ filter="url(#companyLineShadow)" /> {companyGraphRows.map((row, index) => { - const x = 150 + index * (companyGraphRows.length > 1 ? 260 : 0); - const y = 320 - (Number(row.income_supply || 0) / companyGraphMaxValue) * 250; + const x = companyGraphLayout.centerStartX + index * companyGraphLayout.stepX; + const y = companyGraphLayout.baseY - (Number(row.income_supply || 0) / companyGraphMaxValue) * companyGraphLayout.chartHeight; return ( - + {fmtEokManagement(row.income_supply || 0)} diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index afdbad1..6e4feef 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -14,7 +14,7 @@ from zipfile import ZipFile BASE_DIR = Path("/home/hyein/project") -DEFAULT_XLSX_PATH = BASE_DIR / "PTC(2023-2026.02).xlsx" +DEFAULT_XLSX_PATH = BASE_DIR / "PTC 입출금내역(2015~).xlsx" XLSX_SOURCE_CONFIG_PATH = BASE_DIR / "server" / "ptc_source_path.txt" METHOD_XLSX_PATH = BASE_DIR / "PTC공법.xlsx" DB_PATH = BASE_DIR / "db" / "ptc_local.sqlite3" @@ -375,6 +375,24 @@ def normalize_transaction_type(in_out: str, account_name: str) -> str: return "unknown" +def correct_transaction_date( + transaction_date: str, + raw_value: str, + project_code: str, + account_code: str, + description: str, +) -> str: + if ( + transaction_date == "2106-10-31" + and raw_value == "75545" + and project_code == "15-시공-25" + and account_code == "711" + and (description or "").strip() == "강관말뚝" + ): + return "2016-10-31" + return transaction_date + + def read_xlsx_rows(path: Path) -> list[dict]: with ZipFile(path) as book: shared_strings = [] @@ -405,36 +423,72 @@ def read_xlsx_rows(path: Path) -> list[dict]: if not rows: return [] - headers = rows[0] - data_rows = rows[1:] + header_row_index = 0 + headers = rows[0] if rows else [] + for idx, candidate in enumerate(rows): + normalized = [str(cell).strip() for cell in candidate if str(cell).strip()] + if "거래일" in normalized and "입/출금" in normalized and "계정코드" in normalized: + header_row_index = idx + headers = candidate + break + + data_rows = rows[header_row_index + 1 :] width = len(headers) items = [] - for source_row_no, row in enumerate(data_rows, start=2): + + def payload_get(payload: dict, *keys: str) -> str: + for key in keys: + if key in payload and payload.get(key) not in (None, ""): + return payload.get(key, "") + return "" + + for source_row_no, row in enumerate(data_rows, start=header_row_index + 2): current = row + [""] * (width - len(row)) if len(row) < width else row[:width] payload = dict(zip(headers, current)) + transaction_date_raw = payload_get(payload, "거래일") + in_out = payload_get(payload, "입/출금") + account_code = payload_get(payload, "계정코드") + account_name = payload_get(payload, "구분", "계정과목") + department_name = payload_get(payload, "부서") + vendor_name = payload_get(payload, "거래처") + project_code = payload_get(payload, "프로젝트코드") + raw_project_type = payload_get(payload, "프로젝트 구분(안)") + project_name = payload_get(payload, "프로젝트명") + description = payload_get(payload, "적요") + supply_amount_raw = payload_get(payload, "공급가액") + vat_amount_raw = payload_get(payload, "부가세") + total_amount_raw = payload_get(payload, "합계금액") + remarks = payload_get(payload, "비고") + transaction_date = correct_transaction_date( + excel_serial_to_date(transaction_date_raw), + transaction_date_raw, + project_code, + account_code, + description, + ) items.append( { "source_row_no": source_row_no, - "transaction_date_raw": payload.get("거래일", ""), - "transaction_date": excel_serial_to_date(payload.get("거래일", "")), - "in_out": payload.get("입/출금", ""), - "account_code": payload.get("계정코드", ""), - "account_name": payload.get("구분", ""), - "department_name": payload.get("부서", ""), - "vendor_name": payload.get("거래처", ""), - "project_code": payload.get("프로젝트코드", ""), - "project_type": payload.get("프로젝트 구분(안)", ""), - "project_name": payload.get("프로젝트명", ""), - "description": payload.get("적요", ""), - "supply_amount_raw": payload.get("공급가액", ""), - "vat_amount_raw": payload.get("부가세", ""), - "total_amount_raw": payload.get("합계금액", ""), - "remarks": payload.get("비고", ""), - "supply_amount": parse_amount(payload.get("공급가액", "")), - "vat_amount": parse_amount(payload.get("부가세", "")), - "total_amount": parse_amount(payload.get("합계금액", "")), + "transaction_date_raw": transaction_date_raw, + "transaction_date": transaction_date, + "in_out": in_out, + "account_code": account_code, + "account_name": account_name, + "department_name": department_name, + "vendor_name": vendor_name, + "project_code": project_code, + "project_type": raw_project_type or infer_project_type_from_code(project_code), + "project_name": project_name, + "description": description, + "supply_amount_raw": supply_amount_raw, + "vat_amount_raw": vat_amount_raw, + "total_amount_raw": total_amount_raw, + "remarks": remarks, + "supply_amount": parse_amount(supply_amount_raw), + "vat_amount": parse_amount(vat_amount_raw), + "total_amount": parse_amount(total_amount_raw), "normalized_type": normalize_transaction_type( - payload.get("입/출금", ""), payload.get("구분", "") + in_out, account_name ), } ) diff --git a/server/ptc_source_path.txt b/server/ptc_source_path.txt index 0a4bce4..95a288d 100644 --- a/server/ptc_source_path.txt +++ b/server/ptc_source_path.txt @@ -1 +1 @@ -/home/hyein/project/PTC(2023-2026.02).xlsx +/home/hyein/project/PTC 입출금내역(2015~).xlsx diff --git a/windows/README.txt b/windows/README.txt index 2c77872..0e76a92 100644 --- a/windows/README.txt +++ b/windows/README.txt @@ -1,5 +1,7 @@ 사용 파일 - start_ptc_share.bat : 공유용 실행 파일. 관리자 권한으로 다시 실행되어 WSL 서버 시작, IP 공유 설정, 방화벽 허용, 공유 주소 복사까지 처리합니다. +- install_ptc_share_autostart.bat : Windows 로그인 시 자동으로 공유가 시작되도록 작업 스케줄러에 등록합니다. +- remove_ptc_share_autostart.bat : 자동 실행 등록을 해제합니다. - set_ptc_source.bat : 사용할 PTC 원본 `.xlsx` 파일을 선택하고 저장한 뒤 서버를 다시 시작합니다. - stop_ptc_share.bat : 공유 중지 - check_ptc_share.bat : 현재 공유 상태 확인 diff --git a/windows/check_ptc_share.bat b/windows/check_ptc_share.bat index ad796c8..8d57502 100644 --- a/windows/check_ptc_share.bat +++ b/windows/check_ptc_share.bat @@ -1,12 +1,6 @@ @echo off 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" +set "HOST_IP=172.16.40.36" echo [Windows portproxy] netsh interface portproxy show v4tov4 @@ -15,10 +9,13 @@ echo [WSL api] wsl.exe bash -lc "curl -s http://127.0.0.1:4000/api/health" echo. echo [WSL web] -wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC/ | head -n 1" +wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC-lab/ | head -n 1" +echo. +echo [WSL manage web] +wsl.exe bash -lc "curl -I -s http://127.0.0.1:4000/PTC-lab-manage/ | head -n 1" echo. echo [Office LAN web] -powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }" +powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/PTC-lab-manage/' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { $_.Exception.Message }" echo. echo [Office LAN api] powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://%HOST_IP%:4000/api/health' -UseBasicParsing -TimeoutSec 5).Content } catch { $_.Exception.Message }" diff --git a/windows/start_ptc_share.ps1 b/windows/start_ptc_share.ps1 index 408d24e..c92d382 100644 --- a/windows/start_ptc_share.ps1 +++ b/windows/start_ptc_share.ps1 @@ -1,3 +1,8 @@ +param( + [switch]$NoBrowser, + [switch]$NoPause +) + $ErrorActionPreference = "Stop" function Test-IsAdmin { @@ -7,17 +12,22 @@ function Test-IsAdmin { } if (-not (Test-IsAdmin)) { - Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @( + $childArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$PSCommandPath`"" ) + if ($NoBrowser) { $childArgs += "-NoBrowser" } + if ($NoPause) { $childArgs += "-NoPause" } + Start-Process -FilePath "powershell.exe" -Verb RunAs -ArgumentList @( + $childArgs + ) exit 0 } $projectDir = "/home/hyein/project" $apiPort = 4000 -$localUrl = "http://localhost:$apiPort/PTC/" +$localUrl = "http://localhost:$apiPort/PTC-lab-manage/" $preferredLanIp = "172.16.40.36" $defaultSource = "/home/hyein/project/PTC(2023-2026.02).xlsx" $sourceConfigPath = "/home/hyein/project/server/ptc_source_path.txt" @@ -43,30 +53,39 @@ Write-Host "Source file: $currentSource" $startResult = Invoke-WslBash "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 ($startResult.ExitCode -ne 0) { Write-Host "Failed to start the server in WSL." -ForegroundColor Red - Read-Host "Press Enter to exit" + if (-not $NoPause) { + Read-Host "Press Enter to exit" + } exit 1 } -$healthResult = Invoke-WslBash "curl -fsS http://127.0.0.1:$apiPort/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:$apiPort/PTC/ >/tmp/ptc_web_health.txt" +$healthResult = Invoke-WslBash "curl -fsS http://127.0.0.1:$apiPort/api/health >/tmp/ptc_api_health.json && curl -fsSI http://127.0.0.1:$apiPort/PTC-lab/ >/tmp/ptc_web_health.txt && curl -fsSI http://127.0.0.1:$apiPort/PTC-lab-manage/ >/tmp/ptc_manage_web_health.txt" if ($healthResult.ExitCode -ne 0) { Write-Host "Local server check failed. Recent server log:" -ForegroundColor Red $logResult = Invoke-WslBash "tail -n 80 /tmp/ptc_api.log" $logResult.Output | ForEach-Object { Write-Host $_ } - Read-Host "Press Enter to exit" + if (-not $NoPause) { + Read-Host "Press Enter to exit" + } exit 1 } -Start-Process $localUrl +if (-not $NoBrowser) { + Start-Process $localUrl +} Write-Host "" Write-Host "Local URL: $localUrl" -$lanIp = Get-NetIPAddress -AddressFamily IPv4 | +$lanIps = @(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } | - Select-Object -ExpandProperty IPAddress -First 1 + Sort-Object InterfaceMetric, SkipAsSource | + Select-Object -ExpandProperty IPAddress) + +$lanIp = $lanIps | Select-Object -First 1 $wslIpResult = Invoke-WslBash "hostname -I | awk '{print \$1}'" $wslIp = ($wslIpResult.Output | Select-Object -First 1).Trim() @@ -78,14 +97,39 @@ if (-not [string]::IsNullOrWhiteSpace($lanIp) -and -not [string]::IsNullOrWhiteS & netsh advfirewall firewall delete rule name="PTC 4000" | Out-Null & netsh advfirewall firewall add rule name="PTC 4000" dir=in action=allow protocol=TCP localport=$apiPort | Out-Null - $shareIp = if ([string]::IsNullOrWhiteSpace($preferredLanIp)) { $lanIp } else { $preferredLanIp } - $shareUrl = "http://$shareIp:$apiPort/PTC/" + if (-not ($lanIps -contains $preferredLanIp)) { + Write-Host "Preferred share IP $preferredLanIp is not assigned on this PC." -ForegroundColor Red + Write-Host "Detected Windows IPs: $($lanIps -join ', ')" -ForegroundColor Yellow + if (-not $NoPause) { + Read-Host "Press Enter to exit" + } + exit 1 + } + + $shareIp = $preferredLanIp + $shareUrl = "http://$shareIp:$apiPort/PTC-lab-manage/" + Write-Host "Windows IP: $lanIp" + Write-Host "Preferred share IP in use: $shareIp" + Write-Host "WSL IP: $wslIp" Write-Host "LAN URL: $shareUrl" Set-Clipboard -Value $shareUrl Write-Host "The share URL has been copied to the clipboard." + + try { + $lanWebStatus = (Invoke-WebRequest -Uri $shareUrl -UseBasicParsing -TimeoutSec 5).StatusCode + $lanApiStatus = (Invoke-WebRequest -Uri "http://$shareIp:$apiPort/api/health" -UseBasicParsing -TimeoutSec 5).StatusCode + Write-Host "LAN web check: $lanWebStatus" + Write-Host "LAN api check: $lanApiStatus" + } + catch { + Write-Host "LAN check failed for $shareIp:$apiPort" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Yellow + } } else { Write-Host "LAN sharing was skipped because Windows IP or WSL IP could not be detected." } -Read-Host "Press Enter to close" +if (-not $NoPause) { + Read-Host "Press Enter to close" +}