feat(lifecycle): add selectable common allocation mode (expense/income ratio)
This commit is contained in:
@@ -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 @@
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>프로젝트 생애주기 원가</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 style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(120px, 1fr))", gap: 24, minWidth: 560 }}>
|
||||
<div>
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
Reference in New Issue
Block a user