Update dashboard and project info workflows
This commit is contained in:
@@ -215,6 +215,20 @@ def init_db() -> None:
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS project_page_state (
|
||||
page_key TEXT PRIMARY KEY,
|
||||
selected_code TEXT DEFAULT '',
|
||||
selected_year TEXT DEFAULT '',
|
||||
analysis_open INTEGER DEFAULT 0,
|
||||
related_project_selections_json TEXT DEFAULT '{}',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
existing_columns = {
|
||||
row[1]
|
||||
for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall()
|
||||
@@ -247,6 +261,19 @@ def init_db() -> None:
|
||||
for column_name, column_type in required_columns.items():
|
||||
if column_name not in existing_columns:
|
||||
conn.execute(text(f"ALTER TABLE project_status ADD COLUMN {column_name} {column_type}"))
|
||||
page_state_columns = {
|
||||
row[1]
|
||||
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall()
|
||||
}
|
||||
required_page_state_columns = {
|
||||
"selected_code": "TEXT DEFAULT ''",
|
||||
"selected_year": "TEXT DEFAULT ''",
|
||||
"analysis_open": "INTEGER DEFAULT 0",
|
||||
"related_project_selections_json": "TEXT DEFAULT '{}'",
|
||||
}
|
||||
for column_name, column_type in required_page_state_columns.items():
|
||||
if column_name not in page_state_columns:
|
||||
conn.execute(text(f"ALTER TABLE project_page_state ADD COLUMN {column_name} {column_type}"))
|
||||
|
||||
|
||||
def count_transactions() -> int:
|
||||
@@ -1088,6 +1115,102 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
return result
|
||||
|
||||
|
||||
def get_project_page_state() -> dict[str, Any]:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COALESCE(selected_code, '') AS selected_code,
|
||||
COALESCE(selected_year, '') AS selected_year,
|
||||
COALESCE(analysis_open, 0) AS analysis_open,
|
||||
COALESCE(related_project_selections_json, '{}') AS related_project_selections_json
|
||||
FROM project_page_state
|
||||
WHERE page_key = 'projects'
|
||||
"""
|
||||
)
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return {
|
||||
"selected_code": "",
|
||||
"selected_year": "",
|
||||
"analysis_open": False,
|
||||
"related_project_selections": {},
|
||||
}
|
||||
try:
|
||||
related_project_selections_raw = json.loads(normalize_text(row["related_project_selections_json"]) or "{}")
|
||||
except json.JSONDecodeError:
|
||||
related_project_selections_raw = {}
|
||||
related_project_selections = {}
|
||||
if isinstance(related_project_selections_raw, dict):
|
||||
related_project_selections = {
|
||||
normalize_text(key): [
|
||||
normalize_text(value)
|
||||
for value in values
|
||||
if normalize_text(value)
|
||||
]
|
||||
for key, values in related_project_selections_raw.items()
|
||||
if normalize_text(key) and isinstance(values, list)
|
||||
}
|
||||
return {
|
||||
"selected_code": normalize_text(row["selected_code"]),
|
||||
"selected_year": normalize_text(row["selected_year"]),
|
||||
"analysis_open": bool(row["analysis_open"]),
|
||||
"related_project_selections": related_project_selections,
|
||||
}
|
||||
|
||||
|
||||
def save_project_page_state(payload: dict[str, Any]) -> None:
|
||||
selected_code = normalize_text(payload.get("selected_code"))
|
||||
selected_year = normalize_text(payload.get("selected_year"))
|
||||
analysis_open = 1 if payload.get("analysis_open") else 0
|
||||
raw_related = payload.get("related_project_selections") or {}
|
||||
related_project_selections = {}
|
||||
if isinstance(raw_related, dict):
|
||||
related_project_selections = {
|
||||
normalize_text(key): [
|
||||
normalize_text(value)
|
||||
for value in values
|
||||
if normalize_text(value)
|
||||
]
|
||||
for key, values in raw_related.items()
|
||||
if normalize_text(key) and isinstance(values, list)
|
||||
}
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO project_page_state (
|
||||
page_key,
|
||||
selected_code,
|
||||
selected_year,
|
||||
analysis_open,
|
||||
related_project_selections_json,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'projects',
|
||||
:selected_code,
|
||||
:selected_year,
|
||||
:analysis_open,
|
||||
:related_project_selections_json,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(page_key) DO UPDATE SET
|
||||
selected_code = excluded.selected_code,
|
||||
selected_year = excluded.selected_year,
|
||||
analysis_open = excluded.analysis_open,
|
||||
related_project_selections_json = excluded.related_project_selections_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
),
|
||||
{
|
||||
"selected_code": selected_code,
|
||||
"selected_year": selected_year,
|
||||
"analysis_open": analysis_open,
|
||||
"related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_project_year_options() -> list[int]:
|
||||
return get_available_years()
|
||||
|
||||
@@ -1240,6 +1363,40 @@ def get_project_revenue_mix(selected_year: int | None = None) -> list[dict[str,
|
||||
return result[-10:]
|
||||
|
||||
|
||||
def get_project_revenue_mix_monthly() -> list[dict[str, Any]]:
|
||||
recent_10_start_year = get_recent_10_start_year()
|
||||
params: dict[str, Any] = {}
|
||||
year_clause = ""
|
||||
if recent_10_start_year is not None:
|
||||
year_clause = "AND year >= :recent_10_start_year"
|
||||
params["recent_10_start_year"] = recent_10_start_year
|
||||
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
year,
|
||||
month,
|
||||
SUM(CASE WHEN account_code LIKE '40110101%' AND memo1 LIKE '%설계%' THEN amount ELSE 0 END) AS design_revenue,
|
||||
SUM(CASE WHEN account_code LIKE '40110101%' AND (memo1 NOT LIKE '%설계%' OR COALESCE(memo1, '') = '') THEN amount ELSE 0 END) AS design_other_revenue,
|
||||
SUM(CASE WHEN account_code LIKE '40110102%' THEN amount ELSE 0 END) AS supervision_revenue,
|
||||
SUM(CASE WHEN account_code LIKE '40110103%' THEN amount ELSE 0 END) AS inspection_revenue
|
||||
FROM transactions
|
||||
WHERE month IS NOT NULL
|
||||
{year_clause}
|
||||
GROUP BY year, month
|
||||
ORDER BY year, month
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).mappings().all()
|
||||
result = [dict(row) for row in rows]
|
||||
for item in result:
|
||||
item["label"] = f"{int(item['month'])}월" if item.get("month") is not None else str(item.get("year", ""))
|
||||
return result
|
||||
|
||||
|
||||
def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]:
|
||||
year_clause = ""
|
||||
params: dict[str, Any] = {}
|
||||
@@ -1723,19 +1880,85 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
row["group"] = "cost_plan"
|
||||
exec_budget_rows = exec_labor_rows + exec_outsource_rows + exec_cost_plan_rows
|
||||
|
||||
actual_input_rows = []
|
||||
actual_refs = payload.get("actual_input_ref[]", [])
|
||||
actual_amounts = payload.get("actual_input_amount[]", [])
|
||||
actual_notes = payload.get("actual_input_note[]", [])
|
||||
for index, ref in enumerate(actual_refs):
|
||||
actual_input_rows.append(
|
||||
actual_labor_grades = payload.get("actual_labor_grade[]", [])
|
||||
actual_labor_minutes = payload.get("actual_labor_minutes[]", [])
|
||||
actual_labor_amounts = payload.get("actual_labor_amount[]", [])
|
||||
actual_labor_rows: list[dict[str, Any]] = []
|
||||
actual_labor_max_length = max(
|
||||
len(actual_labor_grades),
|
||||
len(actual_labor_minutes),
|
||||
len(actual_labor_amounts),
|
||||
)
|
||||
for index in range(actual_labor_max_length):
|
||||
row = {
|
||||
"grade": actual_labor_grades[index] if index < len(actual_labor_grades) else "",
|
||||
"minutes": actual_labor_minutes[index] if index < len(actual_labor_minutes) else "",
|
||||
"amount": actual_labor_amounts[index] if index < len(actual_labor_amounts) else "",
|
||||
}
|
||||
normalized_row = {key: clean_row_text(value) for key, value in row.items()}
|
||||
amount = normalize_amount(normalized_row.get("amount"))
|
||||
has_other_value = any(
|
||||
value for key, value in normalized_row.items()
|
||||
if key != "amount"
|
||||
)
|
||||
if amount or has_other_value:
|
||||
normalized_row["amount"] = amount
|
||||
actual_labor_rows.append(normalized_row)
|
||||
for row in actual_labor_rows:
|
||||
row["group"] = "labor"
|
||||
actual_labor_adjustment_total = normalize_amount(payload.get("actual_labor_adjustment_total"))
|
||||
actual_labor_adjustment_rows = []
|
||||
if actual_labor_adjustment_total:
|
||||
actual_labor_adjustment_rows.append(
|
||||
{
|
||||
"reference": ref,
|
||||
"amount": actual_amounts[index] if index < len(actual_amounts) else "",
|
||||
"note": actual_notes[index] if index < len(actual_notes) else "",
|
||||
"group": "labor_adjustment",
|
||||
"label": "인건비 조정",
|
||||
"amount": actual_labor_adjustment_total,
|
||||
}
|
||||
)
|
||||
actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount")
|
||||
|
||||
actual_as_rows = build_named_amount_rows(
|
||||
payload.get("actual_as_label[]", []),
|
||||
payload.get("actual_as_amount[]", []),
|
||||
label_key="label",
|
||||
amount_key="amount",
|
||||
)
|
||||
for row in actual_as_rows:
|
||||
row["group"] = "as"
|
||||
|
||||
actual_labor_joint_rows = build_named_amount_rows(
|
||||
payload.get("actual_labor_joint_label[]", []),
|
||||
payload.get("actual_labor_joint_amount[]", []),
|
||||
label_key="label",
|
||||
amount_key="amount",
|
||||
)
|
||||
for row in actual_labor_joint_rows:
|
||||
row["group"] = "labor_joint"
|
||||
|
||||
actual_sga_rows = build_named_amount_rows(
|
||||
payload.get("actual_sga_label[]", []),
|
||||
payload.get("actual_sga_amount[]", []),
|
||||
label_key="label",
|
||||
amount_key="amount",
|
||||
)
|
||||
for row in actual_sga_rows:
|
||||
row["group"] = "sga"
|
||||
|
||||
actual_input_rows = actual_labor_rows + actual_labor_adjustment_rows + actual_labor_joint_rows + actual_as_rows + actual_sga_rows
|
||||
|
||||
if not actual_input_rows:
|
||||
legacy_refs = payload.get("actual_input_ref[]", [])
|
||||
legacy_amounts = payload.get("actual_input_amount[]", [])
|
||||
legacy_notes = payload.get("actual_input_note[]", [])
|
||||
for index, ref in enumerate(legacy_refs):
|
||||
actual_input_rows.append(
|
||||
{
|
||||
"reference": ref,
|
||||
"amount": legacy_amounts[index] if index < len(legacy_amounts) else "",
|
||||
"note": legacy_notes[index] if index < len(legacy_notes) else "",
|
||||
}
|
||||
)
|
||||
actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount")
|
||||
|
||||
expected_as_rate = normalize_amount(payload.get("expected_as_rate"))
|
||||
expected_sga_rate = normalize_amount(payload.get("expected_sga_rate"))
|
||||
@@ -1979,6 +2202,9 @@ def render_home(
|
||||
**base_context(request, message),
|
||||
"overview": get_overview_stats(overview_year),
|
||||
"overview_selected_year": overview_year,
|
||||
"project_dashboard": get_project_dashboard_summary(overview_year),
|
||||
"project_revenue_mix_yearly": get_project_revenue_mix(),
|
||||
"project_revenue_mix_monthly": get_project_revenue_mix_monthly(),
|
||||
"yearly_summary": get_yearly_summary(),
|
||||
"monthly_summary": get_monthly_summary(),
|
||||
"available_years": available_years,
|
||||
@@ -2004,6 +2230,7 @@ def render_projects_page(
|
||||
"project_account_breakdowns": get_project_account_breakdowns(selected_year),
|
||||
"project_status_rows": get_project_status_rows(),
|
||||
"project_edit": get_project_status_for_edit(edit_code),
|
||||
"project_page_state": get_project_page_state(),
|
||||
"support_department_options": get_support_department_options(),
|
||||
"cost_department_options": get_cost_department_options(),
|
||||
"cost_account_options": get_cost_account_options(),
|
||||
@@ -2054,6 +2281,19 @@ async def project_edit_data(code: str | None = None):
|
||||
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/projects/page-state")
|
||||
async def project_page_state_save(request: Request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("잘못된 페이지 상태 형식입니다.")
|
||||
save_project_page_state(payload)
|
||||
return JSONResponse(content={"status": "ok"})
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user