From c13f5964537ec70ac894d5899a9378a24f6d8a9a 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 09:53:09 +0900 Subject: [PATCH] feat(lifecycle): apply monthly shared allocation from project start month --- server/ptc_api_server.py | 198 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/server/ptc_api_server.py b/server/ptc_api_server.py index 8f2a202..189f1eb 100644 --- a/server/ptc_api_server.py +++ b/server/ptc_api_server.py @@ -1020,6 +1020,125 @@ def resolve_lifecycle_allocation(project_type: str, allocation_item: dict | None return 1, 1, 1.0 +def _to_year_month(value: str | None) -> str: + text = str(value or "").strip() + if len(text) < 7 or text[4] != "-": + return "" + month = text[5:7] + if not month.isdigit(): + return "" + mm = int(month) + if mm < 1 or mm > 12: + return "" + return text[:7] + + +def _iter_year_months(start_ym: str, end_ym: str): + if not start_ym or not end_ym: + return + try: + sy, sm = int(start_ym[:4]), int(start_ym[5:7]) + ey, em = int(end_ym[:4]), int(end_ym[5:7]) + except Exception: + return + if (ey, em) < (sy, sm): + return + y, m = sy, sm + while (y, m) <= (ey, em): + yield f"{y:04d}-{m:02d}" + m += 1 + if m > 12: + y += 1 + m = 1 + + +def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project_code: str) -> dict: + project_rows = conn.execute( + """ + select + pm.project_code, + pm.start_date, + pm.end_date, + min(case when coalesce(t.transaction_date, '') <> '' then t.transaction_date end) as min_tx_date, + max(case when coalesce(t.transaction_date, '') <> '' then t.transaction_date end) as max_tx_date + from project_master pm + left join ptc_transactions t on t.project_code = pm.project_code + where coalesce(pm.project_type, '') = '시공' + group by pm.project_code, pm.start_date, pm.end_date + """ + ).fetchall() + + project_ranges: dict[str, tuple[str, str]] = {} + for row in project_rows: + code = (row["project_code"] or "").strip() + if not code: + continue + start_ym = _to_year_month(row["start_date"]) or _to_year_month(row["min_tx_date"]) + end_ym = _to_year_month(row["end_date"]) or _to_year_month(row["max_tx_date"]) or start_ym + if not start_ym: + continue + project_ranges[code] = (start_ym, end_ym) + + base_range = project_ranges.get(base_project_code) + if not base_range: + return {"labor_shared": 0.0, "common_shared": 0.0} + + pool_rows = conn.execute( + """ + select + substr(coalesce(transaction_date, ''), 1, 7) as ym, + account_code_final as account_code, + coalesce(sum(supply_amount), 0) as expense_supply + from ptc_transactions + where in_out = '출금' + and coalesce(transaction_date, '') <> '' + group by substr(coalesce(transaction_date, ''), 1, 7), account_code_final + having ym <> '' + """ + ).fetchall() + + labor_pool_by_month: dict[str, float] = defaultdict(float) + common_pool_by_month: dict[str, float] = defaultdict(float) + for row in pool_rows: + ym = (row["ym"] or "").strip() + code = (row["account_code"] or "").strip() + amount = float(row["expense_supply"] or 0) + if not ym or amount == 0: + continue + meta = ACCOUNT_MASTER.get(code) or {} + if meta.get("project_type") != "관리": + continue + if meta.get("category") == "인건비": + labor_pool_by_month[ym] += amount + else: + common_pool_by_month[ym] += 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} + + month_active_counts: dict[str, int] = defaultdict(int) + for start_ym, end_ym in project_ranges.values(): + for ym in _iter_year_months(start_ym, end_ym): + month_active_counts[ym] += 1 + + base_start_ym, base_end_ym = base_range + labor_shared = 0.0 + common_shared = 0.0 + for ym in candidate_months: + if ym < base_start_ym: + 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: + 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 + + return {"labor_shared": labor_shared, "common_shared": common_shared} + + def build_company_allocated_project_rows( conn: sqlite3.Connection, project_rows: list[sqlite3.Row], source_project_type: str ) -> tuple[list[dict], dict]: @@ -1250,6 +1369,85 @@ 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) + 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 {} + + if labor_shared: + breakdown_components["인건비"]["shared"] += labor_shared + breakdown_components["인건비"]["total"] += labor_shared + project_entry = breakdown_project_maps["인건비"].setdefault( + base_project_code, + { + "project_code": base_project_code, + "project_name": base_project_info.get("project_name") or "", + "project_type": base_project_info.get("project_type") or "시공", + "construction_family": base_project_info.get("construction_family") or "", + "construction_method": base_project_info.get("construction_method") or "", + "allocation_numerator": 1, + "allocation_denominator": 1, + "allocation_ratio": 1.0, + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, + "expense_supply": 0.0, + }, + ) + 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 + + if common_shared: + breakdown_components["관리비"]["shared"] += common_shared + breakdown_components["관리비"]["total"] += common_shared + project_entry = breakdown_project_maps["관리비"].setdefault( + base_project_code, + { + "project_code": base_project_code, + "project_name": base_project_info.get("project_name") or "", + "project_type": base_project_info.get("project_type") or "시공", + "construction_family": base_project_info.get("construction_family") or "", + "construction_method": base_project_info.get("construction_method") or "", + "allocation_numerator": 1, + "allocation_denominator": 1, + "allocation_ratio": 1.0, + "direct_expense_supply": 0.0, + "shared_expense_supply": 0.0, + "expense_supply": 0.0, + }, + ) + 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 + + total_expense = ( + breakdown_components["시공비"]["total"] + + breakdown_components["인건비"]["total"] + + breakdown_components["관리비"]["total"] + ) + breakdown = [ { "label": "시공비",