Refine project status workflows and dashboards
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
@@ -8,7 +9,8 @@ from urllib.parse import parse_qs, quote_plus
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, File, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from openpyxl import load_workbook
|
||||
@@ -192,6 +194,7 @@ def init_db() -> None:
|
||||
task_plan_joint_operating_cost REAL DEFAULT 0,
|
||||
task_plan_entries_json TEXT DEFAULT '[]',
|
||||
exec_budget_labor_by_grade REAL DEFAULT 0,
|
||||
exec_labor_rates_json TEXT DEFAULT '{}',
|
||||
exec_budget_outsource REAL DEFAULT 0,
|
||||
exec_budget_cost_plan REAL DEFAULT 0,
|
||||
exec_budget_entries_json TEXT DEFAULT '[]',
|
||||
@@ -227,6 +230,7 @@ def init_db() -> None:
|
||||
"task_plan_joint_operating_cost": "REAL DEFAULT 0",
|
||||
"task_plan_entries_json": "TEXT DEFAULT '[]'",
|
||||
"exec_budget_labor_by_grade": "REAL DEFAULT 0",
|
||||
"exec_labor_rates_json": "TEXT DEFAULT '{}'",
|
||||
"exec_budget_outsource": "REAL DEFAULT 0",
|
||||
"exec_budget_cost_plan": "REAL DEFAULT 0",
|
||||
"exec_budget_entries_json": "TEXT DEFAULT '[]'",
|
||||
@@ -368,6 +372,64 @@ def get_support_department_options() -> list[dict[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def get_cost_department_options() -> list[dict[str, str]]:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT cost_dept_name
|
||||
FROM transactions
|
||||
WHERE COALESCE(cost_dept_name, '') <> ''
|
||||
ORDER BY cost_dept_name
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
return [
|
||||
{"cost_dept_name": normalize_text(row["cost_dept_name"])}
|
||||
for row in rows
|
||||
if normalize_text(row["cost_dept_name"])
|
||||
]
|
||||
|
||||
|
||||
def get_cost_account_options() -> list[dict[str, str]]:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT account_code, account_name
|
||||
FROM transactions
|
||||
WHERE accounting_category = '원가'
|
||||
AND COALESCE(account_code, '') <> ''
|
||||
AND COALESCE(account_name, '') <> ''
|
||||
ORDER BY account_code, account_name
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
deduped: dict[tuple[str, str], dict[str, str]] = {}
|
||||
for row in rows:
|
||||
account_code = normalize_text(row["account_code"])
|
||||
account_name = normalize_text(row["account_name"])
|
||||
if not account_code or not account_name:
|
||||
continue
|
||||
normalized_code, normalized_name, _ = normalize_account_display(account_code, account_name)
|
||||
key = (normalized_code, normalized_name)
|
||||
deduped[key] = {
|
||||
"account_code": normalized_code,
|
||||
"account_name": normalized_name,
|
||||
}
|
||||
return sorted(deduped.values(), key=lambda item: (item["account_code"], item["account_name"]))
|
||||
|
||||
|
||||
def normalize_account_display(account_code: Any, account_name: Any) -> tuple[str, str, str]:
|
||||
normalized_code = normalize_text(account_code)[:6]
|
||||
normalized_name = re.sub(r"\s*\(.*$", "", normalize_text(account_name)).strip()
|
||||
if normalized_code and normalized_name:
|
||||
label = f"{normalized_code} · {normalized_name}"
|
||||
else:
|
||||
label = normalized_name or normalized_code or "미분류"
|
||||
return normalized_code, normalized_name, label
|
||||
|
||||
|
||||
def get_data_version() -> str:
|
||||
with engine.begin() as conn:
|
||||
transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar()
|
||||
@@ -753,16 +815,21 @@ def get_project_status_rows() -> list[dict[str, Any]]:
|
||||
b.support_dept_name,
|
||||
b.row_count,
|
||||
COALESCE(ps.progress_rate, 0) AS progress_rate,
|
||||
COALESCE(ps.contract_amount, 0) AS contract_amount,
|
||||
COALESCE(ps.collection_amount, 0) AS collection_amount,
|
||||
COALESCE(ps.collection_entries_json, '[]') AS collection_entries_json,
|
||||
COALESCE(ps.change_round, '') AS change_round,
|
||||
COALESCE(ps.item_investment, 0) AS item_investment,
|
||||
COALESCE(ps.task_plan_department_budget, 0) AS task_plan_department_budget,
|
||||
COALESCE(ps.task_plan_outsource_budget, 0) AS task_plan_outsource_budget,
|
||||
COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail,
|
||||
COALESCE(ps.task_plan_joint_operating_cost, 0) AS task_plan_joint_operating_cost,
|
||||
COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json,
|
||||
COALESCE(ps.exec_budget_labor_by_grade, 0) AS exec_budget_labor_by_grade,
|
||||
COALESCE(ps.exec_budget_outsource, 0) AS exec_budget_outsource,
|
||||
COALESCE(ps.exec_budget_cost_plan, 0) AS exec_budget_cost_plan,
|
||||
COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json,
|
||||
COALESCE(ps.actual_input_entries_json, '[]') AS actual_input_entries_json,
|
||||
COALESCE(ps.expected_as_cost, 0) AS expected_as_cost,
|
||||
COALESCE(ps.expected_sga_budget, 0) AS expected_sga_budget,
|
||||
COALESCE(ps.project_start_date, '') AS project_start_date,
|
||||
@@ -807,7 +874,15 @@ def get_project_status_rows() -> list[dict[str, Any]]:
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["collection_entries"] = decode_json_rows(item.pop("collection_entries_json", "[]"))
|
||||
item["task_plan_entries"] = decode_json_rows(item.pop("task_plan_entries_json", "[]"))
|
||||
item["exec_budget_entries"] = decode_json_rows(item.pop("exec_budget_entries_json", "[]"))
|
||||
item["actual_input_entries"] = decode_json_rows(item.pop("actual_input_entries_json", "[]"))
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]:
|
||||
@@ -827,6 +902,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
"task_plan_joint_operating_cost": "",
|
||||
"task_plan_entries": [],
|
||||
"exec_budget_labor_by_grade": "",
|
||||
"exec_labor_rates": {},
|
||||
"exec_budget_outsource": "",
|
||||
"exec_budget_cost_plan": "",
|
||||
"exec_budget_entries": [],
|
||||
@@ -861,6 +937,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
COALESCE(ps.task_plan_joint_operating_cost, '') AS task_plan_joint_operating_cost,
|
||||
COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json,
|
||||
COALESCE(ps.exec_budget_labor_by_grade, '') AS exec_budget_labor_by_grade,
|
||||
COALESCE(ps.exec_labor_rates_json, '{}') AS exec_labor_rates_json,
|
||||
COALESCE(ps.exec_budget_outsource, '') AS exec_budget_outsource,
|
||||
COALESCE(ps.exec_budget_cost_plan, '') AS exec_budget_cost_plan,
|
||||
COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json,
|
||||
@@ -904,6 +981,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
"task_plan_joint_operating_cost": "",
|
||||
"task_plan_entries": [],
|
||||
"exec_budget_labor_by_grade": "",
|
||||
"exec_labor_rates": {},
|
||||
"exec_budget_outsource": "",
|
||||
"exec_budget_cost_plan": "",
|
||||
"exec_budget_entries": [],
|
||||
@@ -924,6 +1002,10 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
result["task_plan_entries"] = decode_json_rows(result.pop("task_plan_entries_json", "[]"))
|
||||
result["exec_budget_entries"] = decode_json_rows(result.pop("exec_budget_entries_json", "[]"))
|
||||
result["actual_input_entries"] = decode_json_rows(result.pop("actual_input_entries_json", "[]"))
|
||||
try:
|
||||
result["exec_labor_rates"] = json.loads(normalize_text(result.pop("exec_labor_rates_json", "{}")) or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result["exec_labor_rates"] = {}
|
||||
if not result["collection_entries"] and normalize_amount(result.get("collection_amount")):
|
||||
result["collection_entries"] = [
|
||||
{
|
||||
@@ -938,20 +1020,62 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
if not result["task_plan_entries"]:
|
||||
fallback_task_rows = []
|
||||
if normalize_amount(result.get("task_plan_department_budget")):
|
||||
fallback_task_rows.append({"group": "department", "label": "기존 부서별 배분액", "amount": result.get("task_plan_department_budget", "")})
|
||||
fallback_task_rows.append(
|
||||
{
|
||||
"group": "department",
|
||||
"dept_name": "기존 부서별 배분",
|
||||
"work_name": "",
|
||||
"amount": result.get("task_plan_department_budget", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("task_plan_outsource_budget")):
|
||||
fallback_task_rows.append({"group": "outsource", "label": "기존 외주비", "amount": result.get("task_plan_outsource_budget", ""), "note": result.get("task_plan_outsource_detail", "")})
|
||||
fallback_task_rows.append(
|
||||
{
|
||||
"group": "outsource",
|
||||
"dept_name": "기존 외주비",
|
||||
"work_name": result.get("task_plan_outsource_detail", ""),
|
||||
"amount": result.get("task_plan_outsource_budget", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("task_plan_joint_operating_cost")):
|
||||
fallback_task_rows.append({"group": "joint", "label": "기존 합사운영비", "amount": result.get("task_plan_joint_operating_cost", "")})
|
||||
fallback_task_rows.append(
|
||||
{
|
||||
"group": "joint",
|
||||
"dept_name": "기존 합사운영비",
|
||||
"work_name": "",
|
||||
"amount": result.get("task_plan_joint_operating_cost", ""),
|
||||
}
|
||||
)
|
||||
result["task_plan_entries"] = fallback_task_rows
|
||||
if not result["exec_budget_entries"]:
|
||||
fallback_exec_rows = []
|
||||
if normalize_amount(result.get("exec_budget_labor_by_grade")):
|
||||
fallback_exec_rows.append({"group": "labor", "label": "기존 직급별 인건비", "amount": result.get("exec_budget_labor_by_grade", "")})
|
||||
fallback_exec_rows.append(
|
||||
{
|
||||
"group": "labor",
|
||||
"grade": "기존 인건비",
|
||||
"hours": "",
|
||||
"amount": result.get("exec_budget_labor_by_grade", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("exec_budget_outsource")):
|
||||
fallback_exec_rows.append({"group": "outsource", "label": "기존 외주비", "amount": result.get("exec_budget_outsource", "")})
|
||||
fallback_exec_rows.append(
|
||||
{
|
||||
"group": "outsource",
|
||||
"dept_name": "기존 외주비",
|
||||
"work_name": "",
|
||||
"amount": result.get("exec_budget_outsource", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("exec_budget_cost_plan")):
|
||||
fallback_exec_rows.append({"group": "cost_plan", "label": "기존 비용계획", "amount": result.get("exec_budget_cost_plan", "")})
|
||||
fallback_exec_rows.append(
|
||||
{
|
||||
"group": "cost_plan",
|
||||
"account_code": "기존",
|
||||
"account_name": "비용계획",
|
||||
"amount": result.get("exec_budget_cost_plan", ""),
|
||||
}
|
||||
)
|
||||
result["exec_budget_entries"] = fallback_exec_rows
|
||||
if not result["actual_input_entries"] and normalize_amount(result.get("item_investment")):
|
||||
result["actual_input_entries"] = [
|
||||
@@ -1175,7 +1299,8 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[
|
||||
WHEN accounting_category = '판관비' THEN 'sga'
|
||||
ELSE 'other'
|
||||
END AS breakdown_kind,
|
||||
COALESCE(account_name, account_code, '미분류') AS account_label,
|
||||
COALESCE(account_code, '') AS account_code,
|
||||
COALESCE(account_name, '') AS account_name,
|
||||
SUM(COALESCE(amount, 0)) AS total_amount
|
||||
FROM transactions
|
||||
WHERE COALESCE(support_dept_code, '') <> ''
|
||||
@@ -1184,27 +1309,32 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[
|
||||
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
|
||||
AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비'))
|
||||
{year_clause}
|
||||
GROUP BY support_dept_code, breakdown_kind, account_label
|
||||
ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_label
|
||||
GROUP BY support_dept_code, breakdown_kind, account_code, account_name
|
||||
ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_code, account_name
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).mappings().all()
|
||||
|
||||
result: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
result: dict[str, dict[str, dict[str, float]]] = {}
|
||||
for row in rows:
|
||||
code = row["support_dept_code"]
|
||||
kind = row["breakdown_kind"]
|
||||
if kind == "other":
|
||||
continue
|
||||
result.setdefault(code, {"revenue": [], "cost": [], "sga": []})
|
||||
result[code][kind].append(
|
||||
{
|
||||
"label": row["account_label"],
|
||||
"amount": row["total_amount"] or 0,
|
||||
}
|
||||
)
|
||||
return result
|
||||
_, _, label = normalize_account_display(row["account_code"], row["account_name"])
|
||||
result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}})
|
||||
result[code][kind][label] = result[code][kind].get(label, 0.0) + float(row["total_amount"] or 0)
|
||||
|
||||
normalized_result: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
for code, buckets in result.items():
|
||||
normalized_result[code] = {}
|
||||
for kind, entries in buckets.items():
|
||||
normalized_result[code][kind] = [
|
||||
{"label": label, "amount": amount}
|
||||
for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True)
|
||||
]
|
||||
return normalized_result
|
||||
|
||||
|
||||
def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]:
|
||||
@@ -1454,6 +1584,28 @@ def build_named_amount_rows(
|
||||
return filter_amount_rows(rows, amount_key=amount_key)
|
||||
|
||||
|
||||
def build_triplet_amount_rows(
|
||||
first_values: list[Any],
|
||||
second_values: list[Any],
|
||||
amounts: list[Any],
|
||||
*,
|
||||
first_key: str,
|
||||
second_key: str,
|
||||
amount_key: str = "amount",
|
||||
) -> list[dict[str, Any]]:
|
||||
max_length = max(len(first_values), len(second_values), len(amounts))
|
||||
rows = []
|
||||
for index in range(max_length):
|
||||
rows.append(
|
||||
{
|
||||
first_key: first_values[index] if index < len(first_values) else "",
|
||||
second_key: second_values[index] if index < len(second_values) else "",
|
||||
amount_key: amounts[index] if index < len(amounts) else "",
|
||||
}
|
||||
)
|
||||
return filter_amount_rows(rows, amount_key=amount_key)
|
||||
|
||||
|
||||
def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
progress_types = payload.get("collection_progress_type[]", [])
|
||||
billing_rounds = payload.get("collection_billing_round[]", [])
|
||||
@@ -1513,57 +1665,59 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
collection_amount = sum_row_amounts(collection_rows)
|
||||
progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0
|
||||
|
||||
task_plan_department_rows = build_named_amount_rows(
|
||||
payload.get("task_plan_department_label[]", []),
|
||||
task_plan_department_rows = build_triplet_amount_rows(
|
||||
payload.get("task_plan_department_dept[]", []),
|
||||
payload.get("task_plan_department_work[]", []),
|
||||
payload.get("task_plan_department_amount[]", []),
|
||||
)
|
||||
task_plan_outsource_rows = []
|
||||
outsource_names = payload.get("task_plan_outsource_vendor[]", [])
|
||||
outsource_amounts = payload.get("task_plan_outsource_amount[]", [])
|
||||
outsource_notes = payload.get("task_plan_outsource_note[]", [])
|
||||
for index, vendor in enumerate(outsource_names):
|
||||
task_plan_outsource_rows.append(
|
||||
{
|
||||
"label": vendor,
|
||||
"amount": outsource_amounts[index] if index < len(outsource_amounts) else "",
|
||||
"note": outsource_notes[index] if index < len(outsource_notes) else "",
|
||||
"group": "outsource",
|
||||
}
|
||||
)
|
||||
task_plan_outsource_rows = filter_amount_rows(task_plan_outsource_rows, amount_key="amount")
|
||||
task_plan_joint_rows = build_named_amount_rows(
|
||||
payload.get("task_plan_joint_label[]", []),
|
||||
payload.get("task_plan_joint_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in task_plan_department_rows:
|
||||
row["group"] = "department"
|
||||
task_plan_outsource_rows = build_triplet_amount_rows(
|
||||
payload.get("task_plan_outsource_dept[]", []),
|
||||
payload.get("task_plan_outsource_work[]", []),
|
||||
payload.get("task_plan_outsource_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in task_plan_outsource_rows:
|
||||
row["group"] = "outsource"
|
||||
task_plan_joint_rows = build_triplet_amount_rows(
|
||||
payload.get("task_plan_joint_dept[]", []),
|
||||
payload.get("task_plan_joint_work[]", []),
|
||||
payload.get("task_plan_joint_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in task_plan_joint_rows:
|
||||
row["group"] = "joint"
|
||||
task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows
|
||||
|
||||
exec_labor_rows = build_named_amount_rows(
|
||||
payload.get("exec_labor_role[]", []),
|
||||
exec_labor_rows = build_triplet_amount_rows(
|
||||
payload.get("exec_labor_grade[]", []),
|
||||
payload.get("exec_labor_hours[]", []),
|
||||
payload.get("exec_labor_amount[]", []),
|
||||
first_key="grade",
|
||||
second_key="hours",
|
||||
)
|
||||
for row in exec_labor_rows:
|
||||
row["group"] = "labor"
|
||||
exec_outsource_rows = []
|
||||
exec_outsource_names = payload.get("exec_outsource_vendor[]", [])
|
||||
exec_outsource_amounts = payload.get("exec_outsource_amount[]", [])
|
||||
exec_outsource_notes = payload.get("exec_outsource_note[]", [])
|
||||
for index, vendor in enumerate(exec_outsource_names):
|
||||
exec_outsource_rows.append(
|
||||
{
|
||||
"label": vendor,
|
||||
"amount": exec_outsource_amounts[index] if index < len(exec_outsource_amounts) else "",
|
||||
"note": exec_outsource_notes[index] if index < len(exec_outsource_notes) else "",
|
||||
"group": "outsource",
|
||||
}
|
||||
)
|
||||
exec_outsource_rows = filter_amount_rows(exec_outsource_rows, amount_key="amount")
|
||||
exec_cost_plan_rows = build_named_amount_rows(
|
||||
payload.get("exec_cost_plan_label[]", []),
|
||||
exec_outsource_rows = build_triplet_amount_rows(
|
||||
payload.get("exec_outsource_dept[]", []),
|
||||
payload.get("exec_outsource_work[]", []),
|
||||
payload.get("exec_outsource_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in exec_outsource_rows:
|
||||
row["group"] = "outsource"
|
||||
exec_cost_plan_rows = build_triplet_amount_rows(
|
||||
payload.get("exec_cost_plan_code[]", []),
|
||||
payload.get("exec_cost_plan_name[]", []),
|
||||
payload.get("exec_cost_plan_amount[]", []),
|
||||
first_key="account_code",
|
||||
second_key="account_name",
|
||||
)
|
||||
for row in exec_cost_plan_rows:
|
||||
row["group"] = "cost_plan"
|
||||
@@ -1587,6 +1741,7 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
expected_sga_rate = normalize_amount(payload.get("expected_sga_rate"))
|
||||
expected_as_cost = contract_amount * expected_as_rate / 100 if contract_amount else 0.0
|
||||
expected_sga_budget = contract_amount * expected_sga_rate / 100 if contract_amount else 0.0
|
||||
exec_labor_rates = normalize_text(payload.get("exec_labor_rates_json")) or "{}"
|
||||
|
||||
return {
|
||||
"support_dept_code": normalize_text(payload.get("support_dept_code")),
|
||||
@@ -1600,12 +1755,13 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"task_plan_department_budget": sum_row_amounts(task_plan_department_rows),
|
||||
"task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows),
|
||||
"task_plan_outsource_detail": "\n".join(
|
||||
f"{row.get('label', '')}: {format_amount_for_text(row.get('amount'))}{' / ' + row.get('note', '') if row.get('note') else ''}"
|
||||
f"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:")
|
||||
for row in task_plan_outsource_rows
|
||||
),
|
||||
"task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows),
|
||||
"task_plan_entries_json": encode_json_rows(task_plan_rows),
|
||||
"exec_budget_labor_by_grade": sum_row_amounts(exec_labor_rows),
|
||||
"exec_labor_rates_json": exec_labor_rates,
|
||||
"exec_budget_outsource": sum_row_amounts(exec_outsource_rows),
|
||||
"exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows),
|
||||
"exec_budget_entries_json": encode_json_rows(exec_budget_rows),
|
||||
@@ -1714,6 +1870,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
||||
task_plan_joint_operating_cost,
|
||||
task_plan_entries_json,
|
||||
exec_budget_labor_by_grade,
|
||||
exec_labor_rates_json,
|
||||
exec_budget_outsource,
|
||||
exec_budget_cost_plan,
|
||||
exec_budget_entries_json,
|
||||
@@ -1745,6 +1902,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
||||
:task_plan_joint_operating_cost,
|
||||
:task_plan_entries_json,
|
||||
:exec_budget_labor_by_grade,
|
||||
:exec_labor_rates_json,
|
||||
:exec_budget_outsource,
|
||||
:exec_budget_cost_plan,
|
||||
:exec_budget_entries_json,
|
||||
@@ -1776,6 +1934,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
||||
task_plan_joint_operating_cost = excluded.task_plan_joint_operating_cost,
|
||||
task_plan_entries_json = excluded.task_plan_entries_json,
|
||||
exec_budget_labor_by_grade = excluded.exec_budget_labor_by_grade,
|
||||
exec_labor_rates_json = excluded.exec_labor_rates_json,
|
||||
exec_budget_outsource = excluded.exec_budget_outsource,
|
||||
exec_budget_cost_plan = excluded.exec_budget_cost_plan,
|
||||
exec_budget_entries_json = excluded.exec_budget_entries_json,
|
||||
@@ -1846,6 +2005,8 @@ def render_projects_page(
|
||||
"project_status_rows": get_project_status_rows(),
|
||||
"project_edit": get_project_status_for_edit(edit_code),
|
||||
"support_department_options": get_support_department_options(),
|
||||
"cost_department_options": get_cost_department_options(),
|
||||
"cost_account_options": get_cost_account_options(),
|
||||
}
|
||||
return templates.TemplateResponse(request, "projects.html", context)
|
||||
|
||||
@@ -1884,6 +2045,15 @@ async def projects(request: Request, edit_code: str | None = None, year: str | N
|
||||
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
|
||||
|
||||
|
||||
@app.get("/projects/edit-data")
|
||||
async def project_edit_data(code: str | None = None):
|
||||
try:
|
||||
return JSONResponse(content=jsonable_encoder(get_project_status_for_edit(code)))
|
||||
except Exception as exc:
|
||||
logger.exception("사업현황 편집 데이터 조회 에러: %s", exc)
|
||||
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/annual-summary")
|
||||
async def annual_summary(request: Request):
|
||||
try:
|
||||
@@ -1953,4 +2123,5 @@ async def save_project(request: Request):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8010, reload=False)
|
||||
auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"}
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8010, reload=auto_reload, reload_dirs=[str(BASE_DIR)])
|
||||
|
||||
@@ -11,4 +11,6 @@ if [ ! -x ".venv/bin/python" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
|
||||
|
||||
exec ./.venv/bin/python main.py
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>수익/비용/영업수지 그래프</h2>
|
||||
<h2>수금/비용/영업수지 그래프</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="legend chart-legend" id="balanceLegend"></div>
|
||||
@@ -162,7 +162,7 @@
|
||||
};
|
||||
|
||||
const labels = {
|
||||
revenue_sum: "수익",
|
||||
revenue_sum: "수금",
|
||||
project_cost_sum: "원가(프로젝트)",
|
||||
support_cost_sum: "원가(지원부서)",
|
||||
support_sga_sum: "판관비(지원부서)",
|
||||
@@ -180,16 +180,18 @@
|
||||
function formatAxisLabel(value) {
|
||||
const numeric = Number(value || 0);
|
||||
if (!numeric) return "0.0";
|
||||
if (numeric >= 100000000) return `${(numeric / 100000000).toFixed(1)}억`;
|
||||
if (numeric >= 1000000) return `${(numeric / 1000000).toFixed(1)}백만`;
|
||||
if (numeric >= 1000) return `${(numeric / 1000).toFixed(1)}천`;
|
||||
const sign = numeric < 0 ? "-" : "";
|
||||
const absolute = Math.abs(numeric);
|
||||
if (absolute >= 100000000) return `${sign}${(absolute / 100000000).toFixed(1)}억`;
|
||||
if (absolute >= 1000000) return `${sign}${(absolute / 1000000).toFixed(1)}백만`;
|
||||
if (absolute >= 1000) return `${sign}${(absolute / 1000).toFixed(1)}천`;
|
||||
return formatNumber(numeric);
|
||||
}
|
||||
|
||||
function pickTickStep(maxValue) {
|
||||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||
const units = [1, 2, 5];
|
||||
const raw = Math.max(maxValue / 5 / baseUnit, 1);
|
||||
const raw = Math.max(maxValue / baseUnit, 1);
|
||||
let power = 1;
|
||||
while (power * 10 <= raw) power *= 10;
|
||||
for (const unit of units) {
|
||||
@@ -199,6 +201,14 @@
|
||||
return power * 10 * baseUnit;
|
||||
}
|
||||
|
||||
function buildPositiveAxisScale(maxValue, tickCount = 4) {
|
||||
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||||
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
|
||||
const tickStep = pickTickStep(paddedMax / tickCount);
|
||||
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||||
return { tickStep, tickMax };
|
||||
}
|
||||
|
||||
function renderLegend(targetId, keys) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
@@ -271,8 +281,7 @@
|
||||
}
|
||||
|
||||
function buildAxis(maxValue, width, height, margin) {
|
||||
const tickStep = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
||||
const { tickStep, tickMax } = buildPositiveAxisScale(maxValue, 4);
|
||||
let axis = "";
|
||||
for (let value = 0; value <= tickMax; value += tickStep) {
|
||||
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
|
||||
@@ -283,6 +292,21 @@
|
||||
return { axis, tickMax };
|
||||
}
|
||||
|
||||
function buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
|
||||
const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(minNegativeValue || 0), 1);
|
||||
const { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4);
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
|
||||
let axis = "";
|
||||
for (let value = -tickMax; value <= tickMax; value += tickStep) {
|
||||
const y = zeroY - (plotHeight * value) / (tickMax * 2);
|
||||
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="3 7" />`;
|
||||
axis += `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" fill="#5a6672" font-size="11" font-weight="700">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
axis += `<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#8ba0ae" stroke-width="1.2" />`;
|
||||
return { axis, tickMax, zeroY };
|
||||
}
|
||||
|
||||
function renderEmptyChart(svgId, message) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
@@ -375,12 +399,13 @@
|
||||
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const maxValue = Math.max(...series.flatMap((item) => [
|
||||
const maxPositiveValue = Math.max(...series.flatMap((item) => [
|
||||
item.revenue_sum || 0,
|
||||
item.total_expense || 0,
|
||||
Math.abs(item.operating_balance || 0),
|
||||
Math.max(item.operating_balance || 0, 0),
|
||||
]), 1);
|
||||
const { axis, tickMax } = buildAxis(maxValue, width, height, margin);
|
||||
const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0);
|
||||
const { axis, tickMax, zeroY } = buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin);
|
||||
const groupWidth = plotWidth / Math.max(series.length, 1);
|
||||
const groupGap = groupWidth * 0.22;
|
||||
const innerGap = 0;
|
||||
@@ -399,13 +424,17 @@
|
||||
series.forEach((item, index) => {
|
||||
const baseX = margin.left + index * groupWidth;
|
||||
metrics.forEach((key, metricIndex) => {
|
||||
const value = key === "operating_balance" ? Math.abs(item[key] || 0) : (item[key] || 0);
|
||||
const barHeight = (plotHeight * value) / tickMax;
|
||||
const rawValue = Number(item[key] || 0);
|
||||
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0);
|
||||
const barHeight = (plotHeight * Math.abs(value)) / (tickMax * 2);
|
||||
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
|
||||
const y = height - margin.bottom - barHeight;
|
||||
const y = value < 0 ? zeroY : zeroY - barHeight;
|
||||
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||
if (value > 0) {
|
||||
markup += `<text x="${x + barWidth / 2}" y="${Math.max(y - 8, margin.top + 12)}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||
if (value !== 0) {
|
||||
const labelY = value < 0
|
||||
? Math.min(y + barHeight + 14, height - margin.bottom + 6)
|
||||
: Math.max(y - 8, margin.top + 12);
|
||||
markup += `<text x="${x + barWidth / 2}" y="${labelY}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
});
|
||||
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${item.label}</text>`;
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
function pickTickStep(maxValue) {
|
||||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||
const units = [1, 2, 5];
|
||||
const raw = Math.max(maxValue / 5 / baseUnit, 1);
|
||||
const raw = Math.max(maxValue / baseUnit, 1);
|
||||
let power = 1;
|
||||
while (power * 10 <= raw) power *= 10;
|
||||
for (const unit of units) {
|
||||
@@ -355,6 +355,14 @@
|
||||
return power * 10 * baseUnit;
|
||||
}
|
||||
|
||||
function buildPositiveAxis(maxValue, tickCount = 4) {
|
||||
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||||
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
|
||||
const tickStep = pickTickStep(paddedMax / tickCount);
|
||||
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||||
return { tickStep, tickMax };
|
||||
}
|
||||
|
||||
function setLegend(containerId, metricKeys) {
|
||||
const target = document.getElementById(containerId);
|
||||
if (!target) return;
|
||||
@@ -393,8 +401,7 @@
|
||||
1,
|
||||
...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))),
|
||||
);
|
||||
const tickStep = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
||||
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
|
||||
const groupWidth = plotWidth / Math.max(rows.length, 1);
|
||||
const axisBaseY = height - margin.bottom;
|
||||
const groupGapRatio = granularity === "monthly" ? 0.28 : 0.18;
|
||||
@@ -464,8 +471,7 @@
|
||||
1,
|
||||
...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))),
|
||||
);
|
||||
const tickStep = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
||||
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
|
||||
const xStep = rows.length > 1 ? plotWidth / (rows.length - 1) : 0;
|
||||
const axisBaseY = height - margin.bottom;
|
||||
|
||||
|
||||
+1332
-197
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user