Refine process cost navigation and unify section layouts

This commit is contained in:
b17301
2026-04-28 18:11:14 +09:00
parent 1d6470c770
commit 3bf48c42db
8 changed files with 1302 additions and 279 deletions
+339 -77
View File
@@ -4473,6 +4473,54 @@ def save_project_quick_links(session_id: str | None, codes: list[str]) -> None:
)
def get_process_cost_quick_links() -> list[str]:
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT support_dept_code
FROM project_quick_links
WHERE page_key = 'process_cost'
ORDER BY sort_order, updated_at DESC, support_dept_code
"""
)
).mappings().all()
return [normalize_text(row["support_dept_code"]) for row in rows if normalize_text(row["support_dept_code"])]
def save_process_cost_quick_links(codes: list[str]) -> None:
normalized_codes: list[str] = []
for code in codes:
normalized_code = normalize_text(code)
if normalized_code and normalized_code not in normalized_codes:
normalized_codes.append(normalized_code)
with engine.begin() as conn:
conn.execute(
text(
"""
DELETE FROM project_quick_links
WHERE page_key = 'process_cost'
"""
)
)
for sort_order, support_dept_code in enumerate(normalized_codes):
conn.execute(
text(
"""
INSERT INTO project_quick_links (
page_key, support_dept_code, sort_order, updated_at
) VALUES (
'process_cost', :support_dept_code, :sort_order, CURRENT_TIMESTAMP
)
"""
),
{
"support_dept_code": support_dept_code,
"sort_order": sort_order,
},
)
def get_project_uncontracted_classification_map() -> dict[str, str]:
with engine.begin() as conn:
rows = conn.execute(
@@ -5332,34 +5380,53 @@ def get_process_cost_related_codes(support_dept_code: str | None) -> list[str]:
def get_process_cost_available_years(source: str) -> list[int]:
normalized_source = normalize_text(source).lower()
if normalized_source == "wehago":
init_wehago_compare_db(engine)
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT DISTINCT fiscal_year
FROM wehago_voucher_rows
WHERE fiscal_year IS NOT NULL
ORDER BY fiscal_year
"""
)
).fetchall()
return [int(row[0]) for row in rows if row and row[0] is not None]
contract_meta = _get_project_contract_meta()
detected_years = {
year
for code in contract_meta.keys()
for year in [_extract_year_from_project_code(code)]
if year is not None
}
max_year = max(detected_years) if detected_years else datetime.now().year
min_year = 1994
if max_year < min_year:
max_year = min_year
return list(range(min_year, max_year + 1))
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT DISTINCT year
FROM transactions
WHERE year IS NOT NULL
ORDER BY year
"""
)
).fetchall()
return [int(row[0]) for row in rows if row and row[0] is not None]
def _extract_year_from_project_code(value: Any) -> int | None:
code = normalize_text(value).upper()
if len(code) < 3:
return None
digits = "".join(ch for ch in code if ch.isdigit())
if len(digits) < 2:
return None
year_2d = digits[:2]
if not year_2d.isdigit():
return None
year_value = int(year_2d)
if year_value >= 94:
return 1900 + year_value
return 2000 + year_value
def _sort_process_cost_project_options(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
def sort_key(item: dict[str, Any]) -> str:
return normalize_text(item.get("support_dept_code")).upper()
return sorted(items, key=sort_key)
def _get_process_cost_project_kind(code: Any) -> tuple[str, str]:
normalized = normalize_text(code).upper()
prefix = normalized[:1]
if prefix == "X":
return "X", "사전사업"
if prefix == "Y":
return "Y", "설계"
if prefix == "Z":
return "Z", "감리"
return "", "기타"
def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
@@ -5385,7 +5452,9 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, '') AS support_dept_name,
COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount,
COALESCE(c.client_name, '') AS client_name,
COALESCE(p.expected_as_cost, b.expected_as_cost, 0) AS expected_as_cost
COALESCE(p.expected_as_cost, b.expected_as_cost, 0) AS expected_as_cost,
COALESCE(p.expected_sga_budget, b.expected_sga_budget, 0) AS expected_sga_budget,
COALESCE(p.project_start_date, b.project_start_date, '') AS project_start_date
FROM code_universe AS u
LEFT JOIN project_contract_info AS c
ON c.support_dept_code = u.support_dept_code
@@ -5405,12 +5474,117 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
return result
def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any]]:
normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)]
if not normalized_codes:
return {}
in_clause, params = build_in_clause("actual_sga_code", normalized_codes)
with engine.begin() as conn:
rows = conn.execute(
text(
f"""
SELECT
support_dept_code,
COALESCE(label, '') AS label,
COALESCE(reference, '') AS reference,
COALESCE(note, '') AS note,
COALESCE(grade, '') AS grade,
COALESCE(minutes, '') AS minutes,
COALESCE(amount, 0) AS amount
FROM project_actual_input_entries
WHERE support_dept_code IN ({in_clause})
AND COALESCE(group_name, '') = 'sga'
ORDER BY position, id
"""
),
params,
).mappings().all()
grouped: dict[str, list[dict[str, Any]]] = {}
for row in rows:
code = normalize_text(row.get("support_dept_code"))
if not code:
continue
grouped.setdefault(code, []).append(dict(row))
result: dict[str, dict[str, Any]] = {}
for code, code_rows in grouped.items():
total_amount = sum(normalize_amount(item.get("amount")) for item in code_rows)
has_detail_trace = any(
normalize_text(item.get("reference"))
or normalize_text(item.get("note"))
or normalize_text(item.get("grade"))
or normalize_text(item.get("minutes"))
for item in code_rows
)
distinct_labels = {
normalize_text(item.get("label"))
for item in code_rows
if normalize_text(item.get("label"))
}
result[code] = {
"rows": code_rows,
"amount": total_amount,
"has_detail_trace": has_detail_trace,
"distinct_labels": distinct_labels,
}
return result
def _get_project_exec_budget_summary(codes: list[str]) -> dict[str, dict[str, float]]:
normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)]
if not normalized_codes:
return {}
in_clause, params = build_in_clause("exec_budget_code", normalized_codes)
with engine.begin() as conn:
rows = conn.execute(
text(
f"""
SELECT
support_dept_code,
COALESCE(group_name, '') AS group_name,
SUM(COALESCE(amount, 0)) AS amount
FROM project_exec_budget_entries
WHERE support_dept_code IN ({in_clause})
GROUP BY support_dept_code, group_name
"""
),
params,
).mappings().all()
result: dict[str, dict[str, float]] = {}
for row in rows:
code = normalize_text(row.get("support_dept_code"))
group_name = normalize_text(row.get("group_name"))
if not code:
continue
current = result.setdefault(code, {"labor": 0.0, "outsource": 0.0, "cost_plan": 0.0})
current[group_name or "labor"] = normalize_amount(row.get("amount"))
return result
def _is_real_project_actual_sga(
actual_sga_summary: dict[str, Any] | None,
expected_sga_budget: float,
) -> bool:
if not actual_sga_summary:
return False
amount = normalize_amount(actual_sga_summary.get("amount"))
if amount <= 0:
return False
if actual_sga_summary.get("has_detail_trace"):
return True
distinct_labels = set(actual_sga_summary.get("distinct_labels") or [])
if len(distinct_labels) > 1:
return True
if distinct_labels and distinct_labels != {"판관비"}:
return True
if expected_sga_budget > 0 and abs(amount - expected_sga_budget) <= 0.5:
return False
return True
def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, dict[str, Any]]:
year_clause = ""
params: dict[str, Any] = {}
if selected_year:
year_clause = "AND year = :selected_year"
params["selected_year"] = selected_year
with engine.begin() as conn:
rows = conn.execute(
text(
@@ -5427,11 +5601,10 @@ def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str,
AND support_dept_code NOT IN ('ZZZZZZ')
AND COALESCE(support_dept_name, '') <> ''
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
{year_clause}
GROUP BY support_dept_code
"""
),
params,
{},
).mappings().all()
result: dict[str, dict[str, Any]] = {}
for row in rows:
@@ -5448,18 +5621,18 @@ def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str,
return result
def get_process_cost_project_options(source: str, selected_year: int | None, include_related: bool = False) -> list[dict[str, Any]]:
def get_process_cost_project_options(
source: str,
start_year: int | None,
end_year: int | None,
include_related: bool = False,
) -> list[dict[str, Any]]:
normalized_source = normalize_text(source).lower()
contract_meta = _get_project_contract_meta()
billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {}
if normalized_source == "wehago":
init_wehago_compare_db(engine)
year_clause = ""
params: dict[str, Any] = {}
if selected_year:
year_clause = "AND fiscal_year = :selected_year"
params["selected_year"] = selected_year
with engine.begin() as conn:
rows = conn.execute(
text(
@@ -5480,7 +5653,6 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
FROM wehago_voucher_rows
WHERE COALESCE(support_dept_code, '') <> ''
AND support_dept_code NOT IN ('ZZZZZZ')
{year_clause}
)
SELECT
support_dept_code,
@@ -5494,10 +5666,10 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
ORDER BY expense_amount DESC, support_dept_code
"""
),
params,
{},
).mappings().all()
else:
tx_by_code = _get_hanmac_process_cost_tx_by_code(selected_year)
tx_by_code = _get_hanmac_process_cost_tx_by_code(None)
universe_codes = sorted(set(contract_meta) | set(billing_summary_map) | set(tx_by_code))
rows = []
for code in universe_codes:
@@ -5525,6 +5697,14 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
continue
contract_row = contract_meta.get(code, {})
billing_row = billing_summary_map.get(code, {})
project_start_date = normalize_text(contract_row.get("project_start_date"))
project_start_year = _extract_year_from_project_code(code)
if start_year and project_start_year and project_start_year < start_year:
continue
if end_year and project_start_year and project_start_year > end_year:
continue
if (start_year or end_year) and not project_start_year:
continue
contract_amount = normalize_amount(contract_row.get("hanmac_contract_amount"))
revenue_amount = normalize_amount(row.get("revenue_amount"))
expense_amount = normalize_amount(row.get("expense_amount"))
@@ -5541,10 +5721,12 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc
"profit_rate": _safe_ratio(profit_amount, revenue_amount),
"voucher_count": int(row.get("voucher_count") or 0),
"last_posting_date": normalize_text(row.get("last_posting_date")),
"project_start_date": project_start_date,
"project_kind_code": _get_process_cost_project_kind(code)[0],
"project_kind_label": _get_process_cost_project_kind(code)[1],
}
)
result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), -normalize_amount(item.get("revenue_amount")), item.get("support_dept_code", "")))
return result
return _sort_process_cost_project_options(result)
def get_process_cost_project_detail(
@@ -5568,14 +5750,12 @@ def get_process_cost_project_detail(
contract_meta = contract_meta_map.get(code, {})
related_clusters = _build_process_cost_related_clusters() if include_related else {}
cluster_codes = related_clusters.get(code, [code]) if include_related else [code]
actual_sga_summary_map = _get_project_actual_sga_summary(cluster_codes)
exec_budget_summary_map = _get_project_exec_budget_summary(cluster_codes)
if normalized_source == "wehago":
init_wehago_compare_db(engine)
in_clause, code_params = build_in_clause("process_cost_wehago_code", cluster_codes)
year_clause = ""
params: dict[str, Any] = dict(code_params)
if selected_year:
year_clause = "AND fiscal_year = :selected_year"
params["selected_year"] = selected_year
amount_expr = (
"CASE "
"WHEN ABS(COALESCE(compare_amount, 0)) > 0 THEN ABS(COALESCE(compare_amount, 0)) "
@@ -5599,7 +5779,6 @@ def get_process_cost_project_detail(
MAX(COALESCE(proof_date, '')) AS last_posting_date
FROM wehago_voucher_rows
WHERE support_dept_code IN ({in_clause})
{year_clause}
"""
),
params,
@@ -5616,7 +5795,6 @@ def get_process_cost_project_detail(
FROM wehago_voucher_rows
WHERE support_dept_code IN ({in_clause})
AND (account_code LIKE '5%' OR account_code LIKE '6%')
{year_clause}
GROUP BY account_code, account_name
ORDER BY amount DESC, account_code
LIMIT 14
@@ -5633,7 +5811,6 @@ def get_process_cost_project_detail(
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount
FROM wehago_voucher_rows
WHERE support_dept_code IN ({in_clause})
{year_clause}
AND LENGTH(COALESCE(proof_date, '')) >= 7
GROUP BY month_label
ORDER BY month_label DESC
@@ -5644,11 +5821,7 @@ def get_process_cost_project_detail(
).mappings().all()
else:
in_clause, code_params = build_in_clause("process_cost_code", cluster_codes)
year_clause = ""
params = dict(code_params)
if selected_year:
year_clause = "AND year = :selected_year"
params["selected_year"] = selected_year
with engine.begin() as conn:
summary = conn.execute(
text(
@@ -5665,7 +5838,6 @@ def get_process_cost_project_detail(
MAX(COALESCE(posting_date, '')) AS last_posting_date
FROM transactions
WHERE support_dept_code IN ({in_clause})
{year_clause}
"""
),
params,
@@ -5682,7 +5854,6 @@ def get_process_cost_project_detail(
FROM transactions
WHERE support_dept_code IN ({in_clause})
AND (account_code LIKE '5%' OR account_code LIKE '6%')
{year_clause}
GROUP BY account_code, account_name
ORDER BY amount DESC, account_code
LIMIT 14
@@ -5699,7 +5870,6 @@ def get_process_cost_project_detail(
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount
FROM transactions
WHERE support_dept_code IN ({in_clause})
{year_clause}
AND year IS NOT NULL
AND month IS NOT NULL
GROUP BY year, month
@@ -5714,24 +5884,46 @@ def get_process_cost_project_detail(
if include_related:
contract_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("hanmac_contract_amount")) for member in cluster_codes)
as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes)
expected_sga_budget = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) for member in cluster_codes)
planned_labor_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("labor")) for member in cluster_codes)
planned_outsource_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("outsource")) for member in cluster_codes)
planned_cost_plan_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("cost_plan")) for member in cluster_codes)
else:
contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount"))
as_cost_amount = normalize_amount(contract_meta.get("expected_as_cost"))
expected_sga_budget = normalize_amount(contract_meta.get("expected_sga_budget"))
planned_labor_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("labor"))
planned_outsource_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("outsource"))
planned_cost_plan_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan"))
revenue_amount = normalize_amount(summary_row.get("revenue_amount"))
expense_amount = normalize_amount(summary_row.get("expense_amount"))
ledger_expense_amount = normalize_amount(summary_row.get("expense_amount"))
labor_amount = normalize_amount(summary_row.get("labor_amount"))
outsourcing_amount = normalize_amount(summary_row.get("outsourcing_amount"))
sga_amount = normalize_amount(summary_row.get("sga_amount"))
design_cost_amount = max(expense_amount - labor_amount - outsourcing_amount - sga_amount, 0.0)
ledger_sga_amount = normalize_amount(summary_row.get("sga_amount"))
real_project_actual_sga_amount = 0.0
for member in cluster_codes:
member_expected_sga_budget = normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget"))
actual_summary = actual_sga_summary_map.get(member)
if _is_real_project_actual_sga(actual_summary, member_expected_sga_budget):
real_project_actual_sga_amount += normalize_amount((actual_summary or {}).get("amount"))
sga_amount = ledger_sga_amount + real_project_actual_sga_amount
expense_amount = ledger_expense_amount + real_project_actual_sga_amount
design_cost_amount = max(ledger_expense_amount - labor_amount - outsourcing_amount - ledger_sga_amount, 0.0)
profit_amount = revenue_amount - expense_amount
target_base = contract_amount * 0.85 if contract_amount > 0 else max(revenue_amount * 0.85, expense_amount)
target_base = (
planned_labor_amount
+ planned_outsource_amount
+ planned_cost_plan_amount
+ as_cost_amount
+ expected_sga_budget
)
phase_rows = [
{"phase": "직접인건비", "target_amount": target_base * 0.30, "actual_amount": labor_amount},
{"phase": "외주비", "target_amount": target_base * 0.32, "actual_amount": outsourcing_amount},
{"phase": "제경비", "target_amount": target_base * 0.23, "actual_amount": design_cost_amount},
{"phase": "직접인건비", "target_amount": planned_labor_amount, "actual_amount": labor_amount},
{"phase": "외주비", "target_amount": planned_outsource_amount, "actual_amount": outsourcing_amount},
{"phase": "제경비", "target_amount": planned_cost_plan_amount, "actual_amount": design_cost_amount},
{"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_cost_amount},
{"phase": "판관비", "target_amount": target_base * 0.15, "actual_amount": sga_amount},
{"phase": "판관비", "target_amount": expected_sga_budget, "actual_amount": sga_amount},
]
for row in phase_rows:
row["gap_amount"] = row["target_amount"] - row["actual_amount"]
@@ -5750,6 +5942,19 @@ def get_process_cost_project_detail(
"last_posting_date": normalize_text(row.get("last_posting_date")),
}
)
if real_project_actual_sga_amount > 0:
normalized_accounts.append(
{
"account_code": "PROJECT-SGA",
"account_name": "판관비(프로젝트 정보)",
"amount": real_project_actual_sga_amount,
"row_count": sum(len((actual_sga_summary_map.get(member) or {}).get("rows") or []) for member in cluster_codes),
"share_rate": _safe_ratio(real_project_actual_sga_amount, expense_amount),
"last_posting_date": "",
}
)
normalized_accounts.sort(key=lambda item: (-normalize_amount(item.get("amount")), normalize_text(item.get("account_code"))))
normalized_accounts = normalized_accounts[:14]
normalized_monthly = []
for row in monthly_rows:
@@ -5781,6 +5986,9 @@ def get_process_cost_project_detail(
"voucher_count": int(summary_row.get("voucher_count") or 0),
"row_count": int(summary_row.get("row_count") or 0),
"included_codes": cluster_codes if include_related else [code],
"expected_sga_budget": expected_sga_budget,
"ledger_sga_amount": ledger_sga_amount,
"project_actual_sga_amount": real_project_actual_sga_amount,
},
"phase_rows": phase_rows,
"account_rows": normalized_accounts,
@@ -5798,7 +6006,8 @@ def get_process_cost_project_detail(
def render_process_cost_page(
request: Request,
source: str | None = None,
year: int | None = None,
start_year: int | None = None,
end_year: int | None = None,
code: str | None = None,
include_related: bool = False,
message: str = "",
@@ -5810,17 +6019,39 @@ def render_process_cost_page(
normalized_source = "hanmac"
years = get_process_cost_available_years(normalized_source)
selected_year = year if year in years else None
project_options = get_process_cost_project_options(normalized_source, selected_year, include_related=include_related)
selected_start_year = start_year if start_year in years else None
selected_end_year = end_year if end_year in years else None
if not selected_start_year and not selected_end_year and years:
selected_start_year = years[0]
selected_end_year = years[-1]
elif selected_start_year and selected_end_year and selected_start_year > selected_end_year:
selected_end_year = selected_start_year
elif selected_end_year and not selected_start_year:
selected_start_year = years[0] if years else None
if selected_start_year and selected_start_year > selected_end_year:
selected_start_year = selected_end_year
elif selected_start_year and not selected_end_year:
selected_end_year = years[-1] if years else None
if selected_end_year and selected_end_year < selected_start_year:
selected_end_year = selected_start_year
project_options = get_process_cost_project_options(
normalized_source,
selected_start_year,
selected_end_year,
include_related=include_related,
)
selected_code = normalize_text(code)
if selected_code and not any(item["support_dept_code"] == selected_code for item in project_options):
selected_code = ""
if not selected_code and project_options:
selected_code = project_options[0]["support_dept_code"]
selected_project = next(
(item for item in project_options if normalize_text(item.get("support_dept_code")) == selected_code),
None,
)
detail = get_process_cost_project_detail(
normalized_source,
selected_year,
None,
selected_code,
include_related=include_related,
)
@@ -5828,12 +6059,16 @@ def render_process_cost_page(
**base_context(request, message),
"process_cost_source": normalized_source,
"process_cost_years": years,
"process_cost_selected_year": selected_year,
"process_cost_years_desc": sorted(years, reverse=True),
"process_cost_selected_start_year": selected_start_year,
"process_cost_selected_end_year": selected_end_year,
"process_cost_selected_code": selected_code,
"process_cost_selected_project": selected_project,
"process_cost_include_related": include_related,
"process_cost_projects": project_options,
"process_cost_detail": detail,
"process_cost_related_codes": get_process_cost_related_codes(selected_code),
"process_cost_quick_link_codes": get_process_cost_quick_links(),
}
return templates.TemplateResponse(request, "process_cost.html", context)
@@ -6832,6 +7067,31 @@ async def project_quick_links_save(request: Request):
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.get("/process-cost/quick-links")
async def process_cost_quick_links_load():
try:
return JSONResponse(content={"codes": get_process_cost_quick_links()})
except Exception as exc:
logger.exception("프로세스 원가 바로가기 조회 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.post("/process-cost/quick-links")
async def process_cost_quick_links_save(request: Request):
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ValueError("잘못된 바로가기 형식입니다.")
codes = payload.get("codes") or []
if not isinstance(codes, list):
raise ValueError("바로가기 목록 형식이 잘못되었습니다.")
save_process_cost_quick_links([normalize_text(code) for code in codes if isinstance(code, str)])
return JSONResponse(content={"status": "ok", "codes": get_process_cost_quick_links()})
except Exception as exc:
logger.exception("프로세스 원가 바로가기 저장 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.post("/projects/uncontracted-category")
async def project_uncontracted_category_save(request: Request):
try:
@@ -7214,7 +7474,8 @@ async def annual_summary(request: Request):
async def process_cost(
request: Request,
source: str | None = None,
year: str | None = None,
start_year: str | None = None,
end_year: str | None = None,
code: str | None = None,
include_related: str | None = None,
):
@@ -7222,7 +7483,8 @@ async def process_cost(
return render_process_cost_page(
request,
source=source,
year=parse_optional_year(year),
start_year=parse_optional_year(start_year),
end_year=parse_optional_year(end_year),
code=code,
include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"},
)