From 9891ea0a3292b71c407e1537cf516286fb0add73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=98=9C=EC=9D=B8?= Date: Mon, 4 May 2026 13:55:41 +0900 Subject: [PATCH] Adjust lifecycle allocation UI and account-level shared cost breakdown --- PTC/management_dashboard_preview.html | 163 ++++++++++++++-- server/ptc_api_server.py | 257 +++++++++++++++++++++++--- 2 files changed, 380 insertions(+), 40 deletions(-) diff --git a/PTC/management_dashboard_preview.html b/PTC/management_dashboard_preview.html index af949d3..44970fd 100644 --- a/PTC/management_dashboard_preview.html +++ b/PTC/management_dashboard_preview.html @@ -1925,6 +1925,7 @@ const [lifecycleAllocationModal, setLifecycleAllocationModal] = useState(null); const [lifecycleAllocationSaving, setLifecycleAllocationSaving] = useState(false); const [lifecycleCommonAllocationSaving, setLifecycleCommonAllocationSaving] = useState(false); + const [lifecycleCommonAllocationDraft, setLifecycleCommonAllocationDraft] = useState(""); const [projectEditModalOpen, setProjectEditModalOpen] = useState(false); const [relatedProjectSearch, setRelatedProjectSearch] = useState(""); const [projectTxnDateFrom, setProjectTxnDateFrom] = useState(""); @@ -1963,6 +1964,13 @@ }); const deferredProjectKeyword = useDeferredValue(projectKeyword); const deferredVendorKeyword = useDeferredValue(vendorKeyword); + const effectiveCommonAllocationMode = lifecycleCommonAllocationDraft + || detail?.lifecycle_cost?.summary?.common_allocation_mode + || "expense_ratio"; + + useEffect(() => { + setLifecycleCommonAllocationDraft(""); + }, [selectedProjectCode]); const debouncedProjectKeyword = useDebouncedValue(deferredProjectKeyword, 250); const debouncedVendorKeyword = useDebouncedValue(deferredVendorKeyword, 250); const deferredRelatedProjectSearch = useDeferredValue(relatedProjectSearch); @@ -3609,6 +3617,8 @@ async function saveLifecycleCommonAllocationMode(nextMode) { if (!selectedProjectCode) return; if (!["expense_ratio", "income_ratio"].includes(nextMode || "")) return; + if (effectiveCommonAllocationMode === nextMode) return; + setLifecycleCommonAllocationDraft(nextMode); setLifecycleCommonAllocationSaving(true); setError(""); try { @@ -3628,6 +3638,7 @@ setDetail(nextDetail); } } catch (err) { + setLifecycleCommonAllocationDraft(""); setError("공통배분 기준 저장에 실패했습니다."); } finally { setLifecycleCommonAllocationSaving(false); @@ -5211,17 +5222,36 @@
프로젝트 생애주기 원가
현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.
-
- 공통배분 기준 - +
+ 공통배분 기준 +
+ + +
+
+
+ 본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값) +
+
+ 기준값: {effectiveCommonAllocationMode === "income_ratio" ? "프로젝트 입금 / 전체입금" : "프로젝트 지출 / 전체지출"} +
+
{lifecycleCommonAllocationSaving ? "저장 중..." : ""}
@@ -5393,6 +5423,12 @@ bucket_label: lifecycleBreakdownModal.label, account_code: item.account_code || "", account_name: item.account_name || "", + allocation_source_amount: Number(item.allocation_source_amount || 0), + allocation_project_basis_amount: Number(item.allocation_project_basis_amount || 0), + allocation_total_basis_amount: Number(item.allocation_total_basis_amount || 0), + allocation_mode: item.allocation_mode || "", + allocation_result_amount: Number(item.expense_supply || 0), + allocation_details: Array.isArray(item.allocation_details) ? item.allocation_details.map((row) => ({ ...row })) : [], detail: null, }); }} @@ -5413,6 +5449,37 @@
{item.account_name || "미지정 계정"}
{item.account_code || "-"}
+ {((item.account_code || "").startsWith("SHARED_")) && ( +
+ 본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값) +
+ 기준값: {item.allocation_mode === "income_ratio" ? "프로젝트 입금 / 전체입금" : "프로젝트 지출 / 전체지출"} +
+ {(Array.isArray(item.allocation_details) && item.allocation_details.length + ? item.allocation_details + : [{ + source_amount: item.allocation_source_amount || 0, + project_basis_amount: item.allocation_project_basis_amount || 0, + total_basis_amount: item.allocation_total_basis_amount || 0, + allocated_amount: item.expense_supply || 0, + }] + ).map((row, idx) => { + const sourceAmount = Number(row.source_amount || 0); + const allocatedAmount = Number(row.allocated_amount || 0); + const projectBasisAmount = Number( + row.display_project_basis_amount ?? row.project_basis_amount ?? 0 + ); + const totalBasisAmount = Number( + row.display_total_basis_amount ?? row.total_basis_amount ?? 0 + ); + return ( + + {((row.year_month || "").slice(0, 4) || "-")}년 · {fmt(sourceAmount)}원 x ({fmt(projectBasisAmount)}원 / {fmt(totalBasisAmount)}원) = {fmt(allocatedAmount)}원 + + ); + })} +
+ )}
{fmt(item.expense_supply || 0)}원
@@ -6755,6 +6822,48 @@
+ {((lifecycleAccountDetailModal.account_code || "").startsWith("SHARED_") || + Number(lifecycleAccountDetailModal.allocation_source_amount || 0) > 0 || + Number(lifecycleAccountDetailModal.detail?.allocation_source_amount || 0) > 0) && ( +
+
배부 계산식
+
+ 본사관리비 배부원천 x (프로젝트 기준값 / 전체 기준값) +
+
+ 기준값: {(lifecycleAccountDetailModal.detail?.allocation_mode || lifecycleAccountDetailModal.allocation_mode) === "income_ratio" + ? "프로젝트 입금 / 전체입금" + : "프로젝트 지출 / 전체지출"} +
+
+ {(() => { + const rows = (Array.isArray(lifecycleAccountDetailModal.detail?.allocation_details) && lifecycleAccountDetailModal.detail.allocation_details.length) + ? lifecycleAccountDetailModal.detail.allocation_details + : (Array.isArray(lifecycleAccountDetailModal.allocation_details) ? lifecycleAccountDetailModal.allocation_details : []); + const fallback = [{ + year_month: "", + source_amount: lifecycleAccountDetailModal.detail?.allocation_source_amount ?? lifecycleAccountDetailModal.allocation_source_amount ?? 0, + project_basis_amount: lifecycleAccountDetailModal.detail?.allocation_project_basis_amount ?? lifecycleAccountDetailModal.allocation_project_basis_amount ?? 0, + total_basis_amount: lifecycleAccountDetailModal.detail?.allocation_total_basis_amount ?? lifecycleAccountDetailModal.allocation_total_basis_amount ?? 0, + allocated_amount: lifecycleAccountDetailModal.allocation_result_amount ?? 0, + }]; + const list = rows.length ? rows : fallback; + return list.map((row, idx) => { + const sourceAmount = Number(row.source_amount || 0); + const allocatedAmount = Number(row.allocated_amount || 0); + const projectBasisAmount = Number(row.display_project_basis_amount ?? row.project_basis_amount ?? 0); + const totalBasisAmount = Number(row.display_total_basis_amount ?? row.total_basis_amount ?? 0); + return ( +
+ {((row.year_month || "").slice(0, 4) || "-")}년 · {fmt(sourceAmount)}원 x ({fmt(projectBasisAmount)}원 / {fmt(totalBasisAmount)}원) = {fmt(allocatedAmount)}원 +
+ ); + }); + })()} +
+
+ )} +
프로젝트별 금액
연결된 프로젝트들 중 이 계정에 포함된 지출입니다.
@@ -6831,6 +6940,38 @@
표시할 거래내역이 없습니다.
)}
+ +
+
계정별 금액
+
현재 선택한 계정 기준 금액입니다.
+
+
+
+
{lifecycleAccountDetailModal.account_name || "(계정명없음)"}
+
{lifecycleAccountDetailModal.account_code || "-"}
+
+
+ {fmt( + (lifecycleAccountDetailModal.account_code || "").startsWith("SHARED_") + ? Number(lifecycleAccountDetailModal.allocation_result_amount || 0) + : Number(lifecycleAccountDetailModal.detail?.summary?.expense_supply_sum || 0) + )}원 +
+
+
+
) : ( diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index 4b008bf..1d3b5fe 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -1109,7 +1109,18 @@ def calculate_monthly_shared_distribution( base_range = project_ranges.get(base_project_code) if not base_range: - return {"labor_shared": 0.0, "common_shared": 0.0} + return { + "labor_shared": 0.0, + "common_shared": 0.0, + "labor_source_total": 0.0, + "common_source_total": 0.0, + "labor_project_basis_total": 0.0, + "common_project_basis_total": 0.0, + "labor_overall_basis_total": 0.0, + "common_overall_basis_total": 0.0, + "labor_allocation_details": [], + "common_allocation_details": [], + } pool_rows = conn.execute( """ @@ -1127,6 +1138,8 @@ def calculate_monthly_shared_distribution( labor_pool_by_month: dict[str, float] = defaultdict(float) common_pool_by_month: dict[str, float] = defaultdict(float) + labor_pool_accounts_by_month: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float)) + common_pool_accounts_by_month: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float)) for row in pool_rows: ym = (row["ym"] or "").strip() code = (row["account_code"] or "").strip() @@ -1138,12 +1151,25 @@ def calculate_monthly_shared_distribution( continue if meta.get("category") == "인건비": labor_pool_by_month[ym] += amount + labor_pool_accounts_by_month[ym][code] += amount else: common_pool_by_month[ym] += amount + common_pool_accounts_by_month[ym][code] += amount candidate_months = sorted(set([*labor_pool_by_month.keys(), *common_pool_by_month.keys()])) if not candidate_months: - return {"labor_shared": 0.0, "common_shared": 0.0} + return { + "labor_shared": 0.0, + "common_shared": 0.0, + "labor_source_total": 0.0, + "common_source_total": 0.0, + "labor_project_basis_total": 0.0, + "common_project_basis_total": 0.0, + "labor_overall_basis_total": 0.0, + "common_overall_basis_total": 0.0, + "labor_allocation_details": [], + "common_allocation_details": [], + } month_active_projects: dict[str, set[str]] = defaultdict(set) for project_code, (start_ym, end_ym) in project_ranges.items(): @@ -1176,6 +1202,16 @@ def calculate_monthly_shared_distribution( base_start_ym, base_end_ym = base_range labor_shared = 0.0 common_shared = 0.0 + labor_source_total = 0.0 + common_source_total = 0.0 + labor_project_basis_total = 0.0 + common_project_basis_total = 0.0 + labor_overall_basis_total = 0.0 + common_overall_basis_total = 0.0 + labor_allocation_details: list[dict] = [] + common_allocation_details: list[dict] = [] + labor_account_allocated: dict[str, float] = defaultdict(float) + common_account_allocated: dict[str, float] = defaultdict(float) for ym in candidate_months: if ym < base_start_ym: continue @@ -1196,13 +1232,70 @@ def calculate_monthly_shared_distribution( if total_value > 0: ratio = max(0.0, min(1.0, base_value / total_value)) + display_project_basis_amount = base_value + display_total_basis_amount = total_value + used_equal_split = False else: ratio = 1.0 / len(active_projects) + display_project_basis_amount = 1.0 + display_total_basis_amount = float(len(active_projects)) + used_equal_split = True - labor_shared += float(labor_pool_by_month.get(ym) or 0) * ratio - common_shared += float(common_pool_by_month.get(ym) or 0) * ratio + labor_pool = float(labor_pool_by_month.get(ym) or 0) + common_pool = float(common_pool_by_month.get(ym) or 0) + labor_shared += labor_pool * ratio + common_shared += common_pool * ratio + labor_source_total += labor_pool + common_source_total += common_pool + labor_project_basis_total += base_value + common_project_basis_total += base_value + labor_overall_basis_total += total_value + common_overall_basis_total += total_value + if labor_pool: + labor_allocation_details.append( + { + "year_month": ym, + "source_amount": labor_pool, + "project_basis_amount": base_value, + "total_basis_amount": total_value, + "display_project_basis_amount": display_project_basis_amount, + "display_total_basis_amount": display_total_basis_amount, + "used_equal_split": used_equal_split, + "allocated_amount": labor_pool * ratio, + } + ) + for account_code, account_amount in (labor_pool_accounts_by_month.get(ym) or {}).items(): + labor_account_allocated[account_code] += float(account_amount or 0.0) * ratio + if common_pool: + common_allocation_details.append( + { + "year_month": ym, + "source_amount": common_pool, + "project_basis_amount": base_value, + "total_basis_amount": total_value, + "display_project_basis_amount": display_project_basis_amount, + "display_total_basis_amount": display_total_basis_amount, + "used_equal_split": used_equal_split, + "allocated_amount": common_pool * ratio, + } + ) + for account_code, account_amount in (common_pool_accounts_by_month.get(ym) or {}).items(): + common_account_allocated[account_code] += float(account_amount or 0.0) * ratio - return {"labor_shared": labor_shared, "common_shared": common_shared} + return { + "labor_shared": labor_shared, + "common_shared": common_shared, + "labor_source_total": labor_source_total, + "common_source_total": common_source_total, + "labor_project_basis_total": labor_project_basis_total, + "common_project_basis_total": common_project_basis_total, + "labor_overall_basis_total": labor_overall_basis_total, + "common_overall_basis_total": common_overall_basis_total, + "labor_allocation_details": labor_allocation_details, + "common_allocation_details": common_allocation_details, + "labor_account_allocated": dict(labor_account_allocated), + "common_account_allocated": dict(common_account_allocated), + } def build_company_allocated_project_rows( @@ -1462,18 +1555,59 @@ def build_project_lifecycle_cost( ) project_entry["shared_expense_supply"] += labor_shared project_entry["expense_supply"] += labor_shared - account_entry = breakdown_account_maps["인건비"].setdefault( - "SHARED_LABOR", - { - "account_code": "SHARED_LABOR", - "account_name": "월별 공통배분(인건비)", - "direct_expense_supply": 0.0, - "shared_expense_supply": 0.0, - "expense_supply": 0.0, - }, - ) - account_entry["shared_expense_supply"] += labor_shared - account_entry["expense_supply"] += labor_shared + labor_account_allocated = monthly_shared.get("labor_account_allocated") or {} + if labor_account_allocated: + for shared_code, shared_amount in labor_account_allocated.items(): + shared_code_str = (shared_code or "").strip() + if not shared_code_str: + continue + shared_amount_value = float(shared_amount or 0.0) + shared_meta = ACCOUNT_MASTER.get(shared_code_str) or {} + account_entry = breakdown_account_maps["인건비"].setdefault( + shared_code_str, + { + "account_code": shared_code_str, + "account_name": shared_meta.get("name") or shared_code_str, + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, + "expense_supply": 0.0, + "allocation_source_amount": 0.0, + "allocation_project_basis_amount": 0.0, + "allocation_total_basis_amount": 0.0, + "allocation_mode": common_allocation_mode, + "allocation_details": [], + }, + ) + account_entry["shared_expense_supply"] += shared_amount_value + account_entry["expense_supply"] += shared_amount_value + account_entry["allocation_source_amount"] = float(monthly_shared.get("labor_source_total") or 0.0) + account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("labor_project_basis_total") or 0.0) + account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("labor_overall_basis_total") or 0.0) + account_entry["allocation_mode"] = common_allocation_mode + account_entry["allocation_details"] = list(monthly_shared.get("labor_allocation_details") or []) + else: + account_entry = breakdown_account_maps["인건비"].setdefault( + "SHARED_LABOR", + { + "account_code": "SHARED_LABOR", + "account_name": "월별 공통배분(인건비)", + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, + "expense_supply": 0.0, + "allocation_source_amount": 0.0, + "allocation_project_basis_amount": 0.0, + "allocation_total_basis_amount": 0.0, + "allocation_mode": common_allocation_mode, + "allocation_details": [], + }, + ) + account_entry["shared_expense_supply"] += labor_shared + account_entry["expense_supply"] += labor_shared + account_entry["allocation_source_amount"] = float(monthly_shared.get("labor_source_total") or 0.0) + account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("labor_project_basis_total") or 0.0) + account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("labor_overall_basis_total") or 0.0) + account_entry["allocation_mode"] = common_allocation_mode + account_entry["allocation_details"] = list(monthly_shared.get("labor_allocation_details") or []) if common_shared: breakdown_components["관리비"]["shared"] += common_shared @@ -1496,18 +1630,59 @@ def build_project_lifecycle_cost( ) project_entry["shared_expense_supply"] += common_shared project_entry["expense_supply"] += common_shared - account_entry = breakdown_account_maps["관리비"].setdefault( - "SHARED_COMMON", - { - "account_code": "SHARED_COMMON", - "account_name": "월별 공통배분(관리비)", - "direct_expense_supply": 0.0, - "shared_expense_supply": 0.0, - "expense_supply": 0.0, - }, - ) - account_entry["shared_expense_supply"] += common_shared - account_entry["expense_supply"] += common_shared + common_account_allocated = monthly_shared.get("common_account_allocated") or {} + if common_account_allocated: + for shared_code, shared_amount in common_account_allocated.items(): + shared_code_str = (shared_code or "").strip() + if not shared_code_str: + continue + shared_amount_value = float(shared_amount or 0.0) + shared_meta = ACCOUNT_MASTER.get(shared_code_str) or {} + account_entry = breakdown_account_maps["관리비"].setdefault( + shared_code_str, + { + "account_code": shared_code_str, + "account_name": shared_meta.get("name") or shared_code_str, + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, + "expense_supply": 0.0, + "allocation_source_amount": 0.0, + "allocation_project_basis_amount": 0.0, + "allocation_total_basis_amount": 0.0, + "allocation_mode": common_allocation_mode, + "allocation_details": [], + }, + ) + account_entry["shared_expense_supply"] += shared_amount_value + account_entry["expense_supply"] += shared_amount_value + account_entry["allocation_source_amount"] = float(monthly_shared.get("common_source_total") or 0.0) + account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("common_project_basis_total") or 0.0) + account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("common_overall_basis_total") or 0.0) + account_entry["allocation_mode"] = common_allocation_mode + account_entry["allocation_details"] = list(monthly_shared.get("common_allocation_details") or []) + else: + account_entry = breakdown_account_maps["관리비"].setdefault( + "SHARED_COMMON", + { + "account_code": "SHARED_COMMON", + "account_name": "월별 공통배분(관리비)", + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, + "expense_supply": 0.0, + "allocation_source_amount": 0.0, + "allocation_project_basis_amount": 0.0, + "allocation_total_basis_amount": 0.0, + "allocation_mode": common_allocation_mode, + "allocation_details": [], + }, + ) + account_entry["shared_expense_supply"] += common_shared + account_entry["expense_supply"] += common_shared + account_entry["allocation_source_amount"] = float(monthly_shared.get("common_source_total") or 0.0) + account_entry["allocation_project_basis_amount"] = float(monthly_shared.get("common_project_basis_total") or 0.0) + account_entry["allocation_total_basis_amount"] = float(monthly_shared.get("common_overall_basis_total") or 0.0) + account_entry["allocation_mode"] = common_allocation_mode + account_entry["allocation_details"] = list(monthly_shared.get("common_allocation_details") or []) total_expense = ( breakdown_components["시공비"]["total"] @@ -1693,8 +1868,32 @@ def build_lifecycle_account_detail( "max_date": max((row.get("transaction_date") or "" for row in filtered_transactions), default=""), } + allocation_mode = "" + allocation_source_amount = 0.0 + allocation_project_basis_amount = 0.0 + allocation_total_basis_amount = 0.0 + allocation_details: list[dict] = [] + if account_code in {"SHARED_LABOR", "SHARED_COMMON"}: + allocation_mode = fetch_lifecycle_common_allocation_mode(conn, base_project_code) + monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code, allocation_mode) + if account_code == "SHARED_LABOR": + allocation_source_amount = float(monthly_shared.get("labor_source_total") or 0.0) + allocation_project_basis_amount = float(monthly_shared.get("labor_project_basis_total") or 0.0) + allocation_total_basis_amount = float(monthly_shared.get("labor_overall_basis_total") or 0.0) + allocation_details = list(monthly_shared.get("labor_allocation_details") or []) + else: + allocation_source_amount = float(monthly_shared.get("common_source_total") or 0.0) + allocation_project_basis_amount = float(monthly_shared.get("common_project_basis_total") or 0.0) + allocation_total_basis_amount = float(monthly_shared.get("common_overall_basis_total") or 0.0) + allocation_details = list(monthly_shared.get("common_allocation_details") or []) + return { "summary": summary, + "allocation_mode": allocation_mode, + "allocation_source_amount": allocation_source_amount, + "allocation_project_basis_amount": allocation_project_basis_amount, + "allocation_total_basis_amount": allocation_total_basis_amount, + "allocation_details": allocation_details, "projects": sorted( project_map.values(), key=lambda item: (-float(item.get("expense_supply_sum") or 0), item.get("project_code") or ""),