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 @@
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", [])