diff --git a/data.db b/data.db index 8475417..f464890 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 82f50db..0a58bd1 100644 --- a/main.py +++ b/main.py @@ -229,6 +229,94 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_related_links ( + base_support_dept_code TEXT NOT NULL, + related_support_dept_code TEXT NOT NULL, + link_source TEXT DEFAULT 'manual', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (base_support_dept_code, related_support_dept_code) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_related_links_base + ON project_related_links (base_support_dept_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_contract_info ( + support_dept_code TEXT PRIMARY KEY, + raw_contract_code TEXT DEFAULT '', + business_division TEXT DEFAULT '', + order_method TEXT DEFAULT '', + owner_department TEXT DEFAULT '', + client_name TEXT DEFAULT '', + support_dept_name TEXT DEFAULT '', + work_category TEXT DEFAULT '', + order_date TEXT DEFAULT '', + contract_date TEXT DEFAULT '', + project_start_date TEXT DEFAULT '', + project_end_date TEXT DEFAULT '', + contract_status TEXT DEFAULT '', + joint_contract TEXT DEFAULT '', + pm_name TEXT DEFAULT '', + progress_status TEXT DEFAULT '', + total_contract_amount REAL DEFAULT 0, + hanmac_contract_amount REAL DEFAULT 0, + review_tag TEXT DEFAULT '', + review_note TEXT DEFAULT '', + source_file TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_billing_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT, + raw_project_code TEXT DEFAULT '', + round_code TEXT DEFAULT '', + support_department TEXT DEFAULT '', + business_division TEXT DEFAULT '', + support_dept_name TEXT DEFAULT '', + contract_amount REAL DEFAULT 0, + client_name TEXT DEFAULT '', + billing_type TEXT DEFAULT '', + progress_round TEXT DEFAULT '', + billing_date TEXT DEFAULT '', + tax_invoice_date TEXT DEFAULT '', + expected_collection_date TEXT DEFAULT '', + billed_amount REAL DEFAULT 0, + collected_amount REAL DEFAULT 0, + balance_amount REAL DEFAULT 0, + collection_rate REAL DEFAULT 0, + note TEXT DEFAULT '', + source_file TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_billing_entries_code + ON project_billing_entries (support_dept_code, billing_date) + """ + ) + ) existing_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall() @@ -274,6 +362,12 @@ def init_db() -> None: 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}")) + related_link_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_related_links)")).fetchall() + } + if "link_source" not in related_link_columns: + conn.execute(text("ALTER TABLE project_related_links ADD COLUMN link_source TEXT DEFAULT 'manual'")) def count_transactions() -> int: @@ -289,10 +383,349 @@ def existing_source_files() -> set[str]: return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} +def existing_contract_source_files() -> set[str]: + with engine.begin() as conn: + rows = conn.execute( + text("SELECT DISTINCT source_file FROM project_contract_info WHERE COALESCE(source_file, '') <> ''") + ).fetchall() + return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} + + +def existing_billing_source_files() -> set[str]: + with engine.begin() as conn: + rows = conn.execute( + text("SELECT DISTINCT source_file FROM project_billing_entries WHERE COALESCE(source_file, '') <> ''") + ).fetchall() + return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} + + +def workbook_row_values(sheet: Any, row_number: int) -> list[str]: + return [normalize_text(sheet.cell(row_number, column).value) for column in range(1, sheet.max_column + 1)] + + +def detect_excel_import_kind(workbook: Any, filename: str = "") -> str: + sheet = workbook.active + row1 = workbook_row_values(sheet, 1) + row5 = workbook_row_values(sheet, 5) if sheet.max_row >= 5 else [] + filename = normalize_text(filename) + if {"총괄코드", "총 계약금액", "한맥계약금액"}.issubset(set(row1)): + return "contract_status" + if {"차수코드", "차수사업명", "청구금액", "수금금액"}.issubset(set(row5)): + return "billing_status" + if "계약현황" in filename: + return "contract_status" + if "기성청구현황" in filename: + return "billing_status" + return "transactions" + + +def import_contract_status_workbook(workbook: Any, source_file: str) -> int: + sheet = workbook.active + with engine.begin() as conn: + conn.execute( + text("DELETE FROM project_contract_info WHERE source_file = :source_file"), + {"source_file": source_file}, + ) + inserted = 0 + for row in sheet.iter_rows(min_row=2, values_only=True): + support_dept_code = normalize_project_code(row[1] if len(row) > 1 else "") + if not support_dept_code: + continue + payload = { + "support_dept_code": support_dept_code, + "raw_contract_code": normalize_text(row[1] if len(row) > 1 else ""), + "business_division": normalize_text(row[0] if len(row) > 0 else ""), + "order_method": normalize_text(row[2] if len(row) > 2 else ""), + "owner_department": normalize_text(row[3] if len(row) > 3 else ""), + "client_name": normalize_text(row[4] if len(row) > 4 else ""), + "support_dept_name": normalize_text(row[5] if len(row) > 5 else ""), + "work_category": normalize_text(row[6] if len(row) > 6 else ""), + "order_date": normalize_date_text(row[7] if len(row) > 7 else ""), + "contract_date": normalize_date_text(row[8] if len(row) > 8 else ""), + "project_start_date": normalize_date_text(row[9] if len(row) > 9 else ""), + "project_end_date": normalize_date_text(row[10] if len(row) > 10 else ""), + "contract_status": normalize_text(row[11] if len(row) > 11 else ""), + "joint_contract": normalize_text(row[12] if len(row) > 12 else ""), + "pm_name": normalize_text(row[13] if len(row) > 13 else ""), + "progress_status": normalize_text(row[14] if len(row) > 14 else ""), + "total_contract_amount": normalize_amount(row[15] if len(row) > 15 else 0), + "hanmac_contract_amount": normalize_amount(row[16] if len(row) > 16 else 0), + "review_tag": "", + "review_note": "", + "source_file": source_file, + } + conn.execute( + text( + """ + INSERT INTO project_contract_info ( + support_dept_code, raw_contract_code, business_division, order_method, + owner_department, client_name, support_dept_name, work_category, + order_date, contract_date, project_start_date, project_end_date, + contract_status, joint_contract, pm_name, progress_status, + total_contract_amount, hanmac_contract_amount, review_tag, review_note, + source_file, updated_at + ) VALUES ( + :support_dept_code, :raw_contract_code, :business_division, :order_method, + :owner_department, :client_name, :support_dept_name, :work_category, + :order_date, :contract_date, :project_start_date, :project_end_date, + :contract_status, :joint_contract, :pm_name, :progress_status, + :total_contract_amount, :hanmac_contract_amount, :review_tag, :review_note, + :source_file, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code) DO UPDATE SET + raw_contract_code = excluded.raw_contract_code, + business_division = excluded.business_division, + order_method = excluded.order_method, + owner_department = excluded.owner_department, + client_name = excluded.client_name, + support_dept_name = excluded.support_dept_name, + work_category = excluded.work_category, + order_date = excluded.order_date, + contract_date = excluded.contract_date, + project_start_date = excluded.project_start_date, + project_end_date = excluded.project_end_date, + contract_status = excluded.contract_status, + joint_contract = excluded.joint_contract, + pm_name = excluded.pm_name, + progress_status = excluded.progress_status, + total_contract_amount = excluded.total_contract_amount, + hanmac_contract_amount = excluded.hanmac_contract_amount, + source_file = excluded.source_file, + updated_at = CURRENT_TIMESTAMP + """ + ), + payload, + ) + inserted += 1 + refresh_contract_review_tags() + sync_auto_project_related_links() + return inserted + + +def import_billing_status_workbook(workbook: Any, source_file: str) -> int: + sheet = workbook.active + with engine.begin() as conn: + conn.execute( + text("DELETE FROM project_billing_entries WHERE source_file = :source_file"), + {"source_file": source_file}, + ) + inserted = 0 + current: dict[str, Any] = {} + for row in sheet.iter_rows(min_row=6, values_only=True): + values = list(row) + if values and all(value in (None, "") for value in values): + continue + if values[0] is not None: + current["support_department"] = normalize_text(values[0]) + if len(values) > 1 and values[1] is not None: + current["business_division"] = normalize_text(values[1]) + if len(values) > 2 and values[2] is not None: + current["raw_project_code"] = normalize_text(values[2]) + if len(values) > 3 and values[3] is not None: + current["round_code"] = normalize_text(values[3]) + if len(values) > 4 and values[4] is not None: + current["support_dept_name"] = normalize_text(values[4]) + if len(values) > 5 and values[5] is not None: + current["contract_amount"] = normalize_amount(values[5]) + if len(values) > 6 and values[6] is not None: + current["client_name"] = normalize_text(values[6]) + + round_code_text = normalize_text(current.get("round_code")) + round_prefix = next((character.upper() for character in round_code_text if character.isalpha()), "Y") + support_dept_code = normalize_project_code( + current.get("raw_project_code") or current.get("round_code"), + default_prefix=round_prefix, + ) + if not support_dept_code: + continue + + summary_row = normalize_text(values[11] if len(values) > 11 else "") == "합계" or normalize_text(values[10] if len(values) > 10 else "").startswith("수금 :") + department_summary = "합계" in normalize_text(values[4] if len(values) > 4 else "") + if summary_row or department_summary: + continue + + payload = { + "support_dept_code": support_dept_code, + "raw_project_code": normalize_text(current.get("raw_project_code")), + "round_code": normalize_text(current.get("round_code")), + "support_department": normalize_text(current.get("support_department")), + "business_division": normalize_text(current.get("business_division")), + "support_dept_name": normalize_text(current.get("support_dept_name")), + "contract_amount": normalize_amount(current.get("contract_amount")), + "client_name": normalize_text(current.get("client_name")), + "billing_type": normalize_text(values[7] if len(values) > 7 else ""), + "progress_round": normalize_round_value(values[8] if len(values) > 8 else ""), + "billing_date": normalize_date_text(values[9] if len(values) > 9 else ""), + "tax_invoice_date": normalize_date_text(values[10] if len(values) > 10 else ""), + "expected_collection_date": normalize_date_text(values[11] if len(values) > 11 else ""), + "billed_amount": normalize_amount(values[12] if len(values) > 12 else 0), + "collected_amount": normalize_amount(values[13] if len(values) > 13 else 0), + "balance_amount": normalize_amount(values[14] if len(values) > 14 else 0), + "collection_rate": normalize_amount(values[15] if len(values) > 15 else 0), + "note": normalize_text(values[16] if len(values) > 16 else ""), + "source_file": source_file, + } + conn.execute( + text( + """ + INSERT INTO project_billing_entries ( + support_dept_code, raw_project_code, round_code, support_department, + business_division, support_dept_name, contract_amount, client_name, + billing_type, progress_round, billing_date, tax_invoice_date, + expected_collection_date, billed_amount, collected_amount, + balance_amount, collection_rate, note, source_file, updated_at + ) VALUES ( + :support_dept_code, :raw_project_code, :round_code, :support_department, + :business_division, :support_dept_name, :contract_amount, :client_name, + :billing_type, :progress_round, :billing_date, :tax_invoice_date, + :expected_collection_date, :billed_amount, :collected_amount, + :balance_amount, :collection_rate, :note, :source_file, CURRENT_TIMESTAMP + ) + """ + ), + payload, + ) + inserted += 1 + refresh_contract_review_tags() + return inserted + + +def refresh_contract_review_tags() -> None: + with engine.begin() as conn: + billing_rows = conn.execute( + text( + """ + SELECT support_dept_code, MAX(contract_amount) AS billing_contract_amount + FROM project_billing_entries + GROUP BY support_dept_code + """ + ) + ).mappings().all() + billing_map = { + normalize_text(row["support_dept_code"]): normalize_amount(row["billing_contract_amount"]) + for row in billing_rows + if normalize_text(row["support_dept_code"]) + } + contract_rows = conn.execute( + text("SELECT support_dept_code, hanmac_contract_amount FROM project_contract_info") + ).mappings().all() + for row in contract_rows: + support_dept_code = normalize_text(row["support_dept_code"]) + hanmac_contract_amount = normalize_amount(row["hanmac_contract_amount"]) + billing_contract_amount = normalize_amount(billing_map.get(support_dept_code)) + review_tag = "" + review_note = "" + if billing_contract_amount and abs(hanmac_contract_amount - billing_contract_amount) > 0.5: + review_tag = "변경계약 검토 필요" + review_note = ( + f"계약현황 한맥계약금액 {hanmac_contract_amount:,.0f}원 / " + f"기성청구현황 계약금액 {billing_contract_amount:,.0f}원" + ) + conn.execute( + text( + """ + UPDATE project_contract_info + SET review_tag = :review_tag, + review_note = :review_note, + updated_at = CURRENT_TIMESTAMP + WHERE support_dept_code = :support_dept_code + """ + ), + { + "support_dept_code": support_dept_code, + "review_tag": review_tag, + "review_note": review_note, + }, + ) + + +def sync_auto_project_related_links() -> None: + with engine.begin() as conn: + billing_rows = conn.execute( + text( + """ + SELECT support_dept_code, raw_project_code, round_code + FROM project_billing_entries + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + existing_codes = { + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT DISTINCT support_dept_code FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_contract_info + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_billing_entries + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + + cluster_map: dict[str, set[str]] = {} + for row in billing_rows: + base_code = normalize_text(row["support_dept_code"]) + raw_project_code = normalize_text(row["raw_project_code"]) + round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y") + if not base_code: + continue + cluster_key = raw_project_code or base_code + cluster = cluster_map.setdefault(cluster_key, set()) + if base_code in existing_codes: + cluster.add(base_code) + if round_code and round_code in existing_codes: + cluster.add(round_code) + + conn.execute(text("DELETE FROM project_related_links WHERE COALESCE(link_source, 'manual') = 'auto'")) + for cluster_codes in cluster_map.values(): + normalized_cluster = sorted(cluster_codes) + if len(normalized_cluster) < 2: + continue + for base_code in normalized_cluster: + for related_code in normalized_cluster: + if base_code == related_code: + continue + conn.execute( + text( + """ + INSERT INTO project_related_links ( + base_support_dept_code, + related_support_dept_code, + link_source, + updated_at + ) VALUES ( + :base_support_dept_code, + :related_support_dept_code, + 'auto', + CURRENT_TIMESTAMP + ) + ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET + link_source = excluded.link_source, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "base_support_dept_code": base_code, + "related_support_dept_code": related_code, + }, + ) + + @app.on_event("startup") def on_startup() -> None: init_db() auto_import_project_excels() + sync_auto_project_related_links() logger.info("DB ready at %s", DB_PATH) @@ -341,6 +774,29 @@ def normalize_date_text(value: Any) -> str: return text_value +def normalize_project_code(value: Any, default_prefix: str = "Y") -> str: + text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "") + prefix = "" + for character in text_value: + if character.isalpha(): + prefix = character.upper() + break + digits = "".join(character for character in text_value if character.isdigit()) + if not digits: + return "" + return f"{prefix or default_prefix}{int(digits)}" + + +def normalize_round_value(value: Any) -> str: + text_value = normalize_text(value) + if not text_value: + return "" + digits = "".join(character for character in text_value if character.isdigit()) + if digits: + return str(int(digits)) + return text_value + + def decode_json_rows(value: Any) -> list[dict[str, Any]]: text_value = normalize_text(value) if not text_value: @@ -381,7 +837,16 @@ def get_support_department_options() -> list[dict[str, str]]: text( """ SELECT DISTINCT support_dept_code, support_dept_name - FROM transactions + FROM ( + SELECT support_dept_code, support_dept_name + FROM transactions + UNION ALL + SELECT support_dept_code, support_dept_name + FROM project_contract_info + UNION ALL + SELECT support_dept_code, support_dept_name + FROM project_billing_entries + ) AS merged WHERE COALESCE(support_dept_code, '') <> '' AND COALESCE(support_dept_name, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') @@ -447,6 +912,151 @@ def get_cost_account_options() -> list[dict[str, str]]: return sorted(deduped.values(), key=lambda item: (item["account_code"], item["account_name"])) +def get_import_sync_summary() -> dict[str, Any]: + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT + (SELECT COUNT(*) FROM project_contract_info) AS contract_project_count, + (SELECT COUNT(*) FROM project_billing_entries) AS billing_entry_count, + (SELECT COUNT(DISTINCT support_dept_code) FROM project_billing_entries) AS billing_project_count, + (SELECT COUNT(*) FROM project_contract_info WHERE COALESCE(review_tag, '') <> '') AS review_needed_count, + (SELECT SUM(hanmac_contract_amount) FROM project_contract_info) AS total_hanmac_contract_amount, + (SELECT SUM(collected_amount) FROM project_billing_entries) AS total_collected_amount, + (SELECT MAX(updated_at) FROM project_contract_info) AS latest_contract_sync, + (SELECT MAX(updated_at) FROM project_billing_entries) AS latest_billing_sync + """ + ) + ).mappings().first() + return dict(row) if row else {} + + +def get_project_contract_info_map() -> dict[str, dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text("SELECT * FROM project_contract_info ORDER BY support_dept_code") + ).mappings().all() + return {normalize_text(row["support_dept_code"]): dict(row) for row in rows} + + +def get_project_billing_summary_map() -> dict[str, dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT support_dept_code, + MAX(support_dept_name) AS support_dept_name, + MAX(contract_amount) AS contract_amount, + MAX(client_name) AS client_name, + MAX(support_department) AS support_department, + MAX(business_division) AS business_division, + SUM(billed_amount) AS billed_amount, + SUM(collected_amount) AS collected_amount, + SUM(balance_amount) AS balance_amount, + MAX(billing_date) AS latest_billing_date + FROM project_billing_entries + GROUP BY support_dept_code + ORDER BY support_dept_code + """ + ) + ).mappings().all() + entry_rows = conn.execute( + text( + """ + SELECT support_dept_code, + billing_type, + progress_round, + billing_date, + tax_invoice_date, + expected_collection_date, + billed_amount, + collected_amount, + balance_amount, + collection_rate, + note + FROM project_billing_entries + ORDER BY support_dept_code, billing_date, progress_round, id + """ + ) + ).mappings().all() + result = {normalize_text(row["support_dept_code"]): dict(row) for row in rows} + for item in result.values(): + item["entries"] = [] + for row in entry_rows: + support_dept_code = normalize_text(row["support_dept_code"]) + if support_dept_code not in result: + continue + result[support_dept_code]["entries"].append( + { + "progress_type": "", + "billing_round": normalize_round_value(row["progress_round"]), + "billing_type": normalize_text(row["billing_type"]), + "billing_date": normalize_date_text(row["billing_date"]), + "billed_amount": normalize_amount(row["billed_amount"]), + "round": normalize_round_value(row["progress_round"]), + "date": normalize_date_text(row["tax_invoice_date"]) or normalize_date_text(row["expected_collection_date"]), + "amount": normalize_amount(row["collected_amount"]), + "balance_amount": normalize_amount(row["balance_amount"]), + "collection_rate": normalize_amount(row["collection_rate"]), + "note": normalize_text(row["note"]), + } + ) + return result + + +def merge_project_external_fields( + item: dict[str, Any], + contract_info: dict[str, Any] | None, + billing_summary: dict[str, Any] | None, +) -> dict[str, Any]: + contract_info = contract_info or {} + billing_summary = billing_summary or {} + support_dept_name = normalize_text(item.get("support_dept_name")) or normalize_text(contract_info.get("support_dept_name")) or normalize_text(billing_summary.get("support_dept_name")) + contract_amount = normalize_amount(item.get("contract_amount")) + if not contract_amount: + contract_amount = normalize_amount(contract_info.get("hanmac_contract_amount")) or normalize_amount(billing_summary.get("contract_amount")) + collection_amount = normalize_amount(item.get("collection_amount")) + if not collection_amount: + collection_amount = normalize_amount(billing_summary.get("collected_amount")) + collection_entries = item.get("collection_entries") + if not collection_entries: + collection_entries = billing_summary.get("entries", []) + project_start_date = normalize_text(item.get("project_start_date")) or normalize_text(contract_info.get("project_start_date")) + project_end_date = normalize_text(item.get("project_end_date")) or normalize_text(contract_info.get("project_end_date")) + completion_status = normalize_text(item.get("completion_status")) or normalize_text(contract_info.get("progress_status")) + project_type = normalize_text(item.get("project_type")) or normalize_text(contract_info.get("business_division")) or normalize_text(billing_summary.get("business_division")) + progress_rate = normalize_amount(item.get("progress_rate")) + if not progress_rate and contract_amount: + progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 + + item["support_dept_name"] = support_dept_name + item["contract_amount"] = contract_amount + item["collection_amount"] = collection_amount + item["collection_entries"] = collection_entries or [] + item["project_start_date"] = project_start_date + item["project_end_date"] = project_end_date + item["completion_status"] = completion_status + item["project_type"] = project_type + item["progress_rate"] = progress_rate + item["client_name"] = normalize_text(contract_info.get("client_name")) or normalize_text(billing_summary.get("client_name")) + item["order_method"] = normalize_text(contract_info.get("order_method")) + item["joint_contract"] = normalize_text(contract_info.get("joint_contract")) + item["pm_name"] = normalize_text(contract_info.get("pm_name")) + item["contract_status"] = normalize_text(contract_info.get("contract_status")) + item["progress_status"] = normalize_text(contract_info.get("progress_status")) + item["work_category"] = normalize_text(contract_info.get("work_category")) + item["review_tag"] = normalize_text(contract_info.get("review_tag")) + item["review_note"] = normalize_text(contract_info.get("review_note")) + item["total_contract_amount"] = normalize_amount(contract_info.get("total_contract_amount")) + item["hanmac_contract_amount"] = normalize_amount(contract_info.get("hanmac_contract_amount")) + item["billing_contract_amount"] = normalize_amount(billing_summary.get("contract_amount")) + item["billed_amount"] = normalize_amount(billing_summary.get("billed_amount")) + item["collection_balance_amount"] = normalize_amount(billing_summary.get("balance_amount")) + item["latest_billing_date"] = normalize_text(billing_summary.get("latest_billing_date")) + return item + + 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() @@ -461,7 +1071,14 @@ def get_data_version() -> str: with engine.begin() as conn: transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar() project_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_status")).scalar() - versions = [normalize_text(transaction_updated), normalize_text(project_updated)] + contract_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_info")).scalar() + billing_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_billing_entries")).scalar() + versions = [ + normalize_text(transaction_updated), + normalize_text(project_updated), + normalize_text(contract_updated), + normalize_text(billing_updated), + ] return max((version for version in versions if version), default="") @@ -834,6 +1451,8 @@ def get_business_monthly_summary() -> list[dict[str, Any]]: def get_project_status_rows() -> list[dict[str, Any]]: + contract_info_map = get_project_contract_info_map() + billing_summary_map = get_project_billing_summary_map() with engine.begin() as conn: rows = conn.execute( text( @@ -902,17 +1521,68 @@ def get_project_status_rows() -> list[dict[str, Any]]: ) ).mappings().all() result = [] + seen_codes: set[str] = set() 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", "[]")) + item = merge_project_external_fields( + item, + contract_info_map.get(normalize_text(item.get("support_dept_code"))), + billing_summary_map.get(normalize_text(item.get("support_dept_code"))), + ) + seen_codes.add(normalize_text(item.get("support_dept_code"))) result.append(item) + for support_dept_code in sorted((set(contract_info_map) | set(billing_summary_map)) - seen_codes): + result.append( + merge_project_external_fields( + { + "support_dept_code": support_dept_code, + "support_dept_name": "", + "row_count": 0, + "progress_rate": 0, + "contract_amount": 0, + "collection_amount": 0, + "collection_entries": [], + "change_round": "", + "item_investment": 0, + "task_plan_department_budget": 0, + "task_plan_outsource_budget": 0, + "task_plan_outsource_detail": "", + "task_plan_joint_operating_cost": 0, + "task_plan_entries": [], + "exec_budget_labor_by_grade": 0, + "exec_budget_outsource": 0, + "exec_budget_cost_plan": 0, + "exec_budget_entries": [], + "actual_input_entries": [], + "expected_as_cost": 0, + "expected_sga_budget": 0, + "project_start_date": "", + "project_end_date": "", + "completion_status": "", + "notes": "", + "total_cost": 0, + "total_sga": 0, + "total_revenue": 0, + "actual_labor": 0, + "actual_outsource": 0, + "latest_year": 0, + "latest_month": 0, + "project_type": "", + }, + contract_info_map.get(support_dept_code), + billing_summary_map.get(support_dept_code), + ) + ) return result def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]: + contract_info_map = get_project_contract_info_map() + billing_summary_map = get_project_billing_summary_map() if not support_dept_code: return { "support_dept_code": "", @@ -943,6 +1613,21 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] "project_end_date": "", "completion_status": "", "notes": "", + "client_name": "", + "order_method": "", + "joint_contract": "", + "pm_name": "", + "contract_status": "", + "progress_status": "", + "work_category": "", + "review_tag": "", + "review_note": "", + "total_contract_amount": 0, + "hanmac_contract_amount": 0, + "billing_contract_amount": 0, + "billed_amount": 0, + "collection_balance_amount": 0, + "latest_billing_date": "", "updated_at": "", } @@ -1022,6 +1707,21 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] "project_end_date": "", "completion_status": "", "notes": "", + "client_name": "", + "order_method": "", + "joint_contract": "", + "pm_name": "", + "contract_status": "", + "progress_status": "", + "work_category": "", + "review_tag": "", + "review_note": "", + "total_contract_amount": 0, + "hanmac_contract_amount": 0, + "billing_contract_amount": 0, + "billed_amount": 0, + "collection_balance_amount": 0, + "latest_billing_date": "", "updated_at": "", } result = dict(row) @@ -1112,7 +1812,11 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] "note": "기존 항목별투입액", } ] - return result + return merge_project_external_fields( + result, + contract_info_map.get(normalize_text(result.get("support_dept_code"))), + billing_summary_map.get(normalize_text(result.get("support_dept_code"))), + ) def get_project_page_state() -> dict[str, Any]: @@ -1209,6 +1913,78 @@ def save_project_page_state(payload: dict[str, Any]) -> None: "related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False), }, ) + for base_code, related_codes in related_project_selections.items(): + save_project_related_links(base_code, related_codes) + + +def get_project_related_links_map() -> dict[str, list[str]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + ORDER BY base_support_dept_code, related_support_dept_code + """ + ) + ).mappings().all() + related_map: dict[str, list[str]] = {} + for row in rows: + base_code = normalize_text(row["base_support_dept_code"]) + related_code = normalize_text(row["related_support_dept_code"]) + if not base_code or not related_code: + continue + related_map.setdefault(base_code, []).append(related_code) + return related_map + + +def save_project_related_links(base_support_dept_code: str, related_codes: list[Any]) -> None: + base_code = normalize_text(base_support_dept_code) + if not base_code: + return + normalized_codes = sorted( + { + normalize_text(code) + for code in related_codes + if normalize_text(code) and normalize_text(code) != base_code + } + ) + with engine.begin() as conn: + conn.execute( + text( + """ + DELETE FROM project_related_links + WHERE base_support_dept_code = :base_support_dept_code + AND COALESCE(link_source, 'manual') = 'manual' + """ + ), + {"base_support_dept_code": base_code}, + ) + for related_code in normalized_codes: + conn.execute( + text( + """ + INSERT INTO project_related_links ( + base_support_dept_code, + related_support_dept_code, + link_source, + updated_at + ) VALUES ( + :base_support_dept_code, + :related_support_dept_code, + 'manual', + CURRENT_TIMESTAMP + ) + ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET + link_source = excluded.link_source, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "base_support_dept_code": base_code, + "related_support_dept_code": related_code, + }, + ) def get_project_year_options() -> list[int]: @@ -1312,6 +2088,197 @@ def get_project_dashboard_summary(selected_year: int | None) -> dict[str, Any]: } +def get_uncontracted_project_dashboard(selected_year: int | None) -> dict[str, Any]: + selected_year = resolve_selected_year(selected_year) + transaction_year_clause = "" + params: dict[str, Any] = {} + if selected_year: + transaction_year_clause = "AND t.year = :selected_year" + params["selected_year"] = selected_year + else: + recent_10_start_year = get_recent_10_start_year() + if recent_10_start_year is not None: + transaction_year_clause = "AND t.year >= :recent_10_start_year" + params["recent_10_start_year"] = recent_10_start_year + + with engine.begin() as conn: + summary = conn.execute( + text( + f""" + WITH project_universe AS ( + SELECT DISTINCT support_dept_code, support_dept_name + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + UNION + SELECT support_dept_code, support_dept_name + FROM project_contract_info + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT support_dept_code, support_dept_name + FROM project_billing_entries + WHERE COALESCE(support_dept_code, '') <> '' + ), + contract_flags AS ( + SELECT support_dept_code, + COALESCE(hanmac_contract_amount, 0) AS hanmac_contract_amount, + COALESCE(review_tag, '') AS review_tag + FROM project_contract_info + ), + project_amounts AS ( + SELECT t.support_dept_code, + SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, + SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount + FROM transactions AS t + WHERE COALESCE(t.support_dept_code, '') <> '' + AND t.support_dept_code NOT IN ('ZZZZZZ') + {transaction_year_clause} + GROUP BY t.support_dept_code + ) + SELECT + SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 THEN 1 ELSE 0 END) AS uncontracted_projects, + SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 AND COALESCE(project_amounts.expense_amount, 0) > 0 THEN 1 ELSE 0 END) AS cost_incurred_projects, + SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 THEN COALESCE(project_amounts.expense_amount, 0) ELSE 0 END) AS expense_amount, + SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 THEN COALESCE(project_amounts.revenue_amount, 0) ELSE 0 END) AS revenue_amount, + SUM(CASE WHEN COALESCE(contract_flags.review_tag, '') <> '' THEN 1 ELSE 0 END) AS review_needed_projects + FROM project_universe + LEFT JOIN contract_flags + ON contract_flags.support_dept_code = project_universe.support_dept_code + LEFT JOIN project_amounts + ON project_amounts.support_dept_code = project_universe.support_dept_code + """ + ), + params, + ).mappings().first() + + yearly_rows = conn.execute( + text( + f""" + WITH yearly_costs AS ( + SELECT t.year, + t.support_dept_code, + SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, + SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount + FROM transactions AS t + WHERE COALESCE(t.support_dept_code, '') <> '' + AND t.support_dept_code NOT IN ('ZZZZZZ') + AND t.year IS NOT NULL + {transaction_year_clause} + GROUP BY t.year, t.support_dept_code + ) + SELECT yearly_costs.year, + COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN yearly_costs.support_dept_code END) AS uncontracted_projects, + COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 AND COALESCE(yearly_costs.expense_amount, 0) > 0 THEN yearly_costs.support_dept_code END) AS cost_incurred_projects, + SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(yearly_costs.expense_amount, 0) ELSE 0 END) AS expense_amount, + SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(yearly_costs.revenue_amount, 0) ELSE 0 END) AS revenue_amount + FROM yearly_costs + LEFT JOIN project_contract_info AS c + ON c.support_dept_code = yearly_costs.support_dept_code + GROUP BY yearly_costs.year + ORDER BY yearly_costs.year + """ + ), + params, + ).mappings().all() + + monthly_focus_year = selected_year + if monthly_focus_year is None: + monthly_focus_year = conn.execute( + text( + """ + WITH monthly_candidates AS ( + SELECT MAX(t.year) AS latest_year + FROM transactions AS t + LEFT JOIN project_contract_info AS c + ON c.support_dept_code = t.support_dept_code + WHERE COALESCE(t.support_dept_code, '') <> '' + AND t.support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(c.hanmac_contract_amount, 0) <= 0 + AND (t.account_code LIKE '5%' OR t.account_code LIKE '6%') + ) + SELECT latest_year FROM monthly_candidates + """ + ) + ).scalar() + + monthly_rows: list[dict[str, Any]] = [] + if monthly_focus_year: + monthly_rows = conn.execute( + text( + f""" + WITH monthly_costs AS ( + SELECT t.month, + t.support_dept_code, + SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, + SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount + FROM transactions AS t + WHERE COALESCE(t.support_dept_code, '') <> '' + AND t.support_dept_code NOT IN ('ZZZZZZ') + AND t.year = :monthly_focus_year + AND t.month IS NOT NULL + GROUP BY t.month, t.support_dept_code + ) + SELECT monthly_costs.month, + COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN monthly_costs.support_dept_code END) AS uncontracted_projects, + COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 AND COALESCE(monthly_costs.expense_amount, 0) > 0 THEN monthly_costs.support_dept_code END) AS cost_incurred_projects, + SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(monthly_costs.expense_amount, 0) ELSE 0 END) AS expense_amount, + SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(monthly_costs.revenue_amount, 0) ELSE 0 END) AS revenue_amount + FROM monthly_costs + LEFT JOIN project_contract_info AS c + ON c.support_dept_code = monthly_costs.support_dept_code + GROUP BY monthly_costs.month + ORDER BY monthly_costs.month + """ + ), + {"monthly_focus_year": monthly_focus_year}, + ).mappings().all() + + top_rows = conn.execute( + text( + f""" + WITH project_costs AS ( + SELECT t.support_dept_code, + MAX(t.support_dept_name) AS support_dept_name, + SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, + SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount, + MAX(t.year) AS latest_year, + MAX(t.month) AS latest_month + FROM transactions AS t + WHERE COALESCE(t.support_dept_code, '') <> '' + AND t.support_dept_code NOT IN ('ZZZZZZ') + {transaction_year_clause} + GROUP BY t.support_dept_code + ) + SELECT project_costs.support_dept_code, + project_costs.support_dept_name, + project_costs.expense_amount, + project_costs.revenue_amount, + project_costs.latest_year, + project_costs.latest_month, + COALESCE(c.review_tag, '') AS review_tag + FROM project_costs + LEFT JOIN project_contract_info AS c + ON c.support_dept_code = project_costs.support_dept_code + WHERE COALESCE(c.hanmac_contract_amount, 0) <= 0 + AND COALESCE(project_costs.expense_amount, 0) > 0 + ORDER BY project_costs.expense_amount DESC, project_costs.support_dept_code + LIMIT 12 + """ + ), + params, + ).mappings().all() + + return { + "summary": dict(summary) if summary else {}, + "yearly_rows": [dict(row) for row in yearly_rows], + "monthly_rows": [dict(row) for row in monthly_rows], + "monthly_focus_year": int(monthly_focus_year) if monthly_focus_year else None, + "top_rows": [dict(row) for row in top_rows], + } + + def get_project_revenue_mix(selected_year: int | None = None) -> list[dict[str, Any]]: params: dict[str, Any] = {} if selected_year: @@ -1409,6 +2376,8 @@ def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]: year_clause = "AND year >= :recent_10_start_year" params["recent_10_start_year"] = recent_10_start_year + contract_info_map = get_project_contract_info_map() + billing_summary_map = get_project_billing_summary_map() with engine.begin() as conn: rows = conn.execute( text( @@ -1430,7 +2399,36 @@ def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]: ), params, ).mappings().all() - return [dict(row) for row in rows] + result = [dict(row) for row in rows] + existing_codes = {normalize_text(row["support_dept_code"]) for row in result} + candidate_codes = sorted((set(contract_info_map) | set(billing_summary_map)) - existing_codes) + recent_10_start_year = get_recent_10_start_year() + for support_dept_code in candidate_codes: + contract_info = contract_info_map.get(support_dept_code, {}) + billing_summary = billing_summary_map.get(support_dept_code, {}) + fallback_date = ( + normalize_text(contract_info.get("project_start_date")) + or normalize_text(contract_info.get("contract_date")) + or normalize_text(billing_summary.get("latest_billing_date")) + ) + fallback_year = 0 + if re.match(r"^\d{4}-\d{2}-\d{2}$", fallback_date): + fallback_year = int(fallback_date[:4]) + if selected_year and fallback_year and fallback_year != selected_year: + continue + if not selected_year and recent_10_start_year is not None and fallback_year and fallback_year < recent_10_start_year: + continue + result.append( + { + "year": fallback_year, + "support_dept_code": support_dept_code, + "support_dept_name": normalize_text(contract_info.get("support_dept_name")) or normalize_text(billing_summary.get("support_dept_name")), + "expense_amount": 0, + "revenue_amount": normalize_amount(billing_summary.get("collected_amount")), + } + ) + result.sort(key=lambda item: (-(int(item.get("year") or 0)), -normalize_amount(item.get("expense_amount")), normalize_text(item.get("support_dept_code")))) + return result def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[str, list[dict[str, Any]]]]: @@ -1659,8 +2657,13 @@ def get_source_files_summary() -> list[dict[str, Any]]: def parse_excel_upload(upload_file: UploadFile) -> int: init_db() workbook = load_workbook(upload_file.file, data_only=True) - sheet = workbook.active + import_kind = detect_excel_import_kind(workbook, upload_file.filename or "") + if import_kind == "contract_status": + return import_contract_status_workbook(workbook, upload_file.filename or "") + if import_kind == "billing_status": + return import_billing_status_workbook(workbook, upload_file.filename or "") + sheet = workbook.active headers = [canonical_header_name(cell.value) for cell in next(sheet.iter_rows(min_row=1, max_row=1))] inserted = 0 @@ -1699,13 +2702,24 @@ def auto_import_project_excels() -> None: return known_files = existing_source_files() + known_contract_files = existing_contract_source_files() + known_billing_files = existing_billing_source_files() if count_transactions() > 0 and all(file.name in known_files for file in excel_files): - return + if all(file.name in known_contract_files or file.name in known_billing_files for file in excel_files): + return for excel_path in excel_files: - if excel_path.name in known_files: + workbook = load_workbook(excel_path, data_only=True) + import_kind = detect_excel_import_kind(workbook, excel_path.name) + if import_kind == "contract_status" and excel_path.name in known_contract_files: continue - inserted = import_excel_path(excel_path) + if import_kind == "billing_status" and excel_path.name in known_billing_files: + continue + if import_kind == "transactions" and excel_path.name in known_files: + continue + with excel_path.open("rb") as excel_file: + upload = UploadFile(filename=excel_path.name, file=excel_file) + inserted = parse_excel_upload(upload) logger.info("Auto-imported %s rows from %s", inserted, excel_path.name) @@ -1810,6 +2824,8 @@ def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: normalized_row["billed_amount"] = billed_amount filtered_rows.append(normalized_row) for row in filtered_rows: + row["billing_round"] = normalize_round_value(row.get("billing_round")) + row["round"] = normalize_round_value(row.get("round")) row["billing_date"] = normalize_date_text(row.get("billing_date")) row["date"] = normalize_date_text(row.get("date")) row["billed_amount"] = normalize_amount(row.get("billed_amount")) @@ -2187,6 +3203,7 @@ def base_context(request: Request, message: str = "") -> dict[str, Any]: "message": message, "data_version": health_payload["data_version"], "server_time": health_payload["server_time"], + "import_sync_summary": get_import_sync_summary(), } @@ -2227,10 +3244,12 @@ def render_projects_page( "project_dashboard": get_project_dashboard_summary(selected_year), "project_revenue_mix": get_project_revenue_mix(selected_year), "project_cost_by_year": get_project_cost_by_year(selected_year), + "project_monthly_cost_rows": get_business_monthly_summary(), "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(), + "project_related_links": get_project_related_links_map(), "support_department_options": get_support_department_options(), "cost_department_options": get_cost_department_options(), "cost_account_options": get_cost_account_options(), @@ -2294,6 +3313,23 @@ async def project_page_state_save(request: Request): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.post("/projects/related-links") +async def project_related_links_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 연관 프로젝트 형식입니다.") + base_code = normalize_text(payload.get("base_code")) + related_codes = payload.get("related_codes") or [] + if not isinstance(related_codes, list): + raise ValueError("연관 프로젝트 목록 형식이 올바르지 않습니다.") + save_project_related_links(base_code, related_codes) + 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: diff --git a/templates/annual_summary.html b/templates/annual_summary.html index 35b7915..713e331 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -1,13 +1,25 @@ {% extends "base.html" %} -{% block title %}연도별 수익 비용 정리{% endblock %} +{% block title %}연도별 수익/비용{% endblock %} {% block head_extra %} {% endblock %} @@ -1618,12 +1767,20 @@ {% endfor %} +
+ +
+
+