Add process cost page with Hanmac/WEHAGO views and tab
This commit is contained in:
@@ -5281,6 +5281,439 @@ def get_available_years() -> list[int]:
|
|||||||
return [int(row[0]) for row in rows if row[0] is not None]
|
return [int(row[0]) for row in rows if row[0] is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ratio(numerator: Any, denominator: Any) -> float:
|
||||||
|
denom = normalize_amount(denominator)
|
||||||
|
if abs(denom) < 1e-9:
|
||||||
|
return 0.0
|
||||||
|
return (normalize_amount(numerator) / denom) * 100.0
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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 _get_project_contract_meta() -> dict[str, dict[str, Any]]:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
COALESCE(support_dept_name, '') AS support_dept_name,
|
||||||
|
COALESCE(hanmac_contract_amount, 0) AS hanmac_contract_amount,
|
||||||
|
COALESCE(client_name, '') AS client_name
|
||||||
|
FROM project_contract_info
|
||||||
|
WHERE COALESCE(support_dept_code, '') <> ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
code = normalize_text(row.get("support_dept_code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
result[code] = dict(row)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_process_cost_project_options(source: str, selected_year: int | None) -> list[dict[str, Any]]:
|
||||||
|
normalized_source = normalize_text(source).lower()
|
||||||
|
contract_meta = _get_project_contract_meta()
|
||||||
|
|
||||||
|
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(
|
||||||
|
f"""
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
COALESCE(support_dept_name, '') AS support_dept_name,
|
||||||
|
COALESCE(proof_date, '') AS proof_date,
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
CASE
|
||||||
|
WHEN ABS(COALESCE(compare_amount, 0)) > 0 THEN ABS(COALESCE(compare_amount, 0))
|
||||||
|
WHEN ABS(COALESCE(debit_supply, 0)) >= ABS(COALESCE(credit_supply, 0)) THEN ABS(COALESCE(debit_supply, 0))
|
||||||
|
ELSE ABS(COALESCE(credit_supply, 0))
|
||||||
|
END AS amount,
|
||||||
|
COALESCE(confirmed_no, '') AS confirmed_no,
|
||||||
|
COALESCE(draft_no, '') AS draft_no
|
||||||
|
FROM wehago_voucher_rows
|
||||||
|
WHERE COALESCE(support_dept_code, '') <> ''
|
||||||
|
AND support_dept_code NOT IN ('ZZZZZZ')
|
||||||
|
{year_clause}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
support_dept_code,
|
||||||
|
MAX(support_dept_name) AS support_dept_name,
|
||||||
|
SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN amount ELSE 0 END) AS revenue_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount,
|
||||||
|
COUNT(DISTINCT CASE WHEN COALESCE(confirmed_no, '') <> '' THEN confirmed_no ELSE draft_no END) AS voucher_count,
|
||||||
|
MAX(proof_date) AS last_posting_date
|
||||||
|
FROM base
|
||||||
|
GROUP BY support_dept_code
|
||||||
|
ORDER BY expense_amount DESC, support_dept_code
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
else:
|
||||||
|
year_clause = ""
|
||||||
|
params = {}
|
||||||
|
if selected_year:
|
||||||
|
year_clause = "AND year = :selected_year"
|
||||||
|
params["selected_year"] = selected_year
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
MAX(COALESCE(support_dept_name, '')) AS support_dept_name,
|
||||||
|
SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount,
|
||||||
|
COUNT(DISTINCT COALESCE(voucher_number, '')) AS voucher_count,
|
||||||
|
MAX(COALESCE(posting_date, '')) AS last_posting_date
|
||||||
|
FROM transactions
|
||||||
|
WHERE COALESCE(support_dept_code, '') <> ''
|
||||||
|
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
|
||||||
|
ORDER BY expense_amount DESC, support_dept_code
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
code = normalize_text(row.get("support_dept_code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
contract_row = contract_meta.get(code, {})
|
||||||
|
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"))
|
||||||
|
profit_amount = revenue_amount - expense_amount
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"support_dept_code": code,
|
||||||
|
"support_dept_name": normalize_text(contract_row.get("support_dept_name")) or normalize_text(row.get("support_dept_name")) or code,
|
||||||
|
"client_name": normalize_text(contract_row.get("client_name")),
|
||||||
|
"contract_amount": contract_amount,
|
||||||
|
"revenue_amount": revenue_amount,
|
||||||
|
"expense_amount": expense_amount,
|
||||||
|
"profit_amount": profit_amount,
|
||||||
|
"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")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), item.get("support_dept_code", "")))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_process_cost_project_detail(source: str, selected_year: int | None, support_dept_code: str) -> dict[str, Any]:
|
||||||
|
code = normalize_text(support_dept_code)
|
||||||
|
if not code:
|
||||||
|
return {
|
||||||
|
"overview": {},
|
||||||
|
"phase_rows": [],
|
||||||
|
"account_rows": [],
|
||||||
|
"monthly_rows": [],
|
||||||
|
"diagnostics": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_source = normalize_text(source).lower()
|
||||||
|
contract_meta = _get_project_contract_meta().get(code, {})
|
||||||
|
if normalized_source == "wehago":
|
||||||
|
init_wehago_compare_db(engine)
|
||||||
|
year_clause = ""
|
||||||
|
params: dict[str, Any] = {"support_dept_code": code}
|
||||||
|
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)) "
|
||||||
|
"WHEN ABS(COALESCE(debit_supply, 0)) >= ABS(COALESCE(credit_supply, 0)) THEN ABS(COALESCE(debit_supply, 0)) "
|
||||||
|
"ELSE ABS(COALESCE(credit_supply, 0)) "
|
||||||
|
"END"
|
||||||
|
)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
summary = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
MAX(COALESCE(support_dept_name, '')) AS support_dept_name,
|
||||||
|
SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN {amount_expr} ELSE 0 END) AS revenue_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5012%' THEN {amount_expr} ELSE 0 END) AS labor_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5017%' THEN {amount_expr} ELSE 0 END) AS outsourcing_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS sga_amount,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN COALESCE(confirmed_no, '') <> '' THEN confirmed_no ELSE draft_no END) AS voucher_count,
|
||||||
|
MAX(COALESCE(proof_date, '')) AS last_posting_date
|
||||||
|
FROM wehago_voucher_rows
|
||||||
|
WHERE support_dept_code = :support_dept_code
|
||||||
|
{year_clause}
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().first()
|
||||||
|
account_rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
COALESCE(account_name, '') AS account_name,
|
||||||
|
SUM({amount_expr}) AS amount,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
MAX(COALESCE(proof_date, '')) AS last_posting_date
|
||||||
|
FROM wehago_voucher_rows
|
||||||
|
WHERE support_dept_code = :support_dept_code
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
monthly_rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
SUBSTR(COALESCE(proof_date, ''), 1, 7) AS month_label,
|
||||||
|
SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN {amount_expr} ELSE 0 END) AS revenue_amount,
|
||||||
|
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 = :support_dept_code
|
||||||
|
{year_clause}
|
||||||
|
AND LENGTH(COALESCE(proof_date, '')) >= 7
|
||||||
|
GROUP BY month_label
|
||||||
|
ORDER BY month_label DESC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
else:
|
||||||
|
year_clause = ""
|
||||||
|
params = {"support_dept_code": code}
|
||||||
|
if selected_year:
|
||||||
|
year_clause = "AND year = :selected_year"
|
||||||
|
params["selected_year"] = selected_year
|
||||||
|
with engine.begin() as conn:
|
||||||
|
summary = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
MAX(COALESCE(support_dept_name, '')) AS support_dept_name,
|
||||||
|
SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_amount,
|
||||||
|
SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_amount,
|
||||||
|
SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_amount,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
COUNT(DISTINCT COALESCE(voucher_number, '')) AS voucher_count,
|
||||||
|
MAX(COALESCE(posting_date, '')) AS last_posting_date
|
||||||
|
FROM transactions
|
||||||
|
WHERE support_dept_code = :support_dept_code
|
||||||
|
{year_clause}
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().first()
|
||||||
|
account_rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
COALESCE(account_name, '') AS account_name,
|
||||||
|
SUM(COALESCE(amount, 0)) AS amount,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
MAX(COALESCE(posting_date, '')) AS last_posting_date
|
||||||
|
FROM transactions
|
||||||
|
WHERE support_dept_code = :support_dept_code
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
monthly_rows = conn.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
printf('%04d-%02d', year, month) AS month_label,
|
||||||
|
SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount,
|
||||||
|
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 = :support_dept_code
|
||||||
|
{year_clause}
|
||||||
|
AND year IS NOT NULL
|
||||||
|
AND month IS NOT NULL
|
||||||
|
GROUP BY year, month
|
||||||
|
ORDER BY year DESC, month DESC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
summary_row = dict(summary) if summary else {}
|
||||||
|
contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount"))
|
||||||
|
revenue_amount = normalize_amount(summary_row.get("revenue_amount"))
|
||||||
|
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)
|
||||||
|
profit_amount = revenue_amount - expense_amount
|
||||||
|
|
||||||
|
target_base = contract_amount * 0.85 if contract_amount > 0 else max(revenue_amount * 0.85, expense_amount)
|
||||||
|
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": target_base * 0.15, "actual_amount": sga_amount},
|
||||||
|
]
|
||||||
|
for row in phase_rows:
|
||||||
|
row["gap_amount"] = row["target_amount"] - row["actual_amount"]
|
||||||
|
row["progress_rate"] = _safe_ratio(row["actual_amount"], row["target_amount"])
|
||||||
|
|
||||||
|
normalized_accounts = []
|
||||||
|
for row in account_rows:
|
||||||
|
amount = normalize_amount(row.get("amount"))
|
||||||
|
normalized_accounts.append(
|
||||||
|
{
|
||||||
|
"account_code": normalize_text(row.get("account_code")),
|
||||||
|
"account_name": normalize_text(row.get("account_name")),
|
||||||
|
"amount": amount,
|
||||||
|
"row_count": int(row.get("row_count") or 0),
|
||||||
|
"share_rate": _safe_ratio(amount, expense_amount),
|
||||||
|
"last_posting_date": normalize_text(row.get("last_posting_date")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_monthly = []
|
||||||
|
for row in monthly_rows:
|
||||||
|
revenue = normalize_amount(row.get("revenue_amount"))
|
||||||
|
expense = normalize_amount(row.get("expense_amount"))
|
||||||
|
normalized_monthly.append(
|
||||||
|
{
|
||||||
|
"month_label": normalize_text(row.get("month_label")),
|
||||||
|
"revenue_amount": revenue,
|
||||||
|
"expense_amount": expense,
|
||||||
|
"profit_amount": revenue - expense,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized_monthly.reverse()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overview": {
|
||||||
|
"support_dept_code": code,
|
||||||
|
"support_dept_name": normalize_text(contract_meta.get("support_dept_name")) or normalize_text(summary_row.get("support_dept_name")) or code,
|
||||||
|
"client_name": normalize_text(contract_meta.get("client_name")),
|
||||||
|
"contract_amount": contract_amount,
|
||||||
|
"revenue_amount": revenue_amount,
|
||||||
|
"expense_amount": expense_amount,
|
||||||
|
"profit_amount": profit_amount,
|
||||||
|
"profit_rate": _safe_ratio(profit_amount, revenue_amount),
|
||||||
|
"target_cost_amount": target_base,
|
||||||
|
"execution_rate": _safe_ratio(expense_amount, target_base),
|
||||||
|
"last_posting_date": normalize_text(summary_row.get("last_posting_date")),
|
||||||
|
"voucher_count": int(summary_row.get("voucher_count") or 0),
|
||||||
|
"row_count": int(summary_row.get("row_count") or 0),
|
||||||
|
},
|
||||||
|
"phase_rows": phase_rows,
|
||||||
|
"account_rows": normalized_accounts,
|
||||||
|
"monthly_rows": normalized_monthly,
|
||||||
|
"diagnostics": {
|
||||||
|
"labor_ratio": _safe_ratio(labor_amount, expense_amount),
|
||||||
|
"outsourcing_ratio": _safe_ratio(outsourcing_amount, expense_amount),
|
||||||
|
"design_cost_ratio": _safe_ratio(design_cost_amount, expense_amount),
|
||||||
|
"sga_ratio": _safe_ratio(sga_amount, expense_amount),
|
||||||
|
"cost_to_revenue_ratio": _safe_ratio(expense_amount, revenue_amount),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_process_cost_page(
|
||||||
|
request: Request,
|
||||||
|
source: str | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
code: str | None = None,
|
||||||
|
message: str = "",
|
||||||
|
) -> HTMLResponse:
|
||||||
|
init_db()
|
||||||
|
init_wehago_compare_db(engine)
|
||||||
|
normalized_source = normalize_text(source).lower()
|
||||||
|
if normalized_source not in {"hanmac", "wehago"}:
|
||||||
|
normalized_source = "hanmac"
|
||||||
|
|
||||||
|
years = get_process_cost_available_years(normalized_source)
|
||||||
|
selected_year = year if year in years else (max(years) if years else None)
|
||||||
|
project_options = get_process_cost_project_options(normalized_source, selected_year)
|
||||||
|
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"]
|
||||||
|
|
||||||
|
detail = get_process_cost_project_detail(normalized_source, selected_year, selected_code)
|
||||||
|
context = {
|
||||||
|
**base_context(request, message),
|
||||||
|
"process_cost_source": normalized_source,
|
||||||
|
"process_cost_years": years,
|
||||||
|
"process_cost_selected_year": selected_year,
|
||||||
|
"process_cost_selected_code": selected_code,
|
||||||
|
"process_cost_projects": project_options,
|
||||||
|
"process_cost_detail": detail,
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(request, "process_cost.html", context)
|
||||||
|
|
||||||
|
|
||||||
def get_financial_series(granularity: str) -> list[dict[str, Any]]:
|
def get_financial_series(granularity: str) -> list[dict[str, Any]]:
|
||||||
group_fields = "year" if granularity == "yearly" else "year, month"
|
group_fields = "year" if granularity == "yearly" else "year, month"
|
||||||
order_fields = "year" if granularity == "yearly" else "year, month"
|
order_fields = "year" if granularity == "yearly" else "year, month"
|
||||||
@@ -6653,6 +7086,25 @@ async def annual_summary(request: Request):
|
|||||||
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
|
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/process-cost")
|
||||||
|
async def process_cost(
|
||||||
|
request: Request,
|
||||||
|
source: str | None = None,
|
||||||
|
year: str | None = None,
|
||||||
|
code: str | None = None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return render_process_cost_page(
|
||||||
|
request,
|
||||||
|
source=source,
|
||||||
|
year=parse_optional_year(year),
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("프로세스 원가 페이지 에러: %s", exc)
|
||||||
|
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/wehago-compare")
|
@app.get("/wehago-compare")
|
||||||
async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None):
|
async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -616,6 +616,7 @@
|
|||||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
||||||
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
||||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
||||||
|
<a href="/process-cost" class="{% if request.url.path == '/process-cost' %}active{% endif %}">프로세스 원가</a>
|
||||||
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
|
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}프로세스 원가{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.pc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(280px, 340px);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-source-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(245, 247, 249, 0.95);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-source-tabs a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-source-tabs a.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-filter select {
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: calc(100vh - 285px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
padding: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-item.active {
|
||||||
|
border-color: #111;
|
||||||
|
box-shadow: 0 0 0 1px #111 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-code {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-name {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-card .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-card .value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-card .meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-block {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-block h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f7f8fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table th,
|
||||||
|
.pc-table td {
|
||||||
|
border-bottom: 1px solid #eceff4;
|
||||||
|
padding: 9px 10px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table th:first-child,
|
||||||
|
.pc-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f5f7fa;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #353b45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table tr:nth-child(even) td {
|
||||||
|
background: #fcfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table td.negative {
|
||||||
|
color: #b0272d;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table td.positive {
|
||||||
|
color: #1f6f3d;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-side-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-kpi {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-kpi h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-kpi-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-kpi-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-bar {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #edf1f5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #445a7e, #67a2d4);
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-mini-chart {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-mini-chart h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-mini-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
min-height: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .pc-grid {
|
||||||
|
grid-template-columns: minmax(250px, 280px) minmax(0, 1fr) minmax(300px, 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-view-mode="dual"] .pc-project-list {
|
||||||
|
max-height: calc(100vh - 250px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1260px) {
|
||||||
|
.pc-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-project-list {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-metric-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set detail = process_cost_detail or {} %}
|
||||||
|
{% set overview = detail.overview or {} %}
|
||||||
|
{% set diagnostics = detail.diagnostics or {} %}
|
||||||
|
<section class="panel pc-shell">
|
||||||
|
<div class="section-title" style="margin-bottom: 0;">
|
||||||
|
<h2>프로세스 원가 관리</h2>
|
||||||
|
<p>토목설계회사 기준 초안 (직접인건비/외주비/설계경비/판관비 중심)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-toolbar">
|
||||||
|
<div class="pc-source-tabs">
|
||||||
|
<a href="/process-cost?source=hanmac{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}" class="{% if process_cost_source == 'hanmac' %}active{% endif %}">Hanmac ERP</a>
|
||||||
|
<a href="/process-cost?source=wehago{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}{% if process_cost_selected_code %}&code={{ process_cost_selected_code }}{% endif %}" class="{% if process_cost_source == 'wehago' %}active{% endif %}">WEHAGO</a>
|
||||||
|
</div>
|
||||||
|
<form method="get" action="/process-cost" class="pc-filter">
|
||||||
|
<input type="hidden" name="source" value="{{ process_cost_source }}">
|
||||||
|
<input type="hidden" name="code" value="{{ process_cost_selected_code }}">
|
||||||
|
<select name="year" aria-label="연도 선택">
|
||||||
|
{% if not process_cost_years %}
|
||||||
|
<option value="">연도 없음</option>
|
||||||
|
{% else %}
|
||||||
|
{% for y in process_cost_years|sort(reverse=True) %}
|
||||||
|
<option value="{{ y }}" {% if process_cost_selected_year == y %}selected{% endif %}>{{ y }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="button-secondary">적용</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-grid">
|
||||||
|
<section class="pc-block">
|
||||||
|
<h3>프로젝트 선택</h3>
|
||||||
|
<div style="padding: 10px;">
|
||||||
|
<input type="text" id="projectSearchInput" class="pc-project-search" placeholder="프로젝트 코드/명 검색">
|
||||||
|
</div>
|
||||||
|
<div class="pc-project-list" id="projectList">
|
||||||
|
{% for item in process_cost_projects %}
|
||||||
|
<a
|
||||||
|
href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_year %}&year={{ process_cost_selected_year }}{% endif %}&code={{ item.support_dept_code }}"
|
||||||
|
class="pc-project-item {% if process_cost_selected_code == item.support_dept_code %}active{% endif %}"
|
||||||
|
data-project-search="{{ (item.support_dept_code ~ ' ' ~ item.support_dept_name ~ ' ' ~ (item.client_name or ''))|lower }}"
|
||||||
|
title="{{ item.support_dept_code }} {{ item.support_dept_name }}"
|
||||||
|
>
|
||||||
|
<div class="pc-project-head">
|
||||||
|
<span class="pc-project-code">{{ item.support_dept_code }}</span>
|
||||||
|
<span class="pc-badge">{{ "{:,.0f}".format(item.expense_amount|default(0)) }}원</span>
|
||||||
|
</div>
|
||||||
|
<div class="pc-project-name">{{ item.support_dept_name }}</div>
|
||||||
|
{% if item.client_name %}
|
||||||
|
<div class="pc-project-name">{{ item.client_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not process_cost_projects %}
|
||||||
|
<div class="pc-note" style="padding: 12px;">해당 조건의 프로젝트 데이터가 없습니다.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pc-shell" style="gap: 12px;">
|
||||||
|
<div class="pc-metric-grid">
|
||||||
|
<article class="pc-card">
|
||||||
|
<span class="label">계약금액</span>
|
||||||
|
<strong class="value">{{ "{:,.0f}".format(overview.contract_amount|default(0)) }}원</strong>
|
||||||
|
<span class="meta">{{ overview.support_dept_name or '-' }}</span>
|
||||||
|
</article>
|
||||||
|
<article class="pc-card">
|
||||||
|
<span class="label">누적 수익</span>
|
||||||
|
<strong class="value">{{ "{:,.0f}".format(overview.revenue_amount|default(0)) }}원</strong>
|
||||||
|
<span class="meta">최근 증빙일 {{ overview.last_posting_date or '-' }}</span>
|
||||||
|
</article>
|
||||||
|
<article class="pc-card">
|
||||||
|
<span class="label">누적 비용</span>
|
||||||
|
<strong class="value">{{ "{:,.0f}".format(overview.expense_amount|default(0)) }}원</strong>
|
||||||
|
<span class="meta">목표원가 {{ "{:,.0f}".format(overview.target_cost_amount|default(0)) }}원</span>
|
||||||
|
</article>
|
||||||
|
<article class="pc-card">
|
||||||
|
<span class="label">예상 영업수지</span>
|
||||||
|
<strong class="value">{{ "{:,.0f}".format(overview.profit_amount|default(0)) }}원</strong>
|
||||||
|
<span class="meta">마진율 {{ "{:.1f}".format(overview.profit_rate|default(0)) }}%</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-note">
|
||||||
|
설계회사 프로세스 원가 관리 초안: 목표원가(계약금액 기준 85%)와 실제 집행을 비교해 과다/과소 집행 구간을 빠르게 파악합니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="pc-block">
|
||||||
|
<h3>설계 프로세스별 원가 현황</h3>
|
||||||
|
<div class="pc-table-wrap">
|
||||||
|
<table class="pc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>구분</th>
|
||||||
|
<th>목표 원가</th>
|
||||||
|
<th>실제 집행</th>
|
||||||
|
<th>차이(목표-실제)</th>
|
||||||
|
<th>집행률</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in detail.phase_rows or [] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.phase }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(row.target_amount|default(0)) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(row.actual_amount|default(0)) }}</td>
|
||||||
|
<td class="{% if (row.gap_amount|default(0)) < 0 %}negative{% elif (row.gap_amount|default(0)) > 0 %}positive{% endif %}">
|
||||||
|
{{ "{:,.0f}".format(row.gap_amount|default(0)) }}
|
||||||
|
</td>
|
||||||
|
<td>{{ "{:.1f}".format(row.progress_rate|default(0)) }}%</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not (detail.phase_rows or []) %}
|
||||||
|
<tr><td colspan="5" style="text-align:center; color: var(--muted);">표시할 데이터가 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pc-block">
|
||||||
|
<h3>계정별 집행 상위 항목</h3>
|
||||||
|
<div class="pc-table-wrap">
|
||||||
|
<table class="pc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>계정</th>
|
||||||
|
<th>집행금액</th>
|
||||||
|
<th>비중</th>
|
||||||
|
<th>전표건수</th>
|
||||||
|
<th>최근 증빙일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in detail.account_rows or [] %}
|
||||||
|
<tr>
|
||||||
|
<td title="{{ row.account_code }} {{ row.account_name }}">{{ row.account_code }} {{ row.account_name }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(row.amount|default(0)) }}</td>
|
||||||
|
<td>{{ "{:.1f}".format(row.share_rate|default(0)) }}%</td>
|
||||||
|
<td>{{ "{:,}".format(row.row_count|default(0)) }}</td>
|
||||||
|
<td>{{ row.last_posting_date or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not (detail.account_rows or []) %}
|
||||||
|
<tr><td colspan="5" style="text-align:center; color: var(--muted);">표시할 데이터가 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="pc-side-stack">
|
||||||
|
<section class="pc-kpi">
|
||||||
|
<h3>원가 건전성 지표</h3>
|
||||||
|
<div class="pc-kpi-row">
|
||||||
|
<div class="pc-kpi-head"><span>비용/수익 비율</span><strong>{{ "{:.1f}".format(diagnostics.cost_to_revenue_ratio|default(0)) }}%</strong></div>
|
||||||
|
<div class="pc-bar"><span data-rate="{{ diagnostics.cost_to_revenue_ratio|default(0) }}"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pc-kpi-row">
|
||||||
|
<div class="pc-kpi-head"><span>직접인건비 비중</span><strong>{{ "{:.1f}".format(diagnostics.labor_ratio|default(0)) }}%</strong></div>
|
||||||
|
<div class="pc-bar"><span data-rate="{{ diagnostics.labor_ratio|default(0) }}"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pc-kpi-row">
|
||||||
|
<div class="pc-kpi-head"><span>외주비 비중</span><strong>{{ "{:.1f}".format(diagnostics.outsourcing_ratio|default(0)) }}%</strong></div>
|
||||||
|
<div class="pc-bar"><span data-rate="{{ diagnostics.outsourcing_ratio|default(0) }}"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pc-kpi-row">
|
||||||
|
<div class="pc-kpi-head"><span>설계경비 비중</span><strong>{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%</strong></div>
|
||||||
|
<div class="pc-bar"><span data-rate="{{ diagnostics.design_cost_ratio|default(0) }}"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pc-kpi-row">
|
||||||
|
<div class="pc-kpi-head"><span>판관비 비중</span><strong>{{ "{:.1f}".format(diagnostics.sga_ratio|default(0)) }}%</strong></div>
|
||||||
|
<div class="pc-bar"><span data-rate="{{ diagnostics.sga_ratio|default(0) }}"></span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pc-kpi">
|
||||||
|
<h3>데이터 현황</h3>
|
||||||
|
<div class="pc-kpi-head"><span>선택 소스</span><strong>{% if process_cost_source == 'wehago' %}WEHAGO{% else %}Hanmac ERP{% endif %}</strong></div>
|
||||||
|
<div class="pc-kpi-head"><span>연도</span><strong>{{ process_cost_selected_year or '-' }}</strong></div>
|
||||||
|
<div class="pc-kpi-head"><span>전표 건수</span><strong>{{ "{:,}".format(overview.voucher_count|default(0)) }}</strong></div>
|
||||||
|
<div class="pc-kpi-head"><span>행 건수</span><strong>{{ "{:,}".format(overview.row_count|default(0)) }}</strong></div>
|
||||||
|
<div class="pc-kpi-head"><span>실행률</span><strong>{{ "{:.1f}".format(overview.execution_rate|default(0)) }}%</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pc-mini-chart">
|
||||||
|
<h3>최근 월별 수익/비용 추이</h3>
|
||||||
|
<svg id="processMonthlyChart" viewBox="0 0 540 220" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const searchInput = document.getElementById("projectSearchInput");
|
||||||
|
const list = document.getElementById("projectList");
|
||||||
|
if (searchInput && list) {
|
||||||
|
searchInput.addEventListener("input", () => {
|
||||||
|
const keyword = (searchInput.value || "").trim().toLowerCase();
|
||||||
|
list.querySelectorAll("[data-project-search]").forEach((item) => {
|
||||||
|
const text = (item.dataset.projectSearch || "").toLowerCase();
|
||||||
|
item.style.display = !keyword || text.includes(keyword) ? "" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".pc-bar > span[data-rate]").forEach((bar) => {
|
||||||
|
const raw = Number(bar.dataset.rate || 0);
|
||||||
|
const width = Math.max(0, Math.min(raw, 100));
|
||||||
|
bar.style.width = `${width}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthlyRows = {{ (process_cost_detail.monthly_rows or []) | tojson }};
|
||||||
|
const svg = document.getElementById("processMonthlyChart");
|
||||||
|
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
|
||||||
|
if (svg) {
|
||||||
|
svg.innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="#7a8089" font-size="13">월별 데이터가 없습니다.</text>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 540;
|
||||||
|
const height = 220;
|
||||||
|
const pad = { top: 16, right: 14, bottom: 36, left: 42 };
|
||||||
|
const plotW = width - pad.left - pad.right;
|
||||||
|
const plotH = height - pad.top - pad.bottom;
|
||||||
|
const maxVal = Math.max(
|
||||||
|
...monthlyRows.map((row) => Number(row.revenue_amount || 0)),
|
||||||
|
...monthlyRows.map((row) => Number(row.expense_amount || 0)),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const stepX = monthlyRows.length > 1 ? plotW / (monthlyRows.length - 1) : 0;
|
||||||
|
const y = (v) => pad.top + (1 - Math.min(v / maxVal, 1)) * plotH;
|
||||||
|
const x = (i) => pad.left + stepX * i;
|
||||||
|
|
||||||
|
const revenuePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.revenue_amount || 0))}`).join(" ");
|
||||||
|
const expensePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.expense_amount || 0))}`).join(" ");
|
||||||
|
|
||||||
|
let labels = "";
|
||||||
|
monthlyRows.forEach((row, i) => {
|
||||||
|
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${(row.month_label || '').slice(2)}</text>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.innerHTML = `
|
||||||
|
<line x1="${pad.left}" y1="${pad.top}" x2="${pad.left}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||||||
|
<line x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||||||
|
<polyline points="${revenuePoints}" fill="none" stroke="#2c5f96" stroke-width="2.3" />
|
||||||
|
<polyline points="${expensePoints}" fill="none" stroke="#d9822b" stroke-width="2.3" />
|
||||||
|
${labels}
|
||||||
|
<text x="${pad.left}" y="${pad.top - 3}" fill="#2c5f96" font-size="11">수익</text>
|
||||||
|
<text x="${pad.left + 36}" y="${pad.top - 3}" fill="#d9822b" font-size="11">비용</text>
|
||||||
|
`;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user