Polish project detail modals and data flows
This commit is contained in:
@@ -2,6 +2,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -21,6 +22,11 @@ from sqlalchemy import create_engine, event, text
|
|||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_HEALTH_PAYLOAD_CACHE: dict[str, Any] = {
|
||||||
|
"expires_at": 0.0,
|
||||||
|
"payload": None,
|
||||||
|
}
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
@@ -636,6 +642,27 @@ def init_db() -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS project_analysis_settings (
|
||||||
|
support_dept_code TEXT NOT NULL PRIMARY KEY,
|
||||||
|
detail_note TEXT DEFAULT '',
|
||||||
|
inactive_related_codes_json TEXT DEFAULT '[]',
|
||||||
|
labor_joint_exempt INTEGER DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_analysis_settings_code
|
||||||
|
ON project_analysis_settings (support_dept_code)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
page_state_columns_before = {
|
page_state_columns_before = {
|
||||||
row[1]
|
row[1]
|
||||||
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall()
|
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall()
|
||||||
@@ -3339,6 +3366,16 @@ def normalize_account_display(account_code: Any, account_name: Any) -> tuple[str
|
|||||||
return normalized_code, normalized_name, label
|
return normalized_code, normalized_name, label
|
||||||
|
|
||||||
|
|
||||||
|
def build_transaction_posting_display(voucher_number: Any, posting_date: Any) -> str:
|
||||||
|
year, month, day = extract_period(normalize_text(voucher_number), normalize_text(posting_date))
|
||||||
|
if year and month and day:
|
||||||
|
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||||
|
parsed_date = normalize_date_text(posting_date)
|
||||||
|
if re.match(r"^\d{4}-\d{2}-\d{2}$", parsed_date):
|
||||||
|
return parsed_date
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -3358,12 +3395,20 @@ def get_data_version() -> str:
|
|||||||
return max((version for version in versions if version), default="")
|
return max((version for version in versions if version), default="")
|
||||||
|
|
||||||
|
|
||||||
def build_health_payload() -> dict[str, str]:
|
def build_health_payload(force: bool = False) -> dict[str, str]:
|
||||||
return {
|
now = time.monotonic()
|
||||||
|
cached_payload = _HEALTH_PAYLOAD_CACHE.get("payload")
|
||||||
|
if not force and cached_payload and now < float(_HEALTH_PAYLOAD_CACHE.get("expires_at") or 0.0):
|
||||||
|
return dict(cached_payload)
|
||||||
|
|
||||||
|
payload = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"server_time": datetime.now().isoformat(timespec="seconds"),
|
"server_time": datetime.now().isoformat(timespec="seconds"),
|
||||||
"data_version": get_data_version(),
|
"data_version": get_data_version(),
|
||||||
}
|
}
|
||||||
|
_HEALTH_PAYLOAD_CACHE["payload"] = payload
|
||||||
|
_HEALTH_PAYLOAD_CACHE["expires_at"] = now + 2.0
|
||||||
|
return dict(payload)
|
||||||
|
|
||||||
|
|
||||||
def check_record_revision(conn: Any, table_name: str, key_column: str, key_value: Any, edit_revision: str) -> None:
|
def check_record_revision(conn: Any, table_name: str, key_column: str, key_value: Any, edit_revision: str) -> None:
|
||||||
@@ -3953,6 +3998,83 @@ def save_project_comparison_note(support_dept_code: str | None, item_key: str |
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_analysis_settings_map() -> dict[str, dict[str, object]]:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
support_dept_code,
|
||||||
|
COALESCE(detail_note, '') AS detail_note,
|
||||||
|
COALESCE(inactive_related_codes_json, '[]') AS inactive_related_codes_json,
|
||||||
|
COALESCE(labor_joint_exempt, 0) AS labor_joint_exempt
|
||||||
|
FROM project_analysis_settings
|
||||||
|
WHERE COALESCE(support_dept_code, '') <> ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
result: dict[str, dict[str, object]] = {}
|
||||||
|
for row in rows:
|
||||||
|
code = normalize_text(row.get("support_dept_code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
inactive_codes = json.loads(row.get("inactive_related_codes_json") or "[]")
|
||||||
|
except Exception:
|
||||||
|
inactive_codes = []
|
||||||
|
result[code] = {
|
||||||
|
"detail_note": normalize_text(row.get("detail_note")),
|
||||||
|
"inactive_related_codes": [
|
||||||
|
normalize_text(value) for value in (inactive_codes or []) if normalize_text(value)
|
||||||
|
],
|
||||||
|
"labor_joint_exempt": bool(row.get("labor_joint_exempt")),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def save_project_analysis_settings(
|
||||||
|
support_dept_code: str | None,
|
||||||
|
detail_note: str | None = None,
|
||||||
|
inactive_related_codes: list[str] | None = None,
|
||||||
|
labor_joint_exempt: bool | None = None,
|
||||||
|
) -> None:
|
||||||
|
code = normalize_text(support_dept_code)
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
current = get_project_analysis_settings_map().get(code, {})
|
||||||
|
next_detail_note = normalize_text(detail_note) if detail_note is not None else normalize_text(current.get("detail_note"))
|
||||||
|
current_inactive = current.get("inactive_related_codes", [])
|
||||||
|
next_inactive_related_codes = [
|
||||||
|
normalize_text(value)
|
||||||
|
for value in (inactive_related_codes if inactive_related_codes is not None else current_inactive)
|
||||||
|
if normalize_text(value)
|
||||||
|
]
|
||||||
|
next_labor_joint_exempt = bool(labor_joint_exempt) if labor_joint_exempt is not None else bool(current.get("labor_joint_exempt"))
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO project_analysis_settings (
|
||||||
|
support_dept_code, detail_note, inactive_related_codes_json, labor_joint_exempt, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:support_dept_code, :detail_note, :inactive_related_codes_json, :labor_joint_exempt, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT(support_dept_code) DO UPDATE SET
|
||||||
|
detail_note = excluded.detail_note,
|
||||||
|
inactive_related_codes_json = excluded.inactive_related_codes_json,
|
||||||
|
labor_joint_exempt = excluded.labor_joint_exempt,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"support_dept_code": code,
|
||||||
|
"detail_note": next_detail_note,
|
||||||
|
"inactive_related_codes_json": json.dumps(next_inactive_related_codes, ensure_ascii=False),
|
||||||
|
"labor_joint_exempt": 1 if next_labor_joint_exempt else 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
contract_info_map = get_project_contract_info_map()
|
contract_info_map = get_project_contract_info_map()
|
||||||
billing_summary_map = get_project_billing_summary_map()
|
billing_summary_map = get_project_billing_summary_map()
|
||||||
@@ -4931,12 +5053,128 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[
|
|||||||
normalized_result[code] = {}
|
normalized_result[code] = {}
|
||||||
for kind, entries in buckets.items():
|
for kind, entries in buckets.items():
|
||||||
normalized_result[code][kind] = [
|
normalized_result[code][kind] = [
|
||||||
{"label": label, "amount": amount}
|
{
|
||||||
|
"label": label,
|
||||||
|
"amount": amount,
|
||||||
|
"account_code": label.split(" · ", 1)[0] if " · " in label else "",
|
||||||
|
"account_name": label.split(" · ", 1)[1] if " · " in label else label,
|
||||||
|
}
|
||||||
for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True)
|
for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True)
|
||||||
]
|
]
|
||||||
return normalized_result
|
return normalized_result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_support_dept_codes_param(raw_codes: Any, fallback_code: Any = "") -> list[str]:
|
||||||
|
values: list[str] = []
|
||||||
|
for chunk in re.split(r"[\s,]+", normalize_text(raw_codes)):
|
||||||
|
normalized = normalize_text(chunk)
|
||||||
|
if normalized and normalized not in values:
|
||||||
|
values.append(normalized)
|
||||||
|
normalized_fallback = normalize_text(fallback_code)
|
||||||
|
if normalized_fallback and normalized_fallback not in values:
|
||||||
|
values.insert(0, normalized_fallback)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def build_in_clause(prefix: str, values: list[str]) -> tuple[str, dict[str, Any]]:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
placeholders: list[str] = []
|
||||||
|
for index, value in enumerate(values):
|
||||||
|
key = f"{prefix}_{index}"
|
||||||
|
placeholders.append(f":{key}")
|
||||||
|
params[key] = value
|
||||||
|
return ", ".join(placeholders), params
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_project_expense_transaction_rows(
|
||||||
|
codes: list[str],
|
||||||
|
expense_group: str = "",
|
||||||
|
account_label: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)]
|
||||||
|
if not normalized_codes:
|
||||||
|
return []
|
||||||
|
in_clause, code_params = build_in_clause("project_code", normalized_codes)
|
||||||
|
query = text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(voucher_number, '') AS voucher_number,
|
||||||
|
COALESCE(posting_date, '') AS posting_date,
|
||||||
|
COALESCE(partner_name, '') AS partner_name,
|
||||||
|
COALESCE(partner_code, '') AS partner_code,
|
||||||
|
COALESCE(cost_dept_name, '') AS cost_dept_name,
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
COALESCE(support_dept_name, '') AS support_dept_name,
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
COALESCE(account_name, '') AS account_name,
|
||||||
|
amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE support_dept_code IN ({in_clause})
|
||||||
|
AND accounting_category = '원가'
|
||||||
|
ORDER BY posting_date DESC, voucher_number DESC, partner_name, cost_dept_name, account_code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(query, code_params).mappings().all()
|
||||||
|
normalized_group = normalize_text(expense_group).lower()
|
||||||
|
normalized_account_label = normalize_text(account_label)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
normalized_code, normalized_name, normalized_label = normalize_account_display(
|
||||||
|
row["account_code"],
|
||||||
|
row["account_name"],
|
||||||
|
)
|
||||||
|
is_design_outsource = "기술협력비" in normalized_label or "기술협력비" in normalized_name
|
||||||
|
if normalized_group == "outsource" and not is_design_outsource:
|
||||||
|
continue
|
||||||
|
if normalized_group == "overhead" and is_design_outsource:
|
||||||
|
continue
|
||||||
|
if normalized_account_label and normalized_label != normalized_account_label:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]),
|
||||||
|
"voucher_number": normalize_text(row["voucher_number"]),
|
||||||
|
"partner_name": normalize_text(row["partner_name"]),
|
||||||
|
"partner_code": normalize_text(row["partner_code"]),
|
||||||
|
"cost_dept_name": normalize_text(row["cost_dept_name"]),
|
||||||
|
"support_dept_code": normalize_text(row["support_dept_code"]),
|
||||||
|
"support_dept_name": normalize_text(row["support_dept_name"]),
|
||||||
|
"account_code": normalized_code,
|
||||||
|
"account_name": normalized_name,
|
||||||
|
"amount": int(round(float(row["amount"] or 0))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_expense_date_range(codes: list[str]) -> tuple[str, str]:
|
||||||
|
normalized_codes = [normalize_text(code) for code in (codes or []) if normalize_text(code)]
|
||||||
|
if not normalized_codes:
|
||||||
|
return "", ""
|
||||||
|
in_clause, code_params = build_in_clause("project_code", normalized_codes)
|
||||||
|
query = text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(voucher_number, '') AS voucher_number,
|
||||||
|
COALESCE(posting_date, '') AS posting_date
|
||||||
|
FROM transactions
|
||||||
|
WHERE support_dept_code IN ({in_clause})
|
||||||
|
AND accounting_category = '원가'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
dates: list[str] = []
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(query, code_params).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
display = build_transaction_posting_display(row["voucher_number"], row["posting_date"])
|
||||||
|
if re.match(r"^\d{4}-\d{2}-\d{2}$", display):
|
||||||
|
dates.append(display)
|
||||||
|
if not dates:
|
||||||
|
return "", ""
|
||||||
|
return min(dates), max(dates)
|
||||||
|
|
||||||
|
|
||||||
def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]:
|
def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -5862,6 +6100,7 @@ def render_projects_page(
|
|||||||
"project_account_breakdowns": get_project_account_breakdowns(selected_year),
|
"project_account_breakdowns": get_project_account_breakdowns(selected_year),
|
||||||
"project_status_rows": get_project_status_rows(),
|
"project_status_rows": get_project_status_rows(),
|
||||||
"project_comparison_notes": get_project_comparison_notes_map(),
|
"project_comparison_notes": get_project_comparison_notes_map(),
|
||||||
|
"project_analysis_settings": get_project_analysis_settings_map(),
|
||||||
"project_edit": get_project_status_for_edit(edit_code),
|
"project_edit": get_project_status_for_edit(edit_code),
|
||||||
"project_focus_code": normalize_text(focus_code),
|
"project_focus_code": normalize_text(focus_code),
|
||||||
"project_page_state": get_project_page_state(),
|
"project_page_state": get_project_page_state(),
|
||||||
@@ -6049,6 +6288,329 @@ async def project_comparison_note_save(request: Request):
|
|||||||
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects/analysis-settings")
|
||||||
|
async def project_analysis_settings_save(request: Request):
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("잘못된 프로젝트 상세 설정 형식입니다.")
|
||||||
|
inactive_related_codes = payload.get("inactive_related_codes")
|
||||||
|
if inactive_related_codes is not None and not isinstance(inactive_related_codes, list):
|
||||||
|
raise ValueError("제외 연관 프로젝트 형식이 올바르지 않습니다.")
|
||||||
|
save_project_analysis_settings(
|
||||||
|
payload.get("support_dept_code"),
|
||||||
|
detail_note=payload.get("detail_note") if "detail_note" in payload else None,
|
||||||
|
inactive_related_codes=inactive_related_codes if isinstance(inactive_related_codes, list) else None,
|
||||||
|
labor_joint_exempt=payload.get("labor_joint_exempt") if "labor_joint_exempt" in payload else None,
|
||||||
|
)
|
||||||
|
return JSONResponse(content={"status": "ok"})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("프로젝트 상세 설정 저장 에러: %s", exc)
|
||||||
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/uncontracted-detail-transactions")
|
||||||
|
async def project_uncontracted_detail_transactions(
|
||||||
|
support_dept_code: str = "",
|
||||||
|
kind: str = "expense",
|
||||||
|
detail_type: str = "",
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
start_year: int | None = None,
|
||||||
|
end_year: int | None = None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
normalized_code = normalize_text(support_dept_code)
|
||||||
|
if not normalized_code:
|
||||||
|
raise ValueError("프로젝트 코드가 필요합니다.")
|
||||||
|
normalized_kind = normalize_text(kind).lower()
|
||||||
|
if normalized_kind not in {"expense", "revenue"}:
|
||||||
|
raise ValueError("조회 종류가 올바르지 않습니다.")
|
||||||
|
normalized_detail_type = normalize_text(detail_type).lower()
|
||||||
|
if normalized_detail_type not in {"year", "month", "category"}:
|
||||||
|
raise ValueError("세부 조회 형식이 올바르지 않습니다.")
|
||||||
|
|
||||||
|
filters = ["support_dept_code = :support_dept_code"]
|
||||||
|
params: dict[str, Any] = {"support_dept_code": normalized_code}
|
||||||
|
|
||||||
|
if normalized_kind == "expense":
|
||||||
|
filters.append("accounting_category IN ('원가', '판관비')")
|
||||||
|
else:
|
||||||
|
filters.append(REVENUE_SQL)
|
||||||
|
|
||||||
|
if normalized_detail_type == "month":
|
||||||
|
if not year or not month:
|
||||||
|
raise ValueError("월별 세부 조회에는 연도와 월이 필요합니다.")
|
||||||
|
filters.append("year = :year")
|
||||||
|
filters.append("month = :month")
|
||||||
|
params["year"] = int(year)
|
||||||
|
params["month"] = int(month)
|
||||||
|
elif normalized_detail_type == "year":
|
||||||
|
if not year:
|
||||||
|
raise ValueError("연도별 세부 조회에는 연도가 필요합니다.")
|
||||||
|
filters.append("year = :year")
|
||||||
|
params["year"] = int(year)
|
||||||
|
else:
|
||||||
|
# category 상세는 프로젝트 생성 시기와 무관하게 선택된 연도 구간 안의 발생 전표를 모두 보여준다.
|
||||||
|
if start_year:
|
||||||
|
filters.append("year >= :start_year")
|
||||||
|
params["start_year"] = int(start_year)
|
||||||
|
if end_year:
|
||||||
|
filters.append("year <= :end_year")
|
||||||
|
params["end_year"] = int(end_year)
|
||||||
|
if category:
|
||||||
|
params["category"] = normalize_text(category)
|
||||||
|
|
||||||
|
query = text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(voucher_number, '') AS voucher_number,
|
||||||
|
COALESCE(posting_date, '') AS posting_date,
|
||||||
|
COALESCE(partner_name, '') AS partner_name,
|
||||||
|
COALESCE(partner_code, '') AS partner_code,
|
||||||
|
COALESCE(cost_dept_name, '') AS cost_dept_name,
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
COALESCE(support_dept_name, '') AS support_dept_name,
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
COALESCE(account_name, '') AS account_name,
|
||||||
|
amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE {' AND '.join(filters)}
|
||||||
|
ORDER BY posting_date DESC, voucher_number DESC, partner_name, cost_dept_name, account_code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]),
|
||||||
|
"voucher_number": normalize_text(row["voucher_number"]),
|
||||||
|
"partner_name": normalize_text(row["partner_name"]),
|
||||||
|
"partner_code": normalize_text(row["partner_code"]),
|
||||||
|
"cost_dept_name": normalize_text(row["cost_dept_name"]),
|
||||||
|
"support_dept_code": normalize_text(row["support_dept_code"]),
|
||||||
|
"support_dept_name": normalize_text(row["support_dept_name"]),
|
||||||
|
"account_code": normalize_text(row["account_code"]),
|
||||||
|
"account_name": normalize_text(row["account_name"]),
|
||||||
|
"amount": int(round(float(row["amount"] or 0))),
|
||||||
|
}
|
||||||
|
for row in conn.execute(query, params).mappings()
|
||||||
|
]
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"rows": rows,
|
||||||
|
"total_amount": sum(int(row["amount"] or 0) for row in rows),
|
||||||
|
"support_dept_code": normalized_code,
|
||||||
|
"kind": normalized_kind,
|
||||||
|
"detail_type": normalized_detail_type,
|
||||||
|
"category": normalize_text(category),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("미계약 세부 거래내역 조회 에러: %s", exc)
|
||||||
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/uncontracted-vendor-transactions")
|
||||||
|
async def project_uncontracted_vendor_transactions(
|
||||||
|
partner_code: str = "",
|
||||||
|
partner_name: str = "",
|
||||||
|
start_year: int | None = None,
|
||||||
|
end_year: int | None = None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
normalized_partner_code = normalize_text(partner_code)
|
||||||
|
normalized_partner_name = normalize_text(partner_name)
|
||||||
|
if not normalized_partner_code and not normalized_partner_name:
|
||||||
|
raise ValueError("거래처 정보가 필요합니다.")
|
||||||
|
|
||||||
|
filters = ["accounting_category IN ('원가', '판관비')"]
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if start_year:
|
||||||
|
filters.append("year >= :start_year")
|
||||||
|
params["start_year"] = int(start_year)
|
||||||
|
if end_year:
|
||||||
|
filters.append("year <= :end_year")
|
||||||
|
params["end_year"] = int(end_year)
|
||||||
|
if normalized_partner_code:
|
||||||
|
filters.append("COALESCE(partner_code, '') = :partner_code")
|
||||||
|
params["partner_code"] = normalized_partner_code
|
||||||
|
else:
|
||||||
|
filters.append("COALESCE(partner_name, '') = :partner_name")
|
||||||
|
params["partner_name"] = normalized_partner_name
|
||||||
|
|
||||||
|
query = text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(voucher_number, '') AS voucher_number,
|
||||||
|
COALESCE(posting_date, '') AS posting_date,
|
||||||
|
COALESCE(partner_name, '') AS partner_name,
|
||||||
|
COALESCE(partner_code, '') AS partner_code,
|
||||||
|
COALESCE(cost_dept_name, '') AS cost_dept_name,
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
COALESCE(support_dept_name, '') AS support_dept_name,
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
COALESCE(account_name, '') AS account_name,
|
||||||
|
amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE {' AND '.join(filters)}
|
||||||
|
ORDER BY posting_date DESC, voucher_number DESC, cost_dept_name, account_code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]),
|
||||||
|
"voucher_number": normalize_text(row["voucher_number"]),
|
||||||
|
"partner_name": normalize_text(row["partner_name"]),
|
||||||
|
"partner_code": normalize_text(row["partner_code"]),
|
||||||
|
"cost_dept_name": normalize_text(row["cost_dept_name"]),
|
||||||
|
"support_dept_code": normalize_text(row["support_dept_code"]),
|
||||||
|
"support_dept_name": normalize_text(row["support_dept_name"]),
|
||||||
|
"account_code": normalize_text(row["account_code"]),
|
||||||
|
"account_name": normalize_text(row["account_name"]),
|
||||||
|
"amount": int(round(float(row["amount"] or 0))),
|
||||||
|
}
|
||||||
|
for row in conn.execute(query, params).mappings()
|
||||||
|
]
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"rows": rows,
|
||||||
|
"total_amount": sum(int(row["amount"] or 0) for row in rows),
|
||||||
|
"partner_code": normalized_partner_code,
|
||||||
|
"partner_name": normalized_partner_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("거래처별 미계약 비용 상세 조회 에러: %s", exc)
|
||||||
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/comparison-actual-transactions")
|
||||||
|
async def project_comparison_actual_transactions(
|
||||||
|
support_dept_code: str = "",
|
||||||
|
codes: str = "",
|
||||||
|
group: str = "",
|
||||||
|
account_label: str = "",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
normalized_codes = parse_support_dept_codes_param(codes, support_dept_code)
|
||||||
|
if not normalized_codes:
|
||||||
|
raise ValueError("프로젝트 코드가 필요합니다.")
|
||||||
|
rows = fetch_project_expense_transaction_rows(
|
||||||
|
normalized_codes,
|
||||||
|
expense_group=group,
|
||||||
|
account_label=account_label,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"rows": rows,
|
||||||
|
"total_amount": sum(int(row["amount"] or 0) for row in rows),
|
||||||
|
"support_dept_code": normalize_text(support_dept_code),
|
||||||
|
"codes": normalized_codes,
|
||||||
|
"group": normalize_text(group).lower(),
|
||||||
|
"account_label": normalize_text(account_label),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("비교 실제 집행 세부 거래내역 조회 에러: %s", exc)
|
||||||
|
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects/comparison-vendor-transactions")
|
||||||
|
async def project_comparison_vendor_transactions(
|
||||||
|
support_dept_code: str = "",
|
||||||
|
codes: str = "",
|
||||||
|
partner_code: str = "",
|
||||||
|
partner_name: str = "",
|
||||||
|
start_date: str = "",
|
||||||
|
end_date: str = "",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
normalized_codes = parse_support_dept_codes_param(codes, support_dept_code)
|
||||||
|
if not normalized_codes:
|
||||||
|
raise ValueError("프로젝트 코드가 필요합니다.")
|
||||||
|
normalized_partner_code = normalize_text(partner_code)
|
||||||
|
normalized_partner_name = normalize_text(partner_name)
|
||||||
|
if not normalized_partner_code and not normalized_partner_name:
|
||||||
|
raise ValueError("거래처 정보가 필요합니다.")
|
||||||
|
project_start_date, project_end_date = get_project_expense_date_range(normalized_codes)
|
||||||
|
effective_start_date = normalize_text(start_date) or project_start_date
|
||||||
|
effective_end_date = normalize_text(end_date) or project_end_date
|
||||||
|
|
||||||
|
filters = ["accounting_category IN ('원가', '판관비')"]
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if effective_start_date:
|
||||||
|
filters.append("COALESCE(posting_date, '') >= :start_date")
|
||||||
|
params["start_date"] = effective_start_date
|
||||||
|
if effective_end_date:
|
||||||
|
filters.append("COALESCE(posting_date, '') <= :end_date")
|
||||||
|
params["end_date"] = effective_end_date
|
||||||
|
if normalized_partner_code:
|
||||||
|
filters.append("COALESCE(partner_code, '') = :partner_code")
|
||||||
|
params["partner_code"] = normalized_partner_code
|
||||||
|
else:
|
||||||
|
filters.append("COALESCE(partner_name, '') = :partner_name")
|
||||||
|
params["partner_name"] = normalized_partner_name
|
||||||
|
|
||||||
|
query = text(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(voucher_number, '') AS voucher_number,
|
||||||
|
COALESCE(posting_date, '') AS posting_date,
|
||||||
|
COALESCE(partner_name, '') AS partner_name,
|
||||||
|
COALESCE(partner_code, '') AS partner_code,
|
||||||
|
COALESCE(cost_dept_name, '') AS cost_dept_name,
|
||||||
|
COALESCE(support_dept_code, '') AS support_dept_code,
|
||||||
|
COALESCE(support_dept_name, '') AS support_dept_name,
|
||||||
|
COALESCE(account_code, '') AS account_code,
|
||||||
|
COALESCE(account_name, '') AS account_name,
|
||||||
|
amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE {' AND '.join(filters)}
|
||||||
|
ORDER BY posting_date DESC, voucher_number DESC, cost_dept_name, account_code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
filtered_rows = [
|
||||||
|
{
|
||||||
|
"posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]),
|
||||||
|
"voucher_number": normalize_text(row["voucher_number"]),
|
||||||
|
"partner_name": normalize_text(row["partner_name"]),
|
||||||
|
"partner_code": normalize_text(row["partner_code"]),
|
||||||
|
"cost_dept_name": normalize_text(row["cost_dept_name"]),
|
||||||
|
"support_dept_code": normalize_text(row["support_dept_code"]),
|
||||||
|
"support_dept_name": normalize_text(row["support_dept_name"]),
|
||||||
|
"account_code": normalize_text(row["account_code"]),
|
||||||
|
"account_name": normalize_text(row["account_name"]),
|
||||||
|
"amount": int(round(float(row["amount"] or 0))),
|
||||||
|
}
|
||||||
|
for row in conn.execute(query, params).mappings()
|
||||||
|
]
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"rows": filtered_rows,
|
||||||
|
"total_amount": sum(int(row["amount"] or 0) for row in filtered_rows),
|
||||||
|
"support_dept_code": normalize_text(support_dept_code),
|
||||||
|
"codes": normalized_codes,
|
||||||
|
"partner_code": normalized_partner_code,
|
||||||
|
"partner_name": normalized_partner_name,
|
||||||
|
"start_date": effective_start_date,
|
||||||
|
"end_date": effective_end_date,
|
||||||
|
"project_start_date": project_start_date,
|
||||||
|
"project_end_date": project_end_date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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:
|
||||||
@@ -6125,7 +6687,7 @@ async def save_project_json(request: Request):
|
|||||||
form_data = parse_project_form(await request.body())
|
form_data = parse_project_form(await request.body())
|
||||||
save_project_status(form_data)
|
save_project_status(form_data)
|
||||||
code = normalize_text(form_data.get("support_dept_code"))
|
code = normalize_text(form_data.get("support_dept_code"))
|
||||||
health_payload = build_health_payload()
|
health_payload = build_health_payload(force=True)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=jsonable_encoder(
|
content=jsonable_encoder(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 18px 20px 20px;
|
padding: 18px 20px 20px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 620px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
@@ -92,6 +94,8 @@
|
|||||||
grid-template-rows: auto auto 1fr;
|
grid-template-rows: auto auto 1fr;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
|
|||||||
+40
-1
@@ -28,6 +28,16 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif;
|
font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -241,6 +251,19 @@
|
|||||||
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03);
|
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option:checked,
|
||||||
|
select option:hover,
|
||||||
|
select option:focus {
|
||||||
|
color: var(--ink);
|
||||||
|
background: #eef2f6;
|
||||||
|
box-shadow: inset 0 0 0 999px #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -293,6 +316,15 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover,
|
||||||
|
.button-secondary:focus-visible {
|
||||||
|
background: #eef2f6;
|
||||||
|
color: #111827;
|
||||||
|
border-color: #d6dde5;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.button-icon {
|
.button-icon {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
@@ -646,6 +678,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollHealth() {
|
async function pollHealth() {
|
||||||
|
if (document.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
|
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -675,7 +710,11 @@
|
|||||||
pollHealth();
|
pollHealth();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.setInterval(pollHealth, 15000);
|
window.setInterval(() => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
pollHealth();
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -106,6 +106,8 @@
|
|||||||
.chart-panel {
|
.chart-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-panel-header {
|
.chart-panel-header {
|
||||||
@@ -138,6 +140,8 @@
|
|||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-box {
|
.legend-box {
|
||||||
|
|||||||
@@ -29,6 +29,16 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(11, 107, 99, 0.16);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection {
|
||||||
|
background: rgba(11, 107, 99, 0.16);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
|
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -138,6 +148,8 @@
|
|||||||
border: 1px dashed #a7d1c8;
|
border: 1px dashed #a7d1c8;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-box p {
|
.upload-box p {
|
||||||
@@ -182,6 +194,7 @@
|
|||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
input[type="file"],
|
input[type="file"],
|
||||||
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -197,12 +210,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 4px rgba(11, 107, 99, 0.12);
|
box-shadow: 0 0 0 4px rgba(11, 107, 99, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option:checked,
|
||||||
|
select option:hover,
|
||||||
|
select option:focus {
|
||||||
|
color: var(--ink);
|
||||||
|
background: #e7f0f5;
|
||||||
|
box-shadow: inset 0 0 0 999px #e7f0f5;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -236,11 +263,19 @@
|
|||||||
color: #204257;
|
color: #204257;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover,
|
||||||
|
.button-secondary:focus-visible {
|
||||||
|
background: #dbeaf2;
|
||||||
|
color: #163345;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
|
|||||||
+1513
-44
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user