feat(lifecycle): apply monthly shared allocation from project start month
This commit is contained in:
@@ -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": "시공비",
|
||||
|
||||
Reference in New Issue
Block a user