From 21ad66c8b44e9acd31653b275e8075366fd917fa 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 10:21:21 +0900 Subject: [PATCH] feat(lifecycle): add selectable common allocation mode (expense/income ratio) --- PTC/management_dashboard_preview.html | 42 +++++++++ server/ptc_api_server.py | 122 ++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/PTC/management_dashboard_preview.html b/PTC/management_dashboard_preview.html index 4b80db9..af949d3 100644 --- a/PTC/management_dashboard_preview.html +++ b/PTC/management_dashboard_preview.html @@ -1924,6 +1924,7 @@ const [lifecycleAccountDetailModalLoading, setLifecycleAccountDetailModalLoading] = useState(false); const [lifecycleAllocationModal, setLifecycleAllocationModal] = useState(null); const [lifecycleAllocationSaving, setLifecycleAllocationSaving] = useState(false); + const [lifecycleCommonAllocationSaving, setLifecycleCommonAllocationSaving] = useState(false); const [projectEditModalOpen, setProjectEditModalOpen] = useState(false); const [relatedProjectSearch, setRelatedProjectSearch] = useState(""); const [projectTxnDateFrom, setProjectTxnDateFrom] = useState(""); @@ -3605,6 +3606,34 @@ } } + async function saveLifecycleCommonAllocationMode(nextMode) { + if (!selectedProjectCode) return; + if (!["expense_ratio", "income_ratio"].includes(nextMode || "")) return; + setLifecycleCommonAllocationSaving(true); + setError(""); + try { + const res = await fetch(`${API_BASE}/api/lifecycle-common-allocation/upsert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + base_project_code: selectedProjectCode, + allocation_mode: nextMode, + }), + }); + if (!res.ok) throw new Error("common 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); + } + } catch (err) { + setError("공통배분 기준 저장에 실패했습니다."); + } finally { + setLifecycleCommonAllocationSaving(false); + } + } + async function saveProjectMaster() { if (!selectedProjectCode) return; setSaving(true); @@ -5182,6 +5211,19 @@
프로젝트 생애주기 원가
현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.
+
+ 공통배분 기준 + + {lifecycleCommonAllocationSaving ? "저장 중..." : ""} +
diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index a1fabe2..4b008bf 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -623,6 +623,15 @@ def init_db() -> None: ) """ ) + cur.execute( + """ + create table if not exists project_lifecycle_common_allocation ( + base_project_code text primary key, + allocation_mode text not null default 'expense_ratio', + updated_at text not null + ) + """ + ) cur.execute( """ create table if not exists project_budget_lines ( @@ -1006,6 +1015,23 @@ def fetch_lifecycle_allocation_map(conn: sqlite3.Connection, base_project_code: return allocation_map +def fetch_lifecycle_common_allocation_mode(conn: sqlite3.Connection, base_project_code: str) -> str: + if not base_project_code: + return "expense_ratio" + row = conn.execute( + """ + select allocation_mode + from project_lifecycle_common_allocation + where base_project_code = ? + """, + (base_project_code,), + ).fetchone() + mode = (row["allocation_mode"] or "").strip() if row else "" + if mode not in {"expense_ratio", "income_ratio"}: + return "expense_ratio" + return mode + + def resolve_lifecycle_allocation(project_type: str, allocation_item: dict | None) -> tuple[int, int, float]: if project_type in {"영업", "설계"}: if allocation_item: @@ -1052,7 +1078,9 @@ def _iter_year_months(start_ym: str, end_ym: str): m = 1 -def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project_code: str) -> dict: +def calculate_monthly_shared_distribution( + conn: sqlite3.Connection, base_project_code: str, allocation_mode: str = "expense_ratio" +) -> dict: project_rows = conn.execute( """ select @@ -1117,10 +1145,33 @@ def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project if not candidate_months: return {"labor_shared": 0.0, "common_shared": 0.0} - month_active_counts: dict[str, int] = defaultdict(int) - for start_ym, end_ym in project_ranges.values(): + month_active_projects: dict[str, set[str]] = defaultdict(set) + for project_code, (start_ym, end_ym) in project_ranges.items(): for ym in _iter_year_months(start_ym, end_ym): - month_active_counts[ym] += 1 + month_active_projects[ym].add(project_code) + + monthly_project_amount_rows = conn.execute( + """ + select + substr(coalesce(transaction_date, ''), 1, 7) as ym, + coalesce(project_code, '') as project_code, + coalesce(sum(case when in_out = '출금' then supply_amount else 0 end), 0) as expense_supply, + coalesce(sum(case when in_out = '입금' then supply_amount else 0 end), 0) as income_supply + from ptc_transactions + where coalesce(transaction_date, '') <> '' + group by substr(coalesce(transaction_date, ''), 1, 7), coalesce(project_code, '') + having ym <> '' + """ + ).fetchall() + project_expense_by_month: dict[tuple[str, str], float] = defaultdict(float) + project_income_by_month: dict[tuple[str, str], float] = defaultdict(float) + for row in monthly_project_amount_rows: + ym = (row["ym"] or "").strip() + project_code = (row["project_code"] or "").strip() + if not ym or not project_code: + continue + project_expense_by_month[(ym, project_code)] += float(row["expense_supply"] or 0) + project_income_by_month[(ym, project_code)] += float(row["income_supply"] or 0) base_start_ym, base_end_ym = base_range labor_shared = 0.0 @@ -1130,11 +1181,26 @@ def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project continue if base_end_ym and ym > base_end_ym: continue - active_count = int(month_active_counts.get(ym) or 0) - if active_count <= 0: + active_projects = sorted(month_active_projects.get(ym) or []) + if not active_projects: continue - labor_shared += float(labor_pool_by_month.get(ym) or 0) / active_count - common_shared += float(common_pool_by_month.get(ym) or 0) / active_count + if base_project_code not in active_projects: + continue + + if allocation_mode == "income_ratio": + base_value = float(project_income_by_month.get((ym, base_project_code)) or 0.0) + total_value = sum(float(project_income_by_month.get((ym, code)) or 0.0) for code in active_projects) + else: + base_value = float(project_expense_by_month.get((ym, base_project_code)) or 0.0) + total_value = sum(float(project_expense_by_month.get((ym, code)) or 0.0) for code in active_projects) + + if total_value > 0: + ratio = max(0.0, min(1.0, base_value / total_value)) + else: + ratio = 1.0 / len(active_projects) + + labor_shared += float(labor_pool_by_month.get(ym) or 0) * ratio + common_shared += float(common_pool_by_month.get(ym) or 0) * ratio return {"labor_shared": labor_shared, "common_shared": common_shared} @@ -1369,7 +1435,8 @@ def build_project_lifecycle_cost( account_entry["shared_expense_supply"] += shared_expense_supply account_entry["expense_supply"] += base_expense_supply - monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code) + common_allocation_mode = fetch_lifecycle_common_allocation_mode(conn, base_project_code) + monthly_shared = calculate_monthly_shared_distribution(conn, base_project_code, common_allocation_mode) labor_shared = float(monthly_shared.get("labor_shared") or 0.0) common_shared = float(monthly_shared.get("common_shared") or 0.0) base_project_info = project_lookup.get(base_project_code) or {} @@ -1502,6 +1569,7 @@ def build_project_lifecycle_cost( "income_supply": total_income, "expense_supply": total_expense, "profit_supply": total_income - total_expense, + "common_allocation_mode": common_allocation_mode, }, } @@ -2199,6 +2267,42 @@ class Handler(BaseHTTPRequestHandler): self._send(200, {"ok": True}) return + if parsed.path == "/api/lifecycle-common-allocation/upsert": + payload = self._read_json() + base_project_code = str(payload.get("base_project_code", "")).strip() + allocation_mode = str(payload.get("allocation_mode", "")).strip() + if not base_project_code: + self._send(400, {"ok": False, "message": "base_project_code is required"}) + return + if allocation_mode not in {"expense_ratio", "income_ratio"}: + self._send(400, {"ok": False, "message": "allocation_mode must be expense_ratio or income_ratio"}) + return + updated_at = datetime.now().isoformat() + conn.execute( + """ + insert into project_lifecycle_common_allocation ( + base_project_code, allocation_mode, updated_at + ) values (?, ?, ?) + on conflict(base_project_code) do update set + allocation_mode = excluded.allocation_mode, + updated_at = excluded.updated_at + """, + (base_project_code, allocation_mode, updated_at), + ) + conn.commit() + self._send( + 200, + { + "ok": True, + "item": { + "base_project_code": base_project_code, + "allocation_mode": allocation_mode, + "updated_at": updated_at, + }, + }, + ) + return + if parsed.path == "/api/project-master/batch-update-method": payload = self._read_json() project_codes = payload.get("project_codes", [])