feat(lifecycle): apply monthly shared allocation from project start month

This commit is contained in:
2026-05-04 09:53:09 +09:00
parent 7882d0cd05
commit c13f596453

View File

@@ -1020,6 +1020,125 @@ def resolve_lifecycle_allocation(project_type: str, allocation_item: dict | None
return 1, 1, 1.0 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( def build_company_allocated_project_rows(
conn: sqlite3.Connection, project_rows: list[sqlite3.Row], source_project_type: str conn: sqlite3.Connection, project_rows: list[sqlite3.Row], source_project_type: str
) -> tuple[list[dict], dict]: ) -> tuple[list[dict], dict]:
@@ -1250,6 +1369,85 @@ 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)
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 = [ breakdown = [
{ {
"label": "시공비", "label": "시공비",