- {!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(" · ")}
+
+ )}
-
+ {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 (
+
{
+ if (currentTab === "lifecycle" && ["영업", "설계"].includes(item.project_type || "")) {
+ openLifecycleAllocationModal(item);
+ return;
+ }
+ setSelectedProjectCode(item.project_code);
+ }}
+ style={{
+ textAlign: "left",
+ border: isCurrent ? "1.5px solid var(--blue)" : "1px solid var(--line)",
+ borderRadius: 16,
+ background: isCurrent ? "rgba(47, 108, 164, 0.06)" : "white",
+ padding: "14px 16px",
+ display: "grid",
+ gap: 8,
+ cursor: "pointer"
+ }}
+ >
+
+ {item.project_code}
+ {item.project_type || "미지정"}
+
+ {item.project_name || "(이름없음)"}
+ {item.construction_family || "종류미지정"} · {item.construction_method || "공법미지정"}
+
+
+
입금
+
{fmt(displayIncome)}원
+
+
+
지출
+
{fmt(displayExpense)}원
+
+
+
수익
+
+ {fmt(displayProfit)}원
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {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} 상세
+
연결된 전체 프로젝트 기준으로 묶은 지출 상세입니다.
+
+
setLifecycleBreakdownModal(null)}>닫기
+
+
+
+
합계 지출
+
{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) => (
+
{
+ if (!selectedProjectCode) return;
+ setLifecycleAccountDetailModal({
+ project_code: selectedProjectCode,
+ bucket_label: lifecycleBreakdownModal.label,
+ account_code: item.account_code || "",
+ account_name: item.account_name || "",
+ detail: null,
+ });
+ }}
+ style={{
+ textAlign: "left",
+ border: "1px solid var(--line)",
+ borderRadius: 14,
+ background: "white",
+ padding: "12px 14px",
+ display: "grid",
+ gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
+ gap: 12,
+ alignItems: "center",
+ cursor: item.account_code ? "pointer" : "default",
+ }}
+ >
+
+
{item.account_name || "미지정 계정"}
+
{item.account_code || "-"}
+
+ {fmt(item.expense_supply || 0)}원
+
+ ))
+ ) : (
+
표시할 계정이 없습니다.
+ )}
+
+
+
+
+ )}
+
+ )}
+
+ {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}
+
+
+
setLifecycleAccountDetailModal(null)}>닫기
+
+ {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) => (
+
{
+ setSelectedProjectCode(item.project_code);
+ setLifecycleAccountDetailModal(null);
+ }}
+ style={{
+ textAlign: "left",
+ border: "1px solid var(--line)",
+ borderRadius: 14,
+ background: "white",
+ padding: "12px 14px",
+ display: "grid",
+ gridTemplateColumns: "minmax(220px, 1fr) minmax(140px, 0.4fr)",
+ gap: 12,
+ alignItems: "center",
+ cursor: "pointer",
+ }}
+ >
+
+
{item.project_name || "(이름없음)"}
+
+ {item.project_code || "-"} · {item.project_type || "미지정"}
+
+
+ {fmt(item.expense_supply_sum || 0)}원
+
+ ))}
+
+ ) : (
+ 표시할 프로젝트가 없습니다.
+ )}
+
+
+
+ 거래내역
+ 이 계정에 포함된 출금 거래를 확인합니다.
+ {(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 || "-"}
+
+
+
setLifecycleAllocationModal(null)}>닫기
+
+
+
{lifecycleAllocationModal.project_name || "(이름없음)"}
+
+ 해당 프로젝트에 반영할 비율을 입력합니다. 예: 1 / 3
+
+
+
+
/
+
+
+
+
+ setLifecycleAllocationModal(null)}>취소
+ {Number(lifecycleAllocationModal?.allocation_numerator ?? 1) !== 1 || Number(lifecycleAllocationModal?.allocation_denominator ?? 1) !== 1 ? (
+
+ {lifecycleAllocationSaving ? "삭제 중..." : "삭제"}
+
+ ) : null}
+
+ {lifecycleAllocationSaving ? "저장 중..." : "배분 저장"}
+
+
+
+
+ )}
+
{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) => (
+ setEditor((prev) => ({
+ ...prev,
+ related_project_codes: normalizedSelectedRelatedProjectCodes.filter((code) => code !== item.project_code).join(", ")
+ }))}
+ style={{
+ border: "1px solid var(--line)",
+ background: "#f8fbff",
+ borderRadius: 999,
+ padding: "7px 12px",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 8,
+ cursor: "pointer",
+ fontSize: 13,
+ fontWeight: 700
+ }}
+ >
+ {item.project_code}
+
+ {item.project_name || item.project_type || "연결됨"}
+
+ ×
+
+ ))}
+
+ ) : (
+
선택된 {group.type} 코드가 없습니다.
+ )}
+
+ ))}
+
+ {!!filteredRelatedProjectCandidates.length && (
+
+ {groupedRelatedProjectCandidates.map((group) => (
+
+
+ {group.type} 후보
+
+ {group.items.length ? group.items.map((item, index) => (
+
{
+ setEditor((prev) => ({
+ ...prev,
+ related_project_codes: [...normalizedSelectedRelatedProjectCodes, item.project_code].join(", ")
+ }));
+ }}
+ style={{
+ width: "100%",
+ textAlign: "left",
+ border: "none",
+ borderBottom: index === group.items.length - 1 ? "none" : "1px solid var(--line)",
+ background: "white",
+ padding: "10px 12px",
+ cursor: "pointer",
+ display: "grid",
+ gap: 4
+ }}
+ >
+
+ {item.project_code}
+ {item.project_type || "미지정"}
+
+ {item.project_name || "(이름없음)"}
+ {item.construction_family || "종류미지정"} · {item.construction_method || "공법미지정"}
+
+ )) : (
+
검색 결과가 없습니다.
+ )}
+
+ ))}
+
+ )}
+ {!filteredRelatedProjectCandidates.length && !!relatedProjectSearch.trim() && (
+
검색 결과가 없습니다.
+ )}
+
+
setProjectEditModalOpen(false)}>취소
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