diff --git a/PTC/management_dashboard_preview.html b/PTC/management_dashboard_preview.html index 040750c..62922c9 100644 --- a/PTC/management_dashboard_preview.html +++ b/PTC/management_dashboard_preview.html @@ -1393,7 +1393,7 @@ ptcBootFail(window.__ptcBootStatus.reason || "React 실행 환경을 준비하지 못했습니다."); throw new Error(window.__ptcBootStatus.reason || "PTC boot failed"); } - const { useDeferredValue, useEffect, useMemo, useRef, useState } = React; + const { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } = React; function resolveApiBase() { const params = new URLSearchParams(window.location.search); const override = (params.get("apiBase") || "").trim(); @@ -1559,6 +1559,7 @@ function filterProjectBudgetRows(rows) { return (rows || []).filter((item) => ( item.section === "수입" || + item.section === "기타" || (item.section === "지출" && (item.group === "시공" || item.group === "관리")) )); } @@ -1778,7 +1779,7 @@ const [currentTab, setCurrentTab] = useState( initialPopupModeParam === "status_projects" ? "dashboard" - : ["dashboard", "project", "vendor", "management", "company"].includes(initialTabParam) ? initialTabParam : "company" + : ["dashboard", "project", "lifecycle", "vendor", "management", "company"].includes(initialTabParam) ? initialTabParam : "company" ); const [projectKeyword, setProjectKeyword] = useState(""); const [projectType, setProjectType] = useState("전체"); @@ -1795,6 +1796,7 @@ const [accountMaster, setAccountMaster] = useState({}); const [allowedAccountCodesByProjectType, setAllowedAccountCodesByProjectType] = useState({}); const [projects, setProjects] = useState([]); + const [relationProjects, setRelationProjects] = useState([]); const [selectedProjectCodes, setSelectedProjectCodes] = useState([]); const [batchMethod, setBatchMethod] = useState(""); const [batchSaving, setBatchSaving] = useState(false); @@ -1872,7 +1874,13 @@ const [companyAccountModalView, setCompanyAccountModalView] = useState("all"); const [companyAccountDetailModal, setCompanyAccountDetailModal] = useState(null); const [companyAccountDetailModalLoading, setCompanyAccountDetailModalLoading] = useState(false); + const [lifecycleBreakdownModal, setLifecycleBreakdownModal] = useState(null); + const [lifecycleAccountDetailModal, setLifecycleAccountDetailModal] = useState(null); + const [lifecycleAccountDetailModalLoading, setLifecycleAccountDetailModalLoading] = useState(false); + const [lifecycleAllocationModal, setLifecycleAllocationModal] = useState(null); + const [lifecycleAllocationSaving, setLifecycleAllocationSaving] = useState(false); const [projectEditModalOpen, setProjectEditModalOpen] = useState(false); + const [relatedProjectSearch, setRelatedProjectSearch] = useState(""); const [projectTxnDateFrom, setProjectTxnDateFrom] = useState(""); const [projectTxnDateTo, setProjectTxnDateTo] = useState(""); const [detail, setDetail] = useState(null); @@ -1904,21 +1912,22 @@ construction_method: "", start_date: "", end_date: "", - note: "" + note: "", + related_project_codes: "" }); const deferredProjectKeyword = useDeferredValue(projectKeyword); const deferredVendorKeyword = useDeferredValue(vendorKeyword); const debouncedProjectKeyword = useDebouncedValue(deferredProjectKeyword, 250); const debouncedVendorKeyword = useDebouncedValue(deferredVendorKeyword, 250); + const deferredRelatedProjectSearch = useDeferredValue(relatedProjectSearch); const projectQuery = useMemo(() => { const params = new URLSearchParams(); - if (debouncedProjectKeyword.trim()) params.set("keyword", debouncedProjectKeyword.trim()); if (projectType) params.set("project_type", projectType); if (projectTxnDateFrom) params.set("date_from", projectTxnDateFrom); if (projectTxnDateTo) params.set("date_to", projectTxnDateTo); return params.toString(); - }, [debouncedProjectKeyword, projectType, projectTxnDateFrom, projectTxnDateTo]); + }, [projectType, projectTxnDateFrom, projectTxnDateTo]); const methodOptionsByFamily = useMemo(() => { if (projectMethodFamily === "전체") return methodOptions; @@ -1927,6 +1936,10 @@ const filteredProjects = useMemo(() => { let items = [...projects]; + const keyword = debouncedProjectKeyword.trim().toLowerCase(); + if (keyword) { + items = items.filter((item) => (item.related_search_text || "").includes(keyword)); + } if (projectType !== "전체") { items = items.filter((item) => (item.project_type || "") === projectType); } @@ -1939,7 +1952,158 @@ items.sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko")); return items; - }, [projects, projectMethodFamily, projectMethod]); + }, [projects, projectMethodFamily, projectMethod, debouncedProjectKeyword, projectType]); + + const isConstructionProject = useCallback((item) => { + const code = String(item?.project_code || ""); + const type = String(item?.project_type || ""); + const name = String(item?.project_name || ""); + return (code.includes("-시공-") || type === "시공") && !name.includes("시공관리"); + }, []); + + const getLifecycleProjectType = useCallback((item) => { + const code = String(item?.project_code || ""); + const type = String(item?.project_type || ""); + if (code.includes("-영업-")) return "영업"; + if (code.includes("-설계-")) return "설계"; + if (code.includes("-시공-")) return "시공"; + return type; + }, []); + + const filteredLifecycleProjects = useMemo( + () => filteredProjects.filter((item) => isConstructionProject(item)), + [filteredProjects, isConstructionProject] + ); + + const selectedRelatedProjectCodes = useMemo( + () => String(editor.related_project_codes || "") + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter(Boolean), + [editor.related_project_codes] + ); + + useEffect(() => { + window.__lifecycleBreakdownState = lifecycleBreakdownModal ? (lifecycleBreakdownModal.label || "__open__") : ""; + }, [lifecycleBreakdownModal]); + + useEffect(() => { + setLifecycleBreakdownModal(null); + setLifecycleAccountDetailModal(null); + }, [selectedProjectCode, currentTab]); + + const currentEditableProjectType = useMemo( + () => getLifecycleProjectType({ + project_code: selectedProjectCode || detail?.summary?.project_code || "", + project_type: editor.project_type || detail?.summary?.project_type || "", + }), + [editor.project_type, detail?.summary?.project_code, detail?.summary?.project_type, getLifecycleProjectType, selectedProjectCode] + ); + + const relatedProjectTargetTypes = useMemo(() => { + const lifecycleTypes = ["영업", "설계", "시공"]; + if (lifecycleTypes.includes(currentEditableProjectType)) { + return lifecycleTypes.filter((item) => item !== currentEditableProjectType); + } + return lifecycleTypes; + }, [currentEditableProjectType]); + + const normalizedSelectedRelatedProjectItems = useMemo(() => ( + selectedRelatedProjectCodes + .map((code) => relationProjects.find((item) => item.project_code === code) || { project_code: code, project_name: "", project_type: "" }) + .filter((item) => { + const derivedType = getLifecycleProjectType(item); + return !derivedType || relatedProjectTargetTypes.includes(derivedType); + }) + ), [getLifecycleProjectType, relationProjects, selectedRelatedProjectCodes, relatedProjectTargetTypes]); + + const normalizedSelectedRelatedProjectCodes = useMemo( + () => normalizedSelectedRelatedProjectItems.map((item) => item.project_code), + [normalizedSelectedRelatedProjectItems] + ); + + const filteredRelatedProjectCandidates = useMemo(() => { + const keyword = String(deferredRelatedProjectSearch || "").trim().toLowerCase(); + const excluded = new Set([selectedProjectCode, ...normalizedSelectedRelatedProjectCodes].filter(Boolean)); + let items = [...relationProjects].filter((item) => !excluded.has(item.project_code)); + items = items.filter((item) => relatedProjectTargetTypes.includes(getLifecycleProjectType(item) || "")); + if (keyword) { + items = items.filter((item) => + [ + item.project_code || "", + item.project_name || "", + getLifecycleProjectType(item) || "", + item.construction_family || "", + item.construction_method || "", + ].join(" ").toLowerCase().includes(keyword) + ); + } + items.sort((a, b) => (b.project_code || "").localeCompare(a.project_code || "", "ko")); + return items.slice(0, 12); + }, [deferredRelatedProjectSearch, getLifecycleProjectType, normalizedSelectedRelatedProjectCodes, relationProjects, relatedProjectTargetTypes, selectedProjectCode]); + + const groupedSelectedRelatedProjects = useMemo(() => { + return relatedProjectTargetTypes.map((type) => ({ + type, + items: normalizedSelectedRelatedProjectItems.filter((item) => getLifecycleProjectType(item) === type), + })); + }, [getLifecycleProjectType, normalizedSelectedRelatedProjectItems, relatedProjectTargetTypes]); + + const groupedRelatedProjectCandidates = useMemo(() => ( + relatedProjectTargetTypes.map((type) => ({ + type, + items: filteredRelatedProjectCandidates.filter((item) => getLifecycleProjectType(item) === type), + })) + ), [filteredRelatedProjectCandidates, getLifecycleProjectType, relatedProjectTargetTypes]); + + const isLifecycleTab = currentTab === "lifecycle"; + const lifecycleRatioMap = useMemo(() => { + const map = {}; + (detail?.lifecycle_cost?.rows || []).forEach((row) => { + const code = row?.project_code || ""; + if (!code) return; + const ratio = Number(row?.allocation_ratio); + map[code] = Number.isFinite(ratio) && ratio >= 0 ? ratio : 1; + }); + return map; + }, [detail?.lifecycle_cost?.rows]); + const lifecycleFlowCards = useMemo(() => { + const related = detail?.related_projects || []; + const roleOrder = ["영업", "설계", "시공"]; + const cards = []; + + for (const role of roleOrder) { + let item = null; + if (role === "시공") { + item = related.find((row) => (row.project_code || "") === (selectedProjectCode || "")) || null; + } + if (!item) { + item = related.find((row) => (row.project_type || "") === role) || null; + } + if (!item) continue; + + const ratio = role === "영업" || role === "설계" + ? (lifecycleRatioMap[item.project_code] ?? 1) + : 1; + const income = Number(item.income_supply || 0); + const expense = Number(item.expense_supply || 0); + cards.push({ + role, + project_code: item.project_code || "", + project_name: item.project_name || "", + note: item.note || "", + income, + expense, + reflected_income: income * ratio, + reflected_expense: expense * ratio, + numerator: Number((detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item.project_code)?.allocation_numerator || 1), + denominator: Number((detail?.lifecycle_cost?.rows || []).find((row) => row.project_code === item.project_code)?.allocation_denominator || 1), + }); + } + + return cards; + }, [detail?.related_projects, detail?.lifecycle_cost?.rows, lifecycleRatioMap, selectedProjectCode]); + const visibleProjectList = isLifecycleTab ? filteredLifecycleProjects : filteredProjects; useEffect(() => { if (filteredProjects.length === 1 && filteredProjects[0]?.project_code && selectedProjectCode !== filteredProjects[0].project_code) { @@ -1947,6 +2111,14 @@ } }, [filteredProjects, selectedProjectCode]); + useEffect(() => { + if (currentTab !== "lifecycle") return; + if (!filteredLifecycleProjects.length) return; + if (!filteredLifecycleProjects.some((item) => item.project_code === selectedProjectCode)) { + setSelectedProjectCode(filteredLifecycleProjects[0].project_code); + } + }, [currentTab, filteredLifecycleProjects, selectedProjectCode]); + const detailQuery = useMemo(() => { if (!selectedProjectCode) return ""; const params = new URLSearchParams(); @@ -2013,15 +2185,17 @@ setLoading(true); setError(""); try { - const [typeRes, optionsRes, projectRes] = await Promise.all([ + const [typeRes, optionsRes, projectRes, relationProjectRes] = await Promise.all([ fetch(`${API_BASE}/api/project-types`), fetch(`${API_BASE}/api/project-master-options`), - fetch(`${API_BASE}/api/projects?${projectQuery}`) + fetch(`${API_BASE}/api/projects?${projectQuery}`), + fetch(`${API_BASE}/api/projects`) ]); - if (!typeRes.ok || !optionsRes.ok || !projectRes.ok) throw new Error("project load failed"); + if (!typeRes.ok || !optionsRes.ok || !projectRes.ok || !relationProjectRes.ok) throw new Error("project load failed"); const typeData = await typeRes.json(); const optionsData = await optionsRes.json(); const projectData = await projectRes.json(); + const relationProjectData = await relationProjectRes.json(); if (ignore) return; setProjectTypes(typeData.items || []); setProjectTypeOptions(optionsData.project_type_options || []); @@ -2031,6 +2205,7 @@ setAccountMaster(optionsData.account_master || {}); setAllowedAccountCodesByProjectType(optionsData.allowed_account_codes_by_project_type || {}); setProjects(projectData.items || []); + setRelationProjects(relationProjectData.items || []); if (selectedProjectCode && !(projectData.items || []).some(item => item.project_code === selectedProjectCode)) { setSelectedProjectCode(""); } @@ -2125,14 +2300,24 @@ setIssueRowSelections({}); setIssueCheckedRows([]); setIssueBulkTargetCode(""); - setEditor({ - project_name: data?.summary?.project_name || "", - project_type: data?.summary?.project_type || "", - construction_family: data?.summary?.construction_family || "", - construction_method: data?.summary?.construction_method || "", - start_date: data?.summary?.start_date || "", - end_date: data?.summary?.end_date || "", - note: data?.summary?.note || "" + setEditor({ + project_name: data?.summary?.project_name || "", + project_type: data?.summary?.project_type || "", + construction_family: data?.summary?.construction_family || "", + construction_method: data?.summary?.construction_method || "", + start_date: data?.summary?.start_date || "", + end_date: data?.summary?.end_date || "", + note: data?.summary?.note || "", + related_project_codes: (data?.related_projects || []) + .filter((item) => item.project_code !== selectedProjectCode) + .filter((item) => { + const currentType = data?.summary?.project_type || ""; + const lifecycleTypes = ["영업", "설계", "시공"]; + if (!lifecycleTypes.includes(currentType)) return true; + return (item.project_type || "") !== currentType; + }) + .map((item) => item.project_code) + .join(", ") }); } } catch (err) { @@ -2525,6 +2710,32 @@ return () => { ignore = true; }; }, [companyAccountDetailModal?.year, companyAccountDetailModal?.project_type, companyAccountDetailModal?.account_code]); + useEffect(() => { + let ignore = false; + async function loadLifecycleAccountDetailModal() { + if (!lifecycleAccountDetailModal?.project_code || !lifecycleAccountDetailModal?.bucket_label || !lifecycleAccountDetailModal?.account_code) return; + setLifecycleAccountDetailModalLoading(true); + try { + const params = new URLSearchParams(); + params.set("project_code", lifecycleAccountDetailModal.project_code); + params.set("bucket_label", lifecycleAccountDetailModal.bucket_label); + params.set("account_code", lifecycleAccountDetailModal.account_code); + const res = await fetch(`${API_BASE}/api/lifecycle-account-detail?${params.toString()}`); + if (!res.ok) throw new Error("lifecycle account detail failed"); + const data = await res.json(); + if (!ignore) { + setLifecycleAccountDetailModal((prev) => (prev ? { ...prev, detail: data } : prev)); + } + } catch (err) { + if (!ignore) setError("생애주기 계정 상세를 불러오지 못했습니다."); + } finally { + if (!ignore) setLifecycleAccountDetailModalLoading(false); + } + } + loadLifecycleAccountDetailModal(); + return () => { ignore = true; }; + }, [lifecycleAccountDetailModal?.project_code, lifecycleAccountDetailModal?.bucket_label, lifecycleAccountDetailModal?.account_code]); + useEffect(() => { setSelectedAccountProjectCode(""); setAccountVendorModal(null); @@ -3202,6 +3413,128 @@ }); } + function openLifecycleAllocationModal(projectItem) { + if (!selectedProjectCode || !projectItem) return; + const projectType = projectItem.project_type || ""; + if (!["영업", "설계"].includes(projectType)) return; + const sourceProjectCode = projectItem.project_code || ""; + const lifecycleRow = ((detail?.lifecycle_cost?.rows || []).find( + (row) => (row.project_code || "") === sourceProjectCode + ) || null); + const numerator = Number( + lifecycleRow?.allocation_numerator ?? projectItem.allocation_numerator ?? 1 + ); + const denominator = Number( + lifecycleRow?.allocation_denominator ?? projectItem.allocation_denominator ?? 1 + ); + setLifecycleAllocationModal({ + base_project_code: selectedProjectCode, + source_project_code: sourceProjectCode, + project_name: projectItem.project_name || "", + project_type: projectType, + allocation_numerator: Number.isFinite(numerator) && numerator >= 0 ? numerator : 1, + allocation_denominator: Number.isFinite(denominator) && denominator > 0 ? denominator : 1, + }); + } + + async function saveLifecycleAllocation() { + if (!lifecycleAllocationModal?.base_project_code || !lifecycleAllocationModal?.source_project_code) return; + const numerator = Math.max(0, Math.floor(Number(lifecycleAllocationModal.allocation_numerator || 0))); + const denominator = Math.max(1, Math.floor(Number(lifecycleAllocationModal.allocation_denominator || 1))); + if (numerator > denominator) { + setError("해당프로젝트 수는 총프로젝트 수보다 클 수 없습니다."); + return; + } + setLifecycleAllocationSaving(true); + setError(""); + try { + const res = await fetch(`${API_BASE}/api/lifecycle-allocation/upsert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + base_project_code: lifecycleAllocationModal.base_project_code, + source_project_code: lifecycleAllocationModal.source_project_code, + allocation_numerator: numerator, + allocation_denominator: denominator, + }), + }); + if (!res.ok) throw new Error("allocation save failed"); + await res.json(); + + const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`); + if (detailRes.ok) { + const nextDetail = await detailRes.json(); + setDetail(nextDetail); + if (lifecycleBreakdownModal?.label) { + const refreshed = (nextDetail?.lifecycle_cost?.breakdown || []).find( + (item) => item.label === lifecycleBreakdownModal.label + ); + if (refreshed) { + setLifecycleBreakdownModal({ + label: refreshed.label || "", + expense_supply: Number(refreshed.expense_supply || 0), + projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [], + accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [], + opened_at: Date.now(), + }); + } else { + setLifecycleBreakdownModal(null); + } + } + } + setLifecycleAllocationModal(null); + } catch (err) { + setError("생애주기 배분 비율 저장에 실패했습니다."); + } finally { + setLifecycleAllocationSaving(false); + } + } + + async function deleteLifecycleAllocation() { + if (!lifecycleAllocationModal?.base_project_code || !lifecycleAllocationModal?.source_project_code) return; + setLifecycleAllocationSaving(true); + setError(""); + try { + const res = await fetch(`${API_BASE}/api/lifecycle-allocation/delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + base_project_code: lifecycleAllocationModal.base_project_code, + source_project_code: lifecycleAllocationModal.source_project_code, + }), + }); + if (!res.ok) throw new Error("allocation delete failed"); + await res.json(); + + const detailRes = await fetch(`${API_BASE}/api/project-detail?${detailQuery}`); + if (detailRes.ok) { + const nextDetail = await detailRes.json(); + setDetail(nextDetail); + if (lifecycleBreakdownModal?.label) { + const refreshed = (nextDetail?.lifecycle_cost?.breakdown || []).find( + (item) => item.label === lifecycleBreakdownModal.label + ); + if (refreshed) { + setLifecycleBreakdownModal({ + label: refreshed.label || "", + expense_supply: Number(refreshed.expense_supply || 0), + projects: Array.isArray(refreshed.projects) ? refreshed.projects.map((project) => ({ ...project })) : [], + accounts: Array.isArray(refreshed.accounts) ? refreshed.accounts.map((account) => ({ ...account })) : [], + opened_at: Date.now(), + }); + } else { + setLifecycleBreakdownModal(null); + } + } + } + setLifecycleAllocationModal(null); + } catch (err) { + setError("생애주기 배분 비율 삭제에 실패했습니다."); + } finally { + setLifecycleAllocationSaving(false); + } + } + async function saveProjectMaster() { if (!selectedProjectCode) return; setSaving(true); @@ -3218,25 +3551,17 @@ construction_method: editor.construction_method, start_date: editor.start_date, end_date: editor.end_date, - note: editor.note + note: editor.note, + related_project_codes: normalizedSelectedRelatedProjectCodes }) }); if (!res.ok) throw new Error("save failed"); - const saved = await res.json(); - setDetail((prev) => prev ? { - ...prev, - summary: { - ...prev.summary, - project_name: saved.item?.project_name || prev.summary.project_name, - project_type: saved.item?.project_type || prev.summary.project_type, - construction_family: saved.item?.construction_family || "", - construction_method: saved.item?.construction_method || "", - start_date: saved.item?.start_date || "", - end_date: saved.item?.end_date || "", - note: saved.item?.note || "" - }, - project_master: saved.item - } : prev); + await res.json(); + const detailRes = await fetch(`${API_BASE}/api/project-detail?project_code=${encodeURIComponent(selectedProjectCode)}`); + if (detailRes.ok) { + const data = await detailRes.json(); + setDetail(data); + } const projectRes = await fetch(`${API_BASE}/api/projects?${projectQuery}`); if (projectRes.ok) { const projectData = await projectRes.json(); @@ -3736,7 +4061,17 @@
- {currentTab === "vendor" ? "거래내역확인" : currentTab === "dashboard" ? "대시보드 시안" : currentTab === "management" ? "관리 계정 보기" : currentTab === "company" ? "전체 연도별 현황" : "프로젝트 관리"} + {currentTab === "vendor" + ? "거래내역확인" + : currentTab === "dashboard" + ? "대시보드 시안" + : currentTab === "management" + ? "관리 계정 보기" + : currentTab === "company" + ? "전체 연도별 현황" + : currentTab === "lifecycle" + ? "프로젝트 생애주기 원가" + : "프로젝트 관리"}
@@ -3746,6 +4081,9 @@ + @@ -4350,10 +4688,11 @@
)} - {currentTab === "project" && ( + {(currentTab === "project" || currentTab === "lifecycle") && (
- {!selectedProjectCode && overallSummary && ( + {!selectedProjectCode && overallSummary && !isLifecycleTab && (
@@ -4554,9 +4906,20 @@ {!!editor.note && (
{editor.note}
)} + {!!(detail?.related_projects || []).filter((item) => item.project_code !== selectedProjectCode).length && ( +
+ 연결 코드: {(detail?.related_projects || []) + .filter((item) => item.project_code !== selectedProjectCode) + .map((item) => `${item.project_type} ${item.project_code}`) + .join(" · ")} +
+ )}
- @@ -4589,6 +4952,386 @@
+ {currentTab === "project" && !!(detail?.related_projects || []).filter((item) => item.project_code !== selectedProjectCode).length && ( +
+
관련 프로젝트
+
수정 화면에서 연결한 영업·설계·시공 코드를 함께 봅니다.
+
+ {(detail.related_projects || []).map((item) => { + const isCurrent = item.project_code === selectedProjectCode; + const ratio = isLifecycleTab ? (lifecycleRatioMap[item.project_code] ?? 1) : 1; + const displayIncome = Number(item.income_supply || 0) * ratio; + const displayExpense = Number(item.expense_supply || 0) * ratio; + const displayProfit = displayIncome - displayExpense; + return ( + + ); + })} +
+
+ )} + + {currentTab === "lifecycle" && (detail?.lifecycle_cost?.rows || []).length > 0 && ( +
+
+
관련 프로젝트 흐름
+
영업/설계 카드를 누르면 배분 비율(해당프로젝트/총프로젝트)을 입력할 수 있습니다.
+
+ {lifecycleFlowCards.map((card) => ( +
{ + if (card.role === "시공") return; + openLifecycleAllocationModal({ + project_code: card.project_code, + project_name: card.project_name, + project_type: card.role, + allocation_numerator: card.numerator, + allocation_denominator: card.denominator, + }); + }} + onKeyDown={(event) => { + if (card.role === "시공") return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + openLifecycleAllocationModal({ + project_code: card.project_code, + project_name: card.project_name, + project_type: card.role, + allocation_numerator: card.numerator, + allocation_denominator: card.denominator, + }); + }} + style={{ + border: "1px solid var(--line)", + borderRadius: 14, + background: "white", + padding: "12px 14px", + display: "grid", + gap: 8, + cursor: card.role !== "시공" ? "pointer" : "default", + }} + > +
+
{card.role}
+ {card.role !== "시공" && ( +
{fmt(card.numerator)} / {fmt(card.denominator)}
+ )} + {card.role === "시공" && ( +
현재 프로젝트
+ )} +
+
{card.project_code}
+
{card.project_name || "(이름없음)"}
+ {!!card.note && ( +
{card.note}
+ )} +
+
+
실제 매출
+
{fmt(card.income)}원
+
+
+
실제 매입
+
{fmt(card.expense)}원
+
+
+ {card.role !== "시공" && ( +
+
+
반영 매출
+
{fmt(card.reflected_income)}원
+
+
+
반영 매입
+
{fmt(card.reflected_expense)}원
+
+
+ )} +
+ ))} + {!lifecycleFlowCards.length && ( +
표시할 연결 프로젝트가 없습니다.
+ )} +
+
+
+
+
프로젝트 생애주기 원가
+
현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.
+
+
+
+
총 입금
+
{fmt(detail.lifecycle_cost.summary?.income_supply || 0)}원
+
+
+
총 지출
+
{fmt(detail.lifecycle_cost.summary?.expense_supply || 0)}원
+
+
+
총 수익
+
+ {fmt(detail.lifecycle_cost.summary?.profit_supply || 0)}원 +
+
+
+
+
+ {(detail.lifecycle_cost.breakdown || []).map((item) => ( +
{ + window.__lifecycleBreakdownClicked = item.label || ""; + setLifecycleBreakdownModal({ + label: item.label || "", + expense_supply: Number(item.expense_supply || 0), + projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [], + accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [], + opened_at: Date.now(), + }); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + window.__lifecycleBreakdownClicked = item.label || ""; + setLifecycleBreakdownModal({ + label: item.label || "", + expense_supply: Number(item.expense_supply || 0), + projects: Array.isArray(item.projects) ? item.projects.map((project) => ({ ...project })) : [], + accounts: Array.isArray(item.accounts) ? item.accounts.map((account) => ({ ...account })) : [], + opened_at: Date.now(), + }); + }} + style={{ + border: "1px solid var(--line)", + borderRadius: 16, + background: "white", + padding: "10px 14px 9px", + display: "grid", + gap: 4, + textAlign: "left", + cursor: "pointer", + }} + > +
{item.label}
+
+ {fmt(item.expense_supply || 0)}원 +
+
+ ))} +
+ {lifecycleBreakdownModal && ( +
+
+
+
{lifecycleBreakdownModal.label} 상세
+
연결된 전체 프로젝트 기준으로 묶은 지출 상세입니다.
+
+ +
+
+
+
합계 지출
+
{fmt(lifecycleBreakdownModal.expense_supply || 0)}원
+
+
+
프로젝트 수
+
{fmt((lifecycleBreakdownModal.projects || []).length)}개
+
+
+
계정 수
+
{fmt((lifecycleBreakdownModal.accounts || []).length)}개
+
+
+ +
+
+
프로젝트별 금액
+
연결된 프로젝트들 중 이 항목에 반영된 지출 금액입니다.
+
+ {(lifecycleBreakdownModal.projects || []).length ? ( + (lifecycleBreakdownModal.projects || []).map((item) => ( +
+
+
{item.project_name || "(이름없음)"}
+
+ {item.project_code} · {item.project_type || "미지정"} · {item.construction_family || "종류미지정"} · {item.construction_method || "공법미지정"} +
+
+
{fmt(item.expense_supply || 0)}원
+
+ )) + ) : ( +
표시할 프로젝트가 없습니다.
+ )} +
+
+ +
+
계정별 금액
+
이 항목에 포함된 계정 기준 합계입니다.
+
+ {(lifecycleBreakdownModal.accounts || []).length ? ( + (lifecycleBreakdownModal.accounts || []).map((item) => ( + + )) + ) : ( +
표시할 계정이 없습니다.
+ )} +
+
+
+
+ )} +
+ )} + + {currentTab === "lifecycle" && !((detail?.lifecycle_cost?.rows || []).length > 0) && ( +
+
프로젝트 생애주기 원가
+
+ 현재 프로젝트 금액을 불러올 수 없습니다. 프로젝트 유형과 연결 코드를 확인해 주세요. +
+
+ )} + + {currentTab === "project" && ( + <> +
+
+
+

거래내역

+

프로젝트 원장에서 조회된 입금/출금 내역입니다.

+
+
+ {(detail?.transactions || []).length ? ( +
+ + + + + + + + + + + + + {(detail?.transactions || []).map((row, index) => ( + + + + + + + + + ))} + +
거래일입/출금계정거래처적요공급가액
{row.transaction_date || "-"}{row.in_out || "-"} +
{row.account_name || "-"}
+
{row.account_code || "-"}
+
{row.vendor_name || "-"}{row.description || "-"}{fmt(row.supply_amount || 0)}원
+
+ ) : ( +
표시할 거래내역이 없습니다.
+ )} +
집행률 / 공정률 그래프
@@ -4910,6 +5653,8 @@
+ + )} )}
@@ -5844,6 +6589,192 @@ )} + {lifecycleAccountDetailModal && ( +
setLifecycleAccountDetailModal(null)}> +
e.stopPropagation()} + style={{ width: "min(1080px, calc(100vw - 32px))", maxHeight: "min(760px, calc(100vh - 32px))", overflow: "auto" }} + > +
+
+
+ {lifecycleAccountDetailModal.account_name || lifecycleAccountDetailModal.account_code || "(계정명없음)"} +
+
+ {lifecycleAccountDetailModal.account_code || "-"} · {lifecycleAccountDetailModal.bucket_label} +
+
+ +
+ {lifecycleAccountDetailModalLoading ? ( +
계정 상세를 불러오는 중입니다.
+ ) : lifecycleAccountDetailModal.detail?.summary ? ( + <> +
+
+
지출 합계
+
{fmt(lifecycleAccountDetailModal.detail.summary.expense_supply_sum || 0)}원
+
+
+
거래 건수
+
{fmt(lifecycleAccountDetailModal.detail.summary.txn_count || 0)}건
+
+
+
기간
+
+ {(lifecycleAccountDetailModal.detail.summary.min_date || "-")} + {" ~ "} + {(lifecycleAccountDetailModal.detail.summary.max_date || "-")} +
+
+
+
+
+
프로젝트별 금액
+
연결된 프로젝트들 중 이 계정에 포함된 지출입니다.
+ {(lifecycleAccountDetailModal.detail.projects || []).length ? ( +
+ {(lifecycleAccountDetailModal.detail.projects || []).map((item) => ( + + ))} +
+ ) : ( +
표시할 프로젝트가 없습니다.
+ )} +
+ +
+
거래내역
+
이 계정에 포함된 출금 거래를 확인합니다.
+ {(lifecycleAccountDetailModal.detail.transactions || []).length ? ( +
+ + + + + + + + + + + + {(lifecycleAccountDetailModal.detail.transactions || []).map((row, index) => ( + + + + + + + + ))} + +
거래일프로젝트거래처적요공급가액
{row.transaction_date || "-"} +
{row.project_name || "프로젝트명 없음"}
+
{row.project_code || "-"}
+
{row.vendor_name || "-"}{row.description || "-"}{fmt(row.allocated_supply_amount ?? row.supply_amount ?? 0)}원
+
+ ) : ( +
표시할 거래내역이 없습니다.
+ )} +
+
+ + ) : ( +
계정 상세를 불러오지 못했습니다.
+ )} +
+
+ )} + + {lifecycleAllocationModal && ( +
setLifecycleAllocationModal(null)}> +
e.stopPropagation()} style={{ width: "min(560px, calc(100vw - 24px))" }}> +
+
+
생애주기 배분 비율
+
+ {lifecycleAllocationModal.project_type || "-"} · {lifecycleAllocationModal.source_project_code || "-"} +
+
+ +
+
+
{lifecycleAllocationModal.project_name || "(이름없음)"}
+
+ 해당 프로젝트에 반영할 비율을 입력합니다. 예: 1 / 3 +
+
+ +
/
+ +
+
+
+ + {Number(lifecycleAllocationModal?.allocation_numerator ?? 1) !== 1 || Number(lifecycleAllocationModal?.allocation_denominator ?? 1) !== 1 ? ( + + ) : null} + +
+
+
+ )} + {companyAccountModal && (
setCompanyAccountModal(null)}>
해당 계정이 잡힌 프로젝트를 확인하고, 눌러서 프로젝트 상세로 이동할 수 있습니다.

+ {companyAccountDetailModal.detail?.project_allocation?.enabled && ( +
+
+ 균등 배분 적용 +
+
+ {companyAccountDetailModal.detail.project_allocation.source_project_type || "-"} 금액을 연결 프로젝트 수 기준으로 균등 배분했습니다. + {" "} + 원본 + {" "} + {fmt(companyAccountDetailModal.detail.project_allocation.source_project_count || 0)} + {"개 프로젝트 → "} + {fmt(companyAccountDetailModal.detail.project_allocation.target_project_count || 0)} + {"개 프로젝트"} +
+
+ )} {(companyAccountDetailModal.detail.projects || []).length ? (
{(companyAccountDetailModal.detail.projects || []).map((row) => ( @@ -6577,6 +7525,104 @@ style={{ minHeight: 96, resize: "vertical", paddingTop: 10, paddingBottom: 10 }} />
+
+
관련 프로젝트 코드
+
+ setRelatedProjectSearch(e.target.value)} + placeholder={`${relatedProjectTargetTypes.join(" / ")} 프로젝트 검색해서 연결`} + /> +
+ {groupedSelectedRelatedProjects.map((group) => ( +
+
{group.type} 연결
+ {group.items.length ? ( +
+ {group.items.map((item) => ( + + ))} +
+ ) : ( +
선택된 {group.type} 코드가 없습니다.
+ )} +
+ ))} +
+ {!!filteredRelatedProjectCandidates.length && ( +
+ {groupedRelatedProjectCandidates.map((group) => ( +
+
+ {group.type} 후보 +
+ {group.items.length ? group.items.map((item, index) => ( + + )) : ( +
검색 결과가 없습니다.
+ )} +
+ ))} +
+ )} + {!filteredRelatedProjectCandidates.length && !!relatedProjectSearch.trim() && ( +
검색 결과가 없습니다.
+ )} +
+
diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index 6e4feef..487d594 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -188,6 +188,9 @@ MANAGEMENT_EXCLUDED_ACCOUNT_CODES = { "962", # 잡손실 "999", # 법인세등 } +# In project/project-lifecycle screens, these accounts should be hidden +# entirely from aggregates and detail rows. +PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES = set(MANAGEMENT_EXCLUDED_ACCOUNT_CODES) ACCOUNT_STRUCTURE_TEMPLATE = [ {"section": "수입", "group": "수입", "categories": ["공사수입", "용역수입", "기타수입", "당좌자산"]}, {"section": "영업외 수지", "group": "영업외수익", "categories": ["이자수입", "잡이익", "배당수익"]}, @@ -598,6 +601,28 @@ def init_db() -> None: ) """ ) + cur.execute( + """ + create table if not exists project_relations ( + project_code text not null, + related_project_code text not null, + updated_at text not null, + primary key (project_code, related_project_code) + ) + """ + ) + cur.execute( + """ + create table if not exists project_lifecycle_allocations ( + base_project_code text not null, + source_project_code text not null, + allocation_numerator integer not null default 1, + allocation_denominator integer not null default 1, + updated_at text not null, + primary key (base_project_code, source_project_code) + ) + """ + ) cur.execute( """ create table if not exists project_budget_lines ( @@ -661,6 +686,7 @@ def init_db() -> None: 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)") + cur.execute("create index if not exists idx_project_lifecycle_allocations_base_project_code on project_lifecycle_allocations(base_project_code)") existing_cols = [row["name"] for row in cur.execute("pragma table_info(project_master)").fetchall()] if "construction_family" not in existing_cols: cur.execute("alter table project_master add column construction_family text") @@ -808,6 +834,558 @@ def fetch_project_defaults(conn: sqlite3.Connection, project_code: str) -> dict: return dict(row) if row else {"project_code": project_code, "project_name": "", "project_type": ""} +def build_related_projects(conn: sqlite3.Connection, project_code: str, project_name: str = "") -> list[dict]: + excluded_values = sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES) + excluded_placeholders = ",".join("?" for _ in excluded_values) if excluded_values else "" + excluded_clause = ( + f"and coalesce(tx.account_code_final, '') not in ({excluded_placeholders})" + if excluded_placeholders + else "" + ) + + rows = conn.execute( + f""" + with recursive related_codes(project_code) as ( + select ? + union + select pr.related_project_code + from project_relations pr + join related_codes rc on rc.project_code = pr.project_code + where coalesce(pr.related_project_code, '') <> '' + union + select pr.project_code + from project_relations pr + join related_codes rc on rc.project_code = pr.related_project_code + where coalesce(pr.project_code, '') <> '' + ), + code_set as ( + select distinct project_code + from related_codes + where coalesce(project_code, '') <> '' + ), + project_summary as ( + select + tx.project_code as project_code, + max(tx.project_name) as project_name, + max(tx.project_type) as transaction_project_type, + sum(case when tx.in_out = '입금' then coalesce(tx.supply_amount, 0) else 0 end) as income_supply, + sum(case when tx.in_out = '출금' then coalesce(tx.supply_amount, 0) else 0 end) as expense_supply, + count(*) as txn_count, + min(tx.transaction_date) as min_date, + max(tx.transaction_date) as max_date + from ptc_transactions tx + join code_set cs on cs.project_code = tx.project_code + where 1 = 1 + {excluded_clause} + group by tx.project_code + ), + master_rows as ( + select + pm.project_code as project_code, + pm.project_name as project_name, + pm.project_type as master_project_type, + pm.construction_family as construction_family, + pm.construction_method as construction_method, + pm.start_date as start_date, + pm.end_date as end_date, + pm.note as note + from project_master pm + join code_set cs on cs.project_code = pm.project_code + ) + select + cs.project_code, + coalesce(ps.project_name, mr.project_name, '') as project_name, + coalesce(ps.transaction_project_type, '') as transaction_project_type, + coalesce(mr.master_project_type, '') as master_project_type, + coalesce(mr.construction_family, '') as construction_family, + coalesce(mr.construction_method, '') as construction_method, + coalesce(mr.start_date, '') as start_date, + coalesce(mr.end_date, '') as end_date, + coalesce(mr.note, '') as note, + coalesce(ps.income_supply, 0) as income_supply, + coalesce(ps.expense_supply, 0) as expense_supply, + coalesce(ps.txn_count, 0) as txn_count, + coalesce(ps.min_date, '') as min_date, + coalesce(ps.max_date, '') as max_date + from code_set cs + left join project_summary ps on ps.project_code = cs.project_code + left join master_rows mr on mr.project_code = cs.project_code + order by cs.project_code + """, + [project_code, *excluded_values], + ).fetchall() + + role_order = {"영업": 0, "설계": 1, "시공": 2, "관리": 3} + items: list[dict] = [] + seen_codes: set[str] = set() + for row in rows: + row_dict = dict(row) + code = (row_dict.get("project_code") or "").strip() + if not code or code in seen_codes: + continue + seen_codes.add(code) + resolved_type = resolve_project_type( + code, + row_dict.get("transaction_project_type") or "", + row_dict.get("master_project_type") or "", + ) + income_supply = float(row_dict.get("income_supply") or 0) + expense_supply = float(row_dict.get("expense_supply") or 0) + item = { + "project_code": code, + "project_name": row_dict.get("project_name") or project_name or "", + "project_type": resolved_type, + "construction_family": resolve_construction_family( + row_dict.get("construction_method") or "", + row_dict.get("construction_family") or "", + ), + "construction_method": row_dict.get("construction_method") or "", + "start_date": row_dict.get("start_date") or "", + "end_date": row_dict.get("end_date") or "", + "note": row_dict.get("note") or "", + "income_supply": income_supply, + "expense_supply": expense_supply, + "profit_supply": income_supply - expense_supply, + "txn_count": int(row_dict.get("txn_count") or 0), + "min_date": row_dict.get("min_date") or "", + "max_date": row_dict.get("max_date") or "", + "is_current": code == project_code, + } + items.append(item) + + items.sort(key=lambda item: (role_order.get(item["project_type"], 9), item["project_code"])) + return items + + +def fetch_lifecycle_allocation_map(conn: sqlite3.Connection, base_project_code: str) -> dict[str, dict]: + if not base_project_code: + return {} + + rows = conn.execute( + """ + select + source_project_code, + allocation_numerator, + allocation_denominator + from project_lifecycle_allocations + where base_project_code = ? + """, + (base_project_code,), + ).fetchall() + allocation_map: dict[str, dict] = {} + for row in rows: + source_project_code = (row["source_project_code"] or "").strip() + numerator = int(row["allocation_numerator"] or 0) + denominator = int(row["allocation_denominator"] or 1) + if not source_project_code: + continue + if denominator <= 0: + denominator = 1 + numerator = max(0, min(numerator, denominator)) + allocation_map[source_project_code] = { + "allocation_numerator": numerator, + "allocation_denominator": denominator, + "allocation_ratio": (numerator / denominator) if denominator > 0 else 1.0, + "has_custom_allocation": True, + } + return allocation_map + + +def resolve_lifecycle_allocation(project_type: str, allocation_item: dict | None) -> tuple[int, int, float]: + if project_type in {"영업", "설계"}: + if allocation_item: + numerator = int(allocation_item.get("allocation_numerator") or 0) + denominator = int(allocation_item.get("allocation_denominator") or 1) + if denominator <= 0: + denominator = 1 + numerator = max(0, min(numerator, denominator)) + ratio = numerator / denominator + return numerator, denominator, ratio + return 1, 1, 1.0 + return 1, 1, 1.0 + + +def build_company_allocated_project_rows( + conn: sqlite3.Connection, project_rows: list[sqlite3.Row], source_project_type: str +) -> tuple[list[dict], dict]: + raw_rows = rows_to_dicts(project_rows) + if source_project_type not in {"영업", "설계"} or not raw_rows: + return raw_rows, {"enabled": False} + + related_cache: dict[str, list[str]] = {} + project_meta_cache: dict[str, dict] = {} + allocated_map: dict[str, dict] = {} + + def get_project_meta(project_code: str) -> dict: + cached = project_meta_cache.get(project_code) + if cached is not None: + return cached + + master = fetch_project_master(conn, project_code) or {} + defaults = fetch_project_defaults(conn, project_code) + resolved_type = resolve_project_type( + project_code, + defaults.get("project_type", ""), + master.get("project_type", ""), + ) + item = { + "project_code": project_code, + "project_name": (master.get("project_name") or defaults.get("project_name") or "").strip(), + "project_type": resolved_type, + } + project_meta_cache[project_code] = item + return item + + def get_target_codes(source_project_code: str) -> list[str]: + cached = related_cache.get(source_project_code) + if cached is not None: + return cached + + related = build_related_projects(conn, source_project_code) + candidates = [item for item in related if (item.get("project_code") or "").strip() and item.get("project_code") != source_project_code] + construction_targets = [item for item in candidates if (item.get("project_type") or "").strip() == "시공"] + target_items = construction_targets if construction_targets else candidates + target_codes = sorted({(item.get("project_code") or "").strip() for item in target_items if (item.get("project_code") or "").strip()}) + if not target_codes: + target_codes = [source_project_code] + related_cache[source_project_code] = target_codes + return target_codes + + for row in raw_rows: + source_code = (row.get("project_code") or "").strip() + if not source_code: + continue + + target_codes = get_target_codes(source_code) + divisor = max(len(target_codes), 1) + income_supply = float(row.get("income_supply_sum") or 0) + expense_supply = float(row.get("expense_supply_sum") or 0) + supply_sum = float(row.get("supply_sum") or 0) + txn_count = float(row.get("txn_count") or 0) + income_count = float(row.get("income_count") or 0) + expense_count = float(row.get("expense_count") or 0) + + for target_code in target_codes: + meta = get_project_meta(target_code) + entry = allocated_map.setdefault( + target_code, + { + "project_code": target_code, + "project_name": meta.get("project_name") or target_code, + "project_type": meta.get("project_type") or "", + "txn_count": 0.0, + "income_count": 0.0, + "expense_count": 0.0, + "income_supply_sum": 0.0, + "expense_supply_sum": 0.0, + "supply_sum": 0.0, + }, + ) + entry["txn_count"] += txn_count / divisor + entry["income_count"] += income_count / divisor + entry["expense_count"] += expense_count / divisor + entry["income_supply_sum"] += income_supply / divisor + entry["expense_supply_sum"] += expense_supply / divisor + entry["supply_sum"] += supply_sum / divisor + + allocated_rows = list(allocated_map.values()) + for row in allocated_rows: + row["txn_count"] = int(round(float(row.get("txn_count") or 0))) + row["income_count"] = int(round(float(row.get("income_count") or 0))) + row["expense_count"] = int(round(float(row.get("expense_count") or 0))) + + allocated_rows.sort(key=lambda item: (-float(item.get("supply_sum") or 0), item.get("project_code") or "")) + return allocated_rows, { + "enabled": True, + "mode": "project_count_equal_split", + "source_project_type": source_project_type, + "source_project_count": len(raw_rows), + "target_project_count": len(allocated_rows), + } + + +def build_project_lifecycle_cost( + conn: sqlite3.Connection, related_projects: list[dict], current_project_type: str, base_project_code: str +) -> dict | None: + if current_project_type != "시공": + return None + + allocation_map = fetch_lifecycle_allocation_map(conn, base_project_code) + role_order = ["영업", "설계", "시공", "관리"] + rows = [item for item in related_projects if item.get("project_type") in role_order] + if not rows: + return None + + rows_with_allocation: list[dict] = [] + for item in rows: + row = dict(item) + project_type = (row.get("project_type") or "").strip() + numerator, denominator, ratio = resolve_lifecycle_allocation( + project_type, + allocation_map.get((row.get("project_code") or "").strip()), + ) + row["allocation_numerator"] = numerator + row["allocation_denominator"] = denominator + row["allocation_ratio"] = ratio + row["has_custom_allocation"] = bool(allocation_map.get((row.get("project_code") or "").strip())) + row["adjusted_expense_supply"] = float(row.get("expense_supply") or 0) * ratio + rows_with_allocation.append(row) + + rows.sort(key=lambda item: (role_order.index(item["project_type"]), item["project_code"])) + total_income = sum(float(item.get("income_supply") or 0) for item in rows_with_allocation) + total_expense = sum(float(item.get("adjusted_expense_supply") or 0) for item in rows_with_allocation) + project_codes = [item.get("project_code") for item in rows_with_allocation if item.get("project_code")] + breakdown_totals = {"시공비": 0.0, "인건비": 0.0, "관리비": 0.0} + breakdown_project_maps: dict[str, dict[str, dict]] = { + "시공비": {}, + "인건비": {}, + "관리비": {}, + } + breakdown_account_maps: dict[str, dict[str, dict]] = { + "시공비": {}, + "인건비": {}, + "관리비": {}, + } + project_lookup = {item.get("project_code"): item for item in rows_with_allocation if item.get("project_code")} + + if project_codes: + placeholders = ",".join("?" for _ in project_codes) + excluded_values = sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES) + excluded_placeholders = ",".join("?" for _ in excluded_values) if excluded_values else "" + excluded_clause = ( + f"and coalesce(t.account_code_final, '') not in ({excluded_placeholders})" + if excluded_placeholders + else "" + ) + expense_rows = conn.execute( + f""" + select + t.project_code, + t.project_type, + t.account_code_final as account_code, + coalesce(sum(t.supply_amount), 0) as expense_supply + from ptc_transactions t + where t.in_out = '출금' + and coalesce(t.project_code, '') in ({placeholders}) + {excluded_clause} + group by t.project_code, t.project_type, t.account_code_final + """, + [*project_codes, *excluded_values], + ).fetchall() + + for row in expense_rows: + account_code = (row["account_code"] or "").strip() + project_code = (row["project_code"] or "").strip() + project_type = (row["project_type"] or "").strip() + meta = ACCOUNT_MASTER.get(account_code) + bucket = classify_lifecycle_bucket(account_code, project_code, project_type, meta) + project_info = project_lookup.get(project_code, {}) + numerator = int(project_info.get("allocation_numerator") or 1) + denominator = int(project_info.get("allocation_denominator") or 1) + allocation_ratio = float(project_info.get("allocation_ratio") or 1.0) + expense_supply = float(row["expense_supply"] or 0) * allocation_ratio + + breakdown_totals[bucket] += expense_supply + + project_entry = breakdown_project_maps[bucket].setdefault( + project_code, + { + "project_code": project_code, + "project_name": project_info.get("project_name") or "", + "project_type": project_info.get("project_type") or project_type or "", + "construction_family": project_info.get("construction_family") or "", + "construction_method": project_info.get("construction_method") or "", + "allocation_numerator": numerator, + "allocation_denominator": denominator, + "allocation_ratio": allocation_ratio, + "expense_supply": 0.0, + }, + ) + project_entry["expense_supply"] += expense_supply + + account_entry = breakdown_account_maps[bucket].setdefault( + account_code or "미지정", + { + "account_code": account_code or "", + "account_name": (meta or {}).get("name") or (row["account_code"] or ""), + "expense_supply": 0.0, + }, + ) + account_entry["expense_supply"] += expense_supply + + breakdown = [ + { + "label": "시공비", + "expense_supply": breakdown_totals["시공비"], + "projects": sorted( + breakdown_project_maps["시공비"].values(), + key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""), + ), + "accounts": sorted( + breakdown_account_maps["시공비"].values(), + key=lambda item: (-float(item.get("expense_supply") or 0), item.get("account_code") or ""), + ), + }, + { + "label": "인건비", + "expense_supply": 0.0, + "projects": [], + "accounts": [], + }, + { + "label": "관리비", + "expense_supply": breakdown_totals["관리비"], + "projects": sorted( + breakdown_project_maps["관리비"].values(), + key=lambda item: (-float(item.get("expense_supply") or 0), item.get("project_code") or ""), + ), + "accounts": sorted( + breakdown_account_maps["관리비"].values(), + key=lambda item: (-float(item.get("expense_supply") or 0), item.get("account_code") or ""), + ), + }, + ] + return { + "rows": sorted( + rows_with_allocation, + key=lambda item: (role_order.index(item["project_type"]), item["project_code"]), + ), + "breakdown": breakdown, + "summary": { + "income_supply": total_income, + "expense_supply": total_expense, + "profit_supply": total_income - total_expense, + }, + } + + +def classify_lifecycle_bucket(account_code: str, project_code: str, project_type: str, meta: dict | None = None) -> str: + meta = meta or ACCOUNT_MASTER.get(account_code) + if meta: + if meta.get("category") == "인건비": + return "시공비" + if meta.get("project_type") == "시공": + return "시공비" + return "관리비" + if "-시공-" in project_code or project_type == "시공": + return "시공비" + return "관리비" + + +def build_lifecycle_account_detail( + conn: sqlite3.Connection, + related_projects: list[dict], + base_project_code: str, + current_project_type: str, + bucket_label: str, + account_code: str, +) -> dict | None: + if current_project_type != "시공" or not account_code: + return None + + role_order = ["영업", "설계", "시공", "관리"] + rows = [item for item in related_projects if item.get("project_type") in role_order] + if not rows: + return None + + project_codes = [item.get("project_code") for item in rows if item.get("project_code")] + if not project_codes: + return None + + placeholders = ",".join("?" for _ in project_codes) + excluded_values = sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES) + excluded_placeholders = ",".join("?" for _ in excluded_values) if excluded_values else "" + excluded_clause = ( + f"and coalesce(account_code_final, '') not in ({excluded_placeholders})" + if excluded_placeholders + else "" + ) + query_values = [account_code, *project_codes, *excluded_values] + tx_rows = conn.execute( + f""" + select + source_row_no, + transaction_date, + in_out, + project_code, + project_name, + project_type, + vendor_name, + department_name, + description, + account_code_final as account_code, + account_name_final as account_name, + supply_amount + from ptc_transactions + where in_out = '출금' + and coalesce(account_code_final, '') = ? + and coalesce(project_code, '') in ({placeholders}) + {excluded_clause} + order by transaction_date desc, source_row_no desc + """, + query_values, + ).fetchall() + + allocation_map = fetch_lifecycle_allocation_map(conn, base_project_code) + related_project_type_map = { + (item.get("project_code") or "").strip(): (item.get("project_type") or "").strip() + for item in related_projects + if (item.get("project_code") or "").strip() + } + + filtered_transactions: list[dict] = [] + project_map: dict[str, dict] = {} + for row in tx_rows: + row_dict = dict(row) + tx_project_code = (row_dict.get("project_code") or "").strip() + tx_project_type = related_project_type_map.get(tx_project_code) or (row_dict.get("project_type") or "").strip() + meta = ACCOUNT_MASTER.get(account_code) + bucket = classify_lifecycle_bucket(account_code, tx_project_code, tx_project_type, meta) + if bucket != bucket_label: + continue + numerator, denominator, ratio = resolve_lifecycle_allocation(tx_project_type, allocation_map.get(tx_project_code)) + allocated_supply_amount = float(row_dict.get("supply_amount") or 0) * ratio + row_dict["allocated_supply_amount"] = allocated_supply_amount + row_dict["allocation_numerator"] = numerator + row_dict["allocation_denominator"] = denominator + row_dict["allocation_ratio"] = ratio + filtered_transactions.append(row_dict) + project_entry = project_map.setdefault( + tx_project_code or "-", + { + "project_code": tx_project_code or "", + "project_name": row_dict.get("project_name") or "", + "project_type": tx_project_type or "", + "allocation_numerator": numerator, + "allocation_denominator": denominator, + "allocation_ratio": ratio, + "expense_supply_sum": 0.0, + "txn_count": 0, + }, + ) + project_entry["expense_supply_sum"] += allocated_supply_amount + project_entry["txn_count"] += 1 + + summary = { + "account_code": account_code, + "account_name": (ACCOUNT_MASTER.get(account_code) or {}).get("name") or account_code, + "income_supply_sum": 0.0, + "expense_supply_sum": sum(float(row.get("allocated_supply_amount") or 0) for row in filtered_transactions), + "txn_count": len(filtered_transactions), + "min_date": min((row.get("transaction_date") or "" for row in filtered_transactions), default=""), + "max_date": max((row.get("transaction_date") or "" for row in filtered_transactions), default=""), + } + + return { + "summary": summary, + "projects": sorted( + project_map.values(), + key=lambda item: (-float(item.get("expense_supply_sum") or 0), item.get("project_code") or ""), + ), + "transactions": filtered_transactions[:100], + } + + def get_project_account_issues(conn: sqlite3.Connection, project_code: str, resolved_project_type: str) -> list[dict]: allowed_codes = ALLOWED_ACCOUNT_CODES_BY_PROJECT_TYPE.get(resolved_project_type) if not allowed_codes: @@ -1084,6 +1662,10 @@ 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]: clauses = ["project_code = ?"] values = [project_code] + if PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES: + excluded_placeholders = ",".join("?" for _ in PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES) + clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})") + values.extend(sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)) if keyword.strip(): like = f"%{keyword.strip().lower()}%" @@ -1241,6 +1823,15 @@ class Handler(BaseHTTPRequestHandler): start_date = str(payload.get("start_date", "")).strip() end_date = str(payload.get("end_date", "")).strip() note = str(payload.get("note", "")).strip() + raw_related_project_codes = payload.get("related_project_codes", []) + if isinstance(raw_related_project_codes, str): + raw_related_project_codes = re.split(r"[\s,]+", raw_related_project_codes) + related_project_codes = [] + for code in raw_related_project_codes if isinstance(raw_related_project_codes, list) else []: + normalized = str(code or "").strip() + if not normalized or normalized == project_code or normalized in related_project_codes: + continue + related_project_codes.append(normalized) updated_at = datetime.now().isoformat() conn.execute( @@ -1260,8 +1851,103 @@ class Handler(BaseHTTPRequestHandler): """, (project_code, project_name, project_type, construction_family, construction_method, start_date, end_date, note, updated_at), ) + conn.execute( + "delete from project_relations where project_code = ? or related_project_code = ?", + (project_code, project_code), + ) + for related_project_code in related_project_codes: + conn.execute( + """ + insert or replace into project_relations(project_code, related_project_code, updated_at) + values (?, ?, ?) + """, + (project_code, related_project_code, updated_at), + ) + conn.execute( + """ + insert or replace into project_relations(project_code, related_project_code, updated_at) + values (?, ?, ?) + """, + (related_project_code, project_code, updated_at), + ) conn.commit() - self._send(200, {"ok": True, "item": fetch_project_master(conn, project_code)}) + item = fetch_project_master(conn, project_code) or {} + item["related_projects"] = build_related_projects(conn, project_code, item.get("project_name") or project_name) + self._send(200, {"ok": True, "item": item}) + return + + if parsed.path == "/api/lifecycle-allocation/upsert": + payload = self._read_json() + base_project_code = str(payload.get("base_project_code", "")).strip() + source_project_code = str(payload.get("source_project_code", "")).strip() + allocation_numerator = int(payload.get("allocation_numerator", 1) or 0) + allocation_denominator = int(payload.get("allocation_denominator", 1) or 1) + if not base_project_code or not source_project_code: + self._send(400, {"ok": False, "message": "base_project_code and source_project_code are required"}) + return + if allocation_denominator <= 0: + self._send(400, {"ok": False, "message": "allocation_denominator must be greater than 0"}) + return + if allocation_numerator < 0: + self._send(400, {"ok": False, "message": "allocation_numerator must be 0 or greater"}) + return + if allocation_numerator > allocation_denominator: + self._send(400, {"ok": False, "message": "allocation_numerator must be <= allocation_denominator"}) + return + + updated_at = datetime.now().isoformat() + conn.execute( + """ + insert into project_lifecycle_allocations ( + base_project_code, source_project_code, allocation_numerator, allocation_denominator, updated_at + ) values (?, ?, ?, ?, ?) + on conflict(base_project_code, source_project_code) do update set + allocation_numerator = excluded.allocation_numerator, + allocation_denominator = excluded.allocation_denominator, + updated_at = excluded.updated_at + """, + ( + base_project_code, + source_project_code, + allocation_numerator, + allocation_denominator, + updated_at, + ), + ) + conn.commit() + self._send( + 200, + { + "ok": True, + "item": { + "base_project_code": base_project_code, + "source_project_code": source_project_code, + "allocation_numerator": allocation_numerator, + "allocation_denominator": allocation_denominator, + "allocation_ratio": allocation_numerator / allocation_denominator if allocation_denominator > 0 else 1.0, + "updated_at": updated_at, + }, + }, + ) + return + + if parsed.path == "/api/lifecycle-allocation/delete": + payload = self._read_json() + base_project_code = str(payload.get("base_project_code", "")).strip() + source_project_code = str(payload.get("source_project_code", "")).strip() + if not base_project_code or not source_project_code: + self._send(400, {"ok": False, "message": "base_project_code and source_project_code are required"}) + return + conn.execute( + """ + delete from project_lifecycle_allocations + where base_project_code = ? + and source_project_code = ? + """, + (base_project_code, source_project_code), + ) + conn.commit() + self._send(200, {"ok": True}) return if parsed.path == "/api/project-master/batch-update-method": @@ -2178,18 +2864,10 @@ class Handler(BaseHTTPRequestHandler): date_to = params.get("date_to", [""])[0].strip() clauses = ["project_code is not null", "project_code <> ''"] values = [] - if keyword: - like = f"%{keyword}%" - clauses.append( - """ - ( - lower(coalesce(project_code, '')) like ? - or lower(coalesce(project_name, '')) like ? - or lower(coalesce(project_type, '')) like ? - ) - """ - ) - values.extend([like, like, like]) + if PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES: + excluded_placeholders = ",".join("?" for _ in PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES) + clauses.append(f"coalesce(account_code_final, '') not in ({excluded_placeholders})") + values.extend(sorted(PROJECT_VIEW_EXCLUDED_ACCOUNT_CODES)) if project_type and project_type != "전체": clauses.append("project_type = ?") values.append(project_type) @@ -2252,6 +2930,38 @@ class Handler(BaseHTTPRequestHandler): item["construction_family"] = "" item["construction_method"] = "" item["note"] = "" + related_projects = build_related_projects( + conn, + item["project_code"], + item.get("project_name") or "", + ) + related_search_terms = [] + for rel in related_projects: + related_search_terms.extend( + [ + rel.get("project_code") or "", + rel.get("project_name") or "", + rel.get("project_type") or "", + ] + ) + item["related_projects"] = related_projects + item["related_search_text"] = " ".join( + filter( + None, + [ + item.get("project_code") or "", + item.get("project_name") or "", + item.get("project_type") or "", + *related_search_terms, + ], + ) + ).lower() + if keyword: + items = [ + item + for item in items + if keyword in (item.get("related_search_text") or "") + ] self._send(200, {"items": items}) return @@ -2945,11 +3655,16 @@ class Handler(BaseHTTPRequestHandler): detail_values, ).fetchall() + allocated_projects, allocation_meta = build_company_allocated_project_rows( + conn, project_rows, project_type + ) + self._send( 200, { "summary": dict(summary) if summary else None, - "projects": rows_to_dicts(project_rows), + "projects": allocated_projects, + "project_allocation": allocation_meta, "transactions": rows_to_dicts(transaction_rows), }, ) @@ -3162,6 +3877,32 @@ class Handler(BaseHTTPRequestHandler): ) return + if parsed.path == "/api/lifecycle-account-detail": + project_code = params.get("project_code", [""])[0].strip() + bucket_label = params.get("bucket_label", [""])[0].strip() + account_code = params.get("account_code", [""])[0].strip() + if not project_code or not bucket_label or not account_code: + self._send(400, {"ok": False, "message": "project_code, bucket_label and account_code are required"}) + return + + master = fetch_project_master(conn, project_code) or fetch_project_defaults(conn, project_code) + project_name = (master or {}).get("project_name") or "" + resolved_project_type = resolve_project_type(project_code, (master or {}).get("project_type") or "") + related_projects = build_related_projects(conn, project_code, project_name) + detail = build_lifecycle_account_detail( + conn, + related_projects, + project_code, + resolved_project_type, + bucket_label, + account_code, + ) + if not detail: + self._send(404, {"ok": False, "message": "lifecycle account detail not found"}) + return + self._send(200, detail) + return + if parsed.path == "/api/management-excluded-account-detail": account_code = params.get("account_code", [""])[0].strip() date_from = params.get("date_from", [""])[0].strip() @@ -3462,6 +4203,17 @@ class Handler(BaseHTTPRequestHandler): summary_dict["start_date"] = "" summary_dict["end_date"] = "" summary_dict["note"] = "" + related_projects = build_related_projects( + conn, + project_code, + summary_dict.get("project_name") if summary_dict else "", + ) + lifecycle_cost = build_project_lifecycle_cost( + conn, + related_projects, + summary_dict["project_type"] if summary_dict else "", + project_code, + ) account_issues = get_project_account_issues( conn, project_code, @@ -3479,6 +4231,8 @@ class Handler(BaseHTTPRequestHandler): "accounts": rows_to_dicts(account_rows), "account_issues": account_issues, "transactions": rows_to_dicts(transaction_rows), + "related_projects": related_projects, + "lifecycle_cost": lifecycle_cost, }, ) return