Refine project status workflows and dashboards

This commit is contained in:
b17301
2026-04-07 20:31:34 +09:00
parent 27a29e8e4c
commit d72a8377fb
6 changed files with 1621 additions and 278 deletions
BIN
View File
Binary file not shown.
+230 -59
View File
@@ -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)])
+2
View File
@@ -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
+45 -16
View File
@@ -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>`;
+11 -5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff