Refine project status workflows and dashboards
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -8,7 +9,8 @@ from urllib.parse import parse_qs, quote_plus
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, File, Request, UploadFile
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
@@ -192,6 +194,7 @@ def init_db() -> None:
|
|||||||
task_plan_joint_operating_cost REAL DEFAULT 0,
|
task_plan_joint_operating_cost REAL DEFAULT 0,
|
||||||
task_plan_entries_json TEXT DEFAULT '[]',
|
task_plan_entries_json TEXT DEFAULT '[]',
|
||||||
exec_budget_labor_by_grade REAL DEFAULT 0,
|
exec_budget_labor_by_grade REAL DEFAULT 0,
|
||||||
|
exec_labor_rates_json TEXT DEFAULT '{}',
|
||||||
exec_budget_outsource REAL DEFAULT 0,
|
exec_budget_outsource REAL DEFAULT 0,
|
||||||
exec_budget_cost_plan REAL DEFAULT 0,
|
exec_budget_cost_plan REAL DEFAULT 0,
|
||||||
exec_budget_entries_json TEXT DEFAULT '[]',
|
exec_budget_entries_json TEXT DEFAULT '[]',
|
||||||
@@ -227,6 +230,7 @@ def init_db() -> None:
|
|||||||
"task_plan_joint_operating_cost": "REAL DEFAULT 0",
|
"task_plan_joint_operating_cost": "REAL DEFAULT 0",
|
||||||
"task_plan_entries_json": "TEXT DEFAULT '[]'",
|
"task_plan_entries_json": "TEXT DEFAULT '[]'",
|
||||||
"exec_budget_labor_by_grade": "REAL DEFAULT 0",
|
"exec_budget_labor_by_grade": "REAL DEFAULT 0",
|
||||||
|
"exec_labor_rates_json": "TEXT DEFAULT '{}'",
|
||||||
"exec_budget_outsource": "REAL DEFAULT 0",
|
"exec_budget_outsource": "REAL DEFAULT 0",
|
||||||
"exec_budget_cost_plan": "REAL DEFAULT 0",
|
"exec_budget_cost_plan": "REAL DEFAULT 0",
|
||||||
"exec_budget_entries_json": "TEXT DEFAULT '[]'",
|
"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:
|
def get_data_version() -> str:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar()
|
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.support_dept_name,
|
||||||
b.row_count,
|
b.row_count,
|
||||||
COALESCE(ps.progress_rate, 0) AS progress_rate,
|
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_amount, 0) AS collection_amount,
|
||||||
|
COALESCE(ps.collection_entries_json, '[]') AS collection_entries_json,
|
||||||
COALESCE(ps.change_round, '') AS change_round,
|
COALESCE(ps.change_round, '') AS change_round,
|
||||||
COALESCE(ps.item_investment, 0) AS item_investment,
|
COALESCE(ps.item_investment, 0) AS item_investment,
|
||||||
COALESCE(ps.task_plan_department_budget, 0) AS task_plan_department_budget,
|
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_budget, 0) AS task_plan_outsource_budget,
|
||||||
COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail,
|
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_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_labor_by_grade, 0) AS exec_budget_labor_by_grade,
|
||||||
COALESCE(ps.exec_budget_outsource, 0) AS exec_budget_outsource,
|
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_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_as_cost, 0) AS expected_as_cost,
|
||||||
COALESCE(ps.expected_sga_budget, 0) AS expected_sga_budget,
|
COALESCE(ps.expected_sga_budget, 0) AS expected_sga_budget,
|
||||||
COALESCE(ps.project_start_date, '') AS project_start_date,
|
COALESCE(ps.project_start_date, '') AS project_start_date,
|
||||||
@@ -807,7 +874,15 @@ def get_project_status_rows() -> list[dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
).mappings().all()
|
).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]:
|
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_joint_operating_cost": "",
|
||||||
"task_plan_entries": [],
|
"task_plan_entries": [],
|
||||||
"exec_budget_labor_by_grade": "",
|
"exec_budget_labor_by_grade": "",
|
||||||
|
"exec_labor_rates": {},
|
||||||
"exec_budget_outsource": "",
|
"exec_budget_outsource": "",
|
||||||
"exec_budget_cost_plan": "",
|
"exec_budget_cost_plan": "",
|
||||||
"exec_budget_entries": [],
|
"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_joint_operating_cost, '') AS task_plan_joint_operating_cost,
|
||||||
COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json,
|
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_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_outsource, '') AS exec_budget_outsource,
|
||||||
COALESCE(ps.exec_budget_cost_plan, '') AS exec_budget_cost_plan,
|
COALESCE(ps.exec_budget_cost_plan, '') AS exec_budget_cost_plan,
|
||||||
COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json,
|
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_joint_operating_cost": "",
|
||||||
"task_plan_entries": [],
|
"task_plan_entries": [],
|
||||||
"exec_budget_labor_by_grade": "",
|
"exec_budget_labor_by_grade": "",
|
||||||
|
"exec_labor_rates": {},
|
||||||
"exec_budget_outsource": "",
|
"exec_budget_outsource": "",
|
||||||
"exec_budget_cost_plan": "",
|
"exec_budget_cost_plan": "",
|
||||||
"exec_budget_entries": [],
|
"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["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["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", "[]"))
|
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")):
|
if not result["collection_entries"] and normalize_amount(result.get("collection_amount")):
|
||||||
result["collection_entries"] = [
|
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"]:
|
if not result["task_plan_entries"]:
|
||||||
fallback_task_rows = []
|
fallback_task_rows = []
|
||||||
if normalize_amount(result.get("task_plan_department_budget")):
|
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")):
|
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")):
|
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
|
result["task_plan_entries"] = fallback_task_rows
|
||||||
if not result["exec_budget_entries"]:
|
if not result["exec_budget_entries"]:
|
||||||
fallback_exec_rows = []
|
fallback_exec_rows = []
|
||||||
if normalize_amount(result.get("exec_budget_labor_by_grade")):
|
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")):
|
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")):
|
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
|
result["exec_budget_entries"] = fallback_exec_rows
|
||||||
if not result["actual_input_entries"] and normalize_amount(result.get("item_investment")):
|
if not result["actual_input_entries"] and normalize_amount(result.get("item_investment")):
|
||||||
result["actual_input_entries"] = [
|
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'
|
WHEN accounting_category = '판관비' THEN 'sga'
|
||||||
ELSE 'other'
|
ELSE 'other'
|
||||||
END AS breakdown_kind,
|
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
|
SUM(COALESCE(amount, 0)) AS total_amount
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE COALESCE(support_dept_code, '') <> ''
|
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 support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
|
||||||
AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비'))
|
AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비'))
|
||||||
{year_clause}
|
{year_clause}
|
||||||
GROUP BY support_dept_code, breakdown_kind, account_label
|
GROUP BY support_dept_code, breakdown_kind, account_code, account_name
|
||||||
ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_label
|
ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_code, account_name
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
|
||||||
result: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
result: dict[str, dict[str, dict[str, float]]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
code = row["support_dept_code"]
|
code = row["support_dept_code"]
|
||||||
kind = row["breakdown_kind"]
|
kind = row["breakdown_kind"]
|
||||||
if kind == "other":
|
if kind == "other":
|
||||||
continue
|
continue
|
||||||
result.setdefault(code, {"revenue": [], "cost": [], "sga": []})
|
_, _, label = normalize_account_display(row["account_code"], row["account_name"])
|
||||||
result[code][kind].append(
|
result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}})
|
||||||
{
|
result[code][kind][label] = result[code][kind].get(label, 0.0) + float(row["total_amount"] or 0)
|
||||||
"label": row["account_label"],
|
|
||||||
"amount": row["total_amount"] or 0,
|
normalized_result: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||||
}
|
for code, buckets in result.items():
|
||||||
)
|
normalized_result[code] = {}
|
||||||
return result
|
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]]:
|
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)
|
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]]:
|
def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
progress_types = payload.get("collection_progress_type[]", [])
|
progress_types = payload.get("collection_progress_type[]", [])
|
||||||
billing_rounds = payload.get("collection_billing_round[]", [])
|
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)
|
collection_amount = sum_row_amounts(collection_rows)
|
||||||
progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0
|
progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0
|
||||||
|
|
||||||
task_plan_department_rows = build_named_amount_rows(
|
task_plan_department_rows = build_triplet_amount_rows(
|
||||||
payload.get("task_plan_department_label[]", []),
|
payload.get("task_plan_department_dept[]", []),
|
||||||
|
payload.get("task_plan_department_work[]", []),
|
||||||
payload.get("task_plan_department_amount[]", []),
|
payload.get("task_plan_department_amount[]", []),
|
||||||
)
|
first_key="dept_name",
|
||||||
task_plan_outsource_rows = []
|
second_key="work_name",
|
||||||
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[]", []),
|
|
||||||
)
|
)
|
||||||
for row in task_plan_department_rows:
|
for row in task_plan_department_rows:
|
||||||
row["group"] = "department"
|
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:
|
for row in task_plan_joint_rows:
|
||||||
row["group"] = "joint"
|
row["group"] = "joint"
|
||||||
task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows
|
task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows
|
||||||
|
|
||||||
exec_labor_rows = build_named_amount_rows(
|
exec_labor_rows = build_triplet_amount_rows(
|
||||||
payload.get("exec_labor_role[]", []),
|
payload.get("exec_labor_grade[]", []),
|
||||||
|
payload.get("exec_labor_hours[]", []),
|
||||||
payload.get("exec_labor_amount[]", []),
|
payload.get("exec_labor_amount[]", []),
|
||||||
|
first_key="grade",
|
||||||
|
second_key="hours",
|
||||||
)
|
)
|
||||||
for row in exec_labor_rows:
|
for row in exec_labor_rows:
|
||||||
row["group"] = "labor"
|
row["group"] = "labor"
|
||||||
exec_outsource_rows = []
|
exec_outsource_rows = build_triplet_amount_rows(
|
||||||
exec_outsource_names = payload.get("exec_outsource_vendor[]", [])
|
payload.get("exec_outsource_dept[]", []),
|
||||||
exec_outsource_amounts = payload.get("exec_outsource_amount[]", [])
|
payload.get("exec_outsource_work[]", []),
|
||||||
exec_outsource_notes = payload.get("exec_outsource_note[]", [])
|
payload.get("exec_outsource_amount[]", []),
|
||||||
for index, vendor in enumerate(exec_outsource_names):
|
first_key="dept_name",
|
||||||
exec_outsource_rows.append(
|
second_key="work_name",
|
||||||
{
|
|
||||||
"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")
|
for row in exec_outsource_rows:
|
||||||
exec_cost_plan_rows = build_named_amount_rows(
|
row["group"] = "outsource"
|
||||||
payload.get("exec_cost_plan_label[]", []),
|
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[]", []),
|
payload.get("exec_cost_plan_amount[]", []),
|
||||||
|
first_key="account_code",
|
||||||
|
second_key="account_name",
|
||||||
)
|
)
|
||||||
for row in exec_cost_plan_rows:
|
for row in exec_cost_plan_rows:
|
||||||
row["group"] = "cost_plan"
|
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_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_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
|
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 {
|
return {
|
||||||
"support_dept_code": normalize_text(payload.get("support_dept_code")),
|
"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_department_budget": sum_row_amounts(task_plan_department_rows),
|
||||||
"task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows),
|
"task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows),
|
||||||
"task_plan_outsource_detail": "\n".join(
|
"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
|
for row in task_plan_outsource_rows
|
||||||
),
|
),
|
||||||
"task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows),
|
"task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows),
|
||||||
"task_plan_entries_json": encode_json_rows(task_plan_rows),
|
"task_plan_entries_json": encode_json_rows(task_plan_rows),
|
||||||
"exec_budget_labor_by_grade": sum_row_amounts(exec_labor_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_outsource": sum_row_amounts(exec_outsource_rows),
|
||||||
"exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows),
|
"exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows),
|
||||||
"exec_budget_entries_json": encode_json_rows(exec_budget_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_joint_operating_cost,
|
||||||
task_plan_entries_json,
|
task_plan_entries_json,
|
||||||
exec_budget_labor_by_grade,
|
exec_budget_labor_by_grade,
|
||||||
|
exec_labor_rates_json,
|
||||||
exec_budget_outsource,
|
exec_budget_outsource,
|
||||||
exec_budget_cost_plan,
|
exec_budget_cost_plan,
|
||||||
exec_budget_entries_json,
|
exec_budget_entries_json,
|
||||||
@@ -1745,6 +1902,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
|||||||
:task_plan_joint_operating_cost,
|
:task_plan_joint_operating_cost,
|
||||||
:task_plan_entries_json,
|
:task_plan_entries_json,
|
||||||
:exec_budget_labor_by_grade,
|
:exec_budget_labor_by_grade,
|
||||||
|
:exec_labor_rates_json,
|
||||||
:exec_budget_outsource,
|
:exec_budget_outsource,
|
||||||
:exec_budget_cost_plan,
|
:exec_budget_cost_plan,
|
||||||
:exec_budget_entries_json,
|
: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_joint_operating_cost = excluded.task_plan_joint_operating_cost,
|
||||||
task_plan_entries_json = excluded.task_plan_entries_json,
|
task_plan_entries_json = excluded.task_plan_entries_json,
|
||||||
exec_budget_labor_by_grade = excluded.exec_budget_labor_by_grade,
|
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_outsource = excluded.exec_budget_outsource,
|
||||||
exec_budget_cost_plan = excluded.exec_budget_cost_plan,
|
exec_budget_cost_plan = excluded.exec_budget_cost_plan,
|
||||||
exec_budget_entries_json = excluded.exec_budget_entries_json,
|
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_status_rows": get_project_status_rows(),
|
||||||
"project_edit": get_project_status_for_edit(edit_code),
|
"project_edit": get_project_status_for_edit(edit_code),
|
||||||
"support_department_options": get_support_department_options(),
|
"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)
|
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)
|
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")
|
@app.get("/annual-summary")
|
||||||
async def annual_summary(request: Request):
|
async def annual_summary(request: Request):
|
||||||
try:
|
try:
|
||||||
@@ -1953,4 +2123,5 @@ async def save_project(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
|
||||||
|
|
||||||
exec ./.venv/bin/python main.py
|
exec ./.venv/bin/python main.py
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>수익/비용/영업수지 그래프</h2>
|
<h2>수금/비용/영업수지 그래프</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<div class="legend chart-legend" id="balanceLegend"></div>
|
<div class="legend chart-legend" id="balanceLegend"></div>
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
revenue_sum: "수익",
|
revenue_sum: "수금",
|
||||||
project_cost_sum: "원가(프로젝트)",
|
project_cost_sum: "원가(프로젝트)",
|
||||||
support_cost_sum: "원가(지원부서)",
|
support_cost_sum: "원가(지원부서)",
|
||||||
support_sga_sum: "판관비(지원부서)",
|
support_sga_sum: "판관비(지원부서)",
|
||||||
@@ -180,16 +180,18 @@
|
|||||||
function formatAxisLabel(value) {
|
function formatAxisLabel(value) {
|
||||||
const numeric = Number(value || 0);
|
const numeric = Number(value || 0);
|
||||||
if (!numeric) return "0.0";
|
if (!numeric) return "0.0";
|
||||||
if (numeric >= 100000000) return `${(numeric / 100000000).toFixed(1)}억`;
|
const sign = numeric < 0 ? "-" : "";
|
||||||
if (numeric >= 1000000) return `${(numeric / 1000000).toFixed(1)}백만`;
|
const absolute = Math.abs(numeric);
|
||||||
if (numeric >= 1000) return `${(numeric / 1000).toFixed(1)}천`;
|
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);
|
return formatNumber(numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickTickStep(maxValue) {
|
function pickTickStep(maxValue) {
|
||||||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||||
const units = [1, 2, 5];
|
const units = [1, 2, 5];
|
||||||
const raw = Math.max(maxValue / 5 / baseUnit, 1);
|
const raw = Math.max(maxValue / baseUnit, 1);
|
||||||
let power = 1;
|
let power = 1;
|
||||||
while (power * 10 <= raw) power *= 10;
|
while (power * 10 <= raw) power *= 10;
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
@@ -199,6 +201,14 @@
|
|||||||
return power * 10 * baseUnit;
|
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) {
|
function renderLegend(targetId, keys) {
|
||||||
const target = document.getElementById(targetId);
|
const target = document.getElementById(targetId);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -271,8 +281,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAxis(maxValue, width, height, margin) {
|
function buildAxis(maxValue, width, height, margin) {
|
||||||
const tickStep = pickTickStep(maxValue);
|
const { tickStep, tickMax } = buildPositiveAxisScale(maxValue, 4);
|
||||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
|
||||||
let axis = "";
|
let axis = "";
|
||||||
for (let value = 0; value <= tickMax; value += tickStep) {
|
for (let value = 0; value <= tickMax; value += tickStep) {
|
||||||
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
|
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
|
||||||
@@ -283,6 +292,21 @@
|
|||||||
return { axis, tickMax };
|
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) {
|
function renderEmptyChart(svgId, message) {
|
||||||
const svg = document.getElementById(svgId);
|
const svg = document.getElementById(svgId);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
@@ -375,12 +399,13 @@
|
|||||||
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
||||||
const plotWidth = width - margin.left - margin.right;
|
const plotWidth = width - margin.left - margin.right;
|
||||||
const plotHeight = height - margin.top - margin.bottom;
|
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.revenue_sum || 0,
|
||||||
item.total_expense || 0,
|
item.total_expense || 0,
|
||||||
Math.abs(item.operating_balance || 0),
|
Math.max(item.operating_balance || 0, 0),
|
||||||
]), 1);
|
]), 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 groupWidth = plotWidth / Math.max(series.length, 1);
|
||||||
const groupGap = groupWidth * 0.22;
|
const groupGap = groupWidth * 0.22;
|
||||||
const innerGap = 0;
|
const innerGap = 0;
|
||||||
@@ -399,13 +424,17 @@
|
|||||||
series.forEach((item, index) => {
|
series.forEach((item, index) => {
|
||||||
const baseX = margin.left + index * groupWidth;
|
const baseX = margin.left + index * groupWidth;
|
||||||
metrics.forEach((key, metricIndex) => {
|
metrics.forEach((key, metricIndex) => {
|
||||||
const value = key === "operating_balance" ? Math.abs(item[key] || 0) : (item[key] || 0);
|
const rawValue = Number(item[key] || 0);
|
||||||
const barHeight = (plotHeight * value) / tickMax;
|
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 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)" />`;
|
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||||
if (value > 0) {
|
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>`;
|
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>`;
|
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) {
|
function pickTickStep(maxValue) {
|
||||||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||||
const units = [1, 2, 5];
|
const units = [1, 2, 5];
|
||||||
const raw = Math.max(maxValue / 5 / baseUnit, 1);
|
const raw = Math.max(maxValue / baseUnit, 1);
|
||||||
let power = 1;
|
let power = 1;
|
||||||
while (power * 10 <= raw) power *= 10;
|
while (power * 10 <= raw) power *= 10;
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
@@ -355,6 +355,14 @@
|
|||||||
return power * 10 * baseUnit;
|
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) {
|
function setLegend(containerId, metricKeys) {
|
||||||
const target = document.getElementById(containerId);
|
const target = document.getElementById(containerId);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -393,8 +401,7 @@
|
|||||||
1,
|
1,
|
||||||
...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))),
|
...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))),
|
||||||
);
|
);
|
||||||
const tickStep = pickTickStep(maxValue);
|
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
|
||||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
|
||||||
const groupWidth = plotWidth / Math.max(rows.length, 1);
|
const groupWidth = plotWidth / Math.max(rows.length, 1);
|
||||||
const axisBaseY = height - margin.bottom;
|
const axisBaseY = height - margin.bottom;
|
||||||
const groupGapRatio = granularity === "monthly" ? 0.28 : 0.18;
|
const groupGapRatio = granularity === "monthly" ? 0.28 : 0.18;
|
||||||
@@ -464,8 +471,7 @@
|
|||||||
1,
|
1,
|
||||||
...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))),
|
...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))),
|
||||||
);
|
);
|
||||||
const tickStep = pickTickStep(maxValue);
|
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
|
||||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
|
||||||
const xStep = rows.length > 1 ? plotWidth / (rows.length - 1) : 0;
|
const xStep = rows.length > 1 ? plotWidth / (rows.length - 1) : 0;
|
||||||
const axisBaseY = height - margin.bottom;
|
const axisBaseY = height - margin.bottom;
|
||||||
|
|
||||||
|
|||||||
+1321
-186
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user