feat(lifecycle): add selectable common allocation mode (expense/income ratio)

This commit is contained in:
2026-05-04 10:21:21 +09:00
parent 68eabcc228
commit 21ad66c8b4
2 changed files with 155 additions and 9 deletions

View File

@@ -1924,6 +1924,7 @@
const [lifecycleAccountDetailModalLoading, setLifecycleAccountDetailModalLoading] = useState(false); const [lifecycleAccountDetailModalLoading, setLifecycleAccountDetailModalLoading] = useState(false);
const [lifecycleAllocationModal, setLifecycleAllocationModal] = useState(null); const [lifecycleAllocationModal, setLifecycleAllocationModal] = useState(null);
const [lifecycleAllocationSaving, setLifecycleAllocationSaving] = useState(false); const [lifecycleAllocationSaving, setLifecycleAllocationSaving] = useState(false);
const [lifecycleCommonAllocationSaving, setLifecycleCommonAllocationSaving] = useState(false);
const [projectEditModalOpen, setProjectEditModalOpen] = useState(false); const [projectEditModalOpen, setProjectEditModalOpen] = useState(false);
const [relatedProjectSearch, setRelatedProjectSearch] = useState(""); const [relatedProjectSearch, setRelatedProjectSearch] = useState("");
const [projectTxnDateFrom, setProjectTxnDateFrom] = 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() { async function saveProjectMaster() {
if (!selectedProjectCode) return; if (!selectedProjectCode) return;
setSaving(true); setSaving(true);
@@ -5182,6 +5211,19 @@
<div> <div>
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div> <div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</div>
<div className="subtle" style={{ marginTop: 6 }}>현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.</div> <div className="subtle" style={{ marginTop: 6 }}>현재 시공 프로젝트를 포함해 연결된 전체 비용을 시공비, 인건비, 관리비로 나눠 봅니다.</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 10 }}>
<span className="subtle">공통배분 기준</span>
<select
value={detail?.lifecycle_cost?.summary?.common_allocation_mode || "expense_ratio"}
onChange={(e) => saveLifecycleCommonAllocationMode(e.target.value)}
disabled={lifecycleCommonAllocationSaving}
style={{ minWidth: 220 }}
>
<option value="expense_ratio">프로젝트 지출 / 전체지출 비율</option>
<option value="income_ratio">프로젝트 입금 / 전체입금 비율</option>
</select>
<span className="subtle">{lifecycleCommonAllocationSaving ? "저장 중..." : ""}</span>
</div>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(120px, 1fr))", gap: 24, minWidth: 560 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(120px, 1fr))", gap: 24, minWidth: 560 }}>
<div> <div>

View File

@@ -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( cur.execute(
""" """
create table if not exists project_budget_lines ( 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 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]: def resolve_lifecycle_allocation(project_type: str, allocation_item: dict | None) -> tuple[int, int, float]:
if project_type in {"영업", "설계"}: if project_type in {"영업", "설계"}:
if allocation_item: if allocation_item:
@@ -1052,7 +1078,9 @@ def _iter_year_months(start_ym: str, end_ym: str):
m = 1 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( project_rows = conn.execute(
""" """
select select
@@ -1117,10 +1145,33 @@ def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project
if not candidate_months: if not candidate_months:
return {"labor_shared": 0.0, "common_shared": 0.0} return {"labor_shared": 0.0, "common_shared": 0.0}
month_active_counts: dict[str, int] = defaultdict(int) month_active_projects: dict[str, set[str]] = defaultdict(set)
for start_ym, end_ym in project_ranges.values(): for project_code, (start_ym, end_ym) in project_ranges.items():
for ym in _iter_year_months(start_ym, end_ym): 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 base_start_ym, base_end_ym = base_range
labor_shared = 0.0 labor_shared = 0.0
@@ -1130,11 +1181,26 @@ def calculate_monthly_shared_distribution(conn: sqlite3.Connection, base_project
continue continue
if base_end_ym and ym > base_end_ym: if base_end_ym and ym > base_end_ym:
continue continue
active_count = int(month_active_counts.get(ym) or 0) active_projects = sorted(month_active_projects.get(ym) or [])
if active_count <= 0: if not active_projects:
continue continue
labor_shared += float(labor_pool_by_month.get(ym) or 0) / active_count if base_project_code not in active_projects:
common_shared += float(common_pool_by_month.get(ym) or 0) / active_count 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} 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["shared_expense_supply"] += shared_expense_supply
account_entry["expense_supply"] += base_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) labor_shared = float(monthly_shared.get("labor_shared") or 0.0)
common_shared = float(monthly_shared.get("common_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 {} base_project_info = project_lookup.get(base_project_code) or {}
@@ -1502,6 +1569,7 @@ def build_project_lifecycle_cost(
"income_supply": total_income, "income_supply": total_income,
"expense_supply": total_expense, "expense_supply": total_expense,
"profit_supply": total_income - 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}) self._send(200, {"ok": True})
return 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": if parsed.path == "/api/project-master/batch-update-method":
payload = self._read_json() payload = self._read_json()
project_codes = payload.get("project_codes", []) project_codes = payload.get("project_codes", [])