feat: deliver schema 15 monthly settlement lifecycle

This commit is contained in:
2026-10-17 15:19:46 +09:00
parent dda76b3bbd
commit 38a854d525
79 changed files with 4003 additions and 1831 deletions
+92 -40
View File
@@ -26,20 +26,70 @@ def _invalidate_and_rerun(draft_id: str, result: dict) -> None:
st.rerun()
def _authenticated_actor() -> str | None:
from dashboard.authentication import get_authenticated_actor
return get_authenticated_actor()
def _create_settlement_dialog(db_path: str | None, month: str, correction_run: str = "") -> None:
@st.dialog("정산 진행")
def dialog() -> None:
target_month = st.text_input("대상 월", value=month, key="settlement_dialog_month")
memo = st.text_area("메모 (선택)", key="settlement_dialog_memo")
reason = st.text_area("정정 사유 (필수)", key="settlement_dialog_reason") if correction_run else ""
if correction_run:
st.caption(f"정정 연결 마감 run: {correction_run}")
left, right = st.columns(2)
if left.button("취소", key="settlement_dialog_cancel"):
st.session_state.pop("settlement_dialog_open", None)
st.rerun()
if right.button("초안 만들기", type="primary", key="settlement_dialog_submit"):
actor = _authenticated_actor()
if not actor:
st.error("인증된 작업자 identity를 확인할 수 없습니다. 다시 로그인하세요.")
return
if correction_run and not reason.strip():
st.error("정정 사유를 입력하세요.")
return
# Avoid creating a database when the read/list path is unavailable.
listing_probe = gateway.list_settlement_drafts(target_month, db_path=db_path)
if not listing_probe.get("success"):
_safe_result(listing_probe)
return
# A cancelled draft must not reserve the next create operation's
# idempotency key. Scope the key to this explicit dialog submit.
import hashlib
import uuid
operation = "correction" if correction_run else "initial"
nonce = uuid.uuid4().hex
key = hashlib.sha256(f"dashboard:{operation}:{target_month}:{correction_run}:{nonce}".encode()).hexdigest()
result = gateway.create_settlement_draft(target_month, actor, key, memo=memo,
correction_of_run_id=correction_run, correction_reason=reason, db_path=db_path)
if result.get("success"):
created_id = (result.get("draft") or {}).get("id", "")
# The selectbox is the authoritative selection on the next
# rerun; seed its widget state, not a separate shadow key.
st.query_params["page"] = "월정산"
st.query_params["month"] = target_month
st.query_params.pop("year", None)
st.query_params.pop("status", None)
st.rerun()
_safe_result(result)
dialog()
def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None:
st.header("월정산 초안 lifecycle")
st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.")
month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month")
actor = st.text_input("작업자", value="operator", key="settlement_actor")
with st.form("settlement_create"):
memo = st.text_input("메모")
idem = st.text_input("생성 키", value="dashboard-create")
correction_run = st.text_input("정정 대상 run ID (선택)")
correction_reason = st.text_input("정정 사유")
create = st.form_submit_button("초안 생성 / 정정 초안")
if create:
_safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo,
correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path))
st.header("정산 진행")
st.caption("월별 정산을 시작하고, 활성 초안을 이어서 작업합니다.")
month_value = month or "2026-09"
start = st.button("정산 시작", type="primary", key="settlement_start")
if start:
st.session_state["settlement_dialog_open"] = True
if start or st.session_state.get("settlement_dialog_open"):
_create_settlement_dialog(db_path, month_value)
actor = _authenticated_actor()
if not actor:
st.warning("인증된 identity가 없으므로 조회만 가능합니다. 정산 작업을 시작하려면 다시 로그인하세요.")
listed = gateway.list_settlement_drafts(month_value, db_path=db_path)
drafts = listed.get("drafts", []) if listed.get("success") else []
@@ -47,19 +97,20 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
if not isinstance(drafts, list): drafts = []
ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")]
if ids:
preferred = st.session_state.get("settlement_selected_draft")
if preferred in ids:
st.session_state["settlement_draft_id"] = preferred
elif st.session_state.get("settlement_draft_id") not in ids:
st.session_state["settlement_draft_id"] = ids[0]
selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else ""
if not selected_id:
if listed.get("success"):
st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.")
st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.")
st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
st.button("검토", disabled=True, key="settlement_review_disabled")
st.button("승인", disabled=True, key="settlement_approve_disabled")
st.button("최종 마감", disabled=True, key="settlement_finalize_disabled")
st.button("초안 취소", disabled=True, key="settlement_cancel_disabled")
st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled")
st.caption("목록은 활성·완료·취소 초안을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
st.button("정산시작", disabled=not actor, key="settlement_start_empty")
st.caption("제외 사유는 항목별로 명시 입력합니다.")
st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.")
st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다. 기존 lifecycle 작업: 검토·승인·최종·취소·평가자료.")
return
detail = gateway.get_settlement_draft(selected_id, db_path=db_path)
feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None)
@@ -70,7 +121,6 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
draft = detail.get("draft", {}) if detail.get("success") else {}
if not isinstance(draft, dict): draft = {}
version = draft.get("version")
draft_version = version
fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else ""
state_key = "settlement_bound_state"
bound = (selected_id, version, fingerprint)
@@ -79,11 +129,15 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.session_state[state_key] = bound
st.session_state.pop(f"review_result_{selected_id}", None)
st.session_state.pop(f"approved_{selected_id}", None)
st.caption(f"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}")
st.caption(f"review_fingerprint={fingerprint or '미검토'}")
st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}")
st.caption(f"월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version}")
st.caption(f"draft_version={draft.get('draft_version', version)} · created_by={draft.get('created_by', '')} · created_at={draft.get('created_at', '')} · updated_at={draft.get('updated_at', '')}")
st.caption(f"final_run_id={draft.get('final_run_id', '')} · correction_of_run_id={draft.get('correction_of_run_id', '')} · correction_reason={draft.get('correction_reason', '')}")
st.caption(f"검토 상태: {'검토 완료' if fingerprint else '미검토'} · review_fingerprint={fingerprint} · 메모 {draft.get('memo', '')}")
if draft.get("status") == "active":
action_label = "정정이어서작업" if draft.get("correction_of_run_id") else "이어서작업"
st.button(action_label, disabled=not actor, key=f"continue_{selected_id}")
if draft.get("status") == "completed":
st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.")
st.success("완료 — 변경 불가, 정정은 새 초안으로 생성합니다.")
if draft.get("status") in {"completed", "cancelled"}:
final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {}
if final:
@@ -93,10 +147,8 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.write(label, final.get(key) if final.get(key) is not None else "미보존")
rows = final.get("cashflow_items", [])
if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)])
st.markdown("### 불변 이력")
st.write("final_run_id", draft.get("final_run_id"))
st.write("correction_of_run_id", draft.get("correction_of_run_id"))
st.json(detail.get("items", []))
if draft.get("final_run_id") and st.button("정정시작", disabled=not actor, key=f"correction_start_{selected_id}"):
_create_settlement_dialog(db_path, draft.get("month", month_value), str(draft["final_run_id"]))
st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.")
return
items = detail.get("items", []) if isinstance(detail.get("items"), list) else []
@@ -116,7 +168,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []}
with st.form(f"cashflow_policy_{selected_id}"):
policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220)
policy_submit = st.form_submit_button("정책 저장")
policy_submit = st.form_submit_button("정책 저장", disabled=not actor)
if policy_submit:
try:
policy = json.loads(policy_text)
@@ -138,7 +190,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
selection_reason = st.text_input("선택 사유")
included = st.checkbox("포함", value=True)
exclusion_reason = st.text_input("제외 사유")
save = st.form_submit_button("저장 / 지정 / 포함·제외")
save = st.form_submit_button("저장 / 지정 / 포함·제외", disabled=not actor)
if save:
result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path)
current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version
@@ -164,7 +216,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"])
valuation_memo = st.text_input("평가 메모")
registration_key = st.text_input("등록 idempotency key")
register = st.form_submit_button("평가자료 등록")
register = st.form_submit_button("평가자료 등록", disabled=not actor)
if register:
result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency,
valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification,
@@ -173,7 +225,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
if st.button("평가자료 조회", key=f"valuation_list_{selected_id}") and source_id:
if st.button("평가자료 조회", disabled=not actor, key=f"valuation_list_{selected_id}") and source_id:
vals = gateway.list_account_valuations(source_id, db_path=db_path)
if vals.get("success"):
st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])])
@@ -185,7 +237,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
correction_key = st.text_input("평가 정정 키")
correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"])
correction_source_ref = st.text_input("정정 증빙 참조 ID")
correct = st.form_submit_button("평가자료 정정")
correct = st.form_submit_button("평가자료 정정", disabled=not actor)
if correct:
result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason,
idempotency_key=correction_key, verification_status=correction_verification,
@@ -195,10 +247,10 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
review_result = None
if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"):
if st.button("검토 / 새로고침 대상", disabled=not actor, key=f"review_{selected_id}"):
review_result = gateway.review_settlement_draft(selected_id, db_path=db_path)
st.session_state[f"review_result_{selected_id}"] = review_result
if st.button("대상 명시적 갱신", key=f"refresh_{selected_id}"):
if st.button("대상 명시적 갱신", disabled=not actor, key=f"refresh_{selected_id}"):
result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path)
if result.get("success"):
_invalidate_and_rerun(selected_id, result)
@@ -229,7 +281,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
allow_missing = st.checkbox("누락 승인", value=False)
allow_carried = st.checkbox("이월 승인", value=False)
allow_substitute = st.checkbox("대체자료 승인", value=False)
approve = st.form_submit_button("검토 결과 승인", disabled=bool(review.get("cashflow_blockers")))
approve = st.form_submit_button("검토 결과 승인", disabled=(not actor or bool(review.get("cashflow_blockers"))))
if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.")
if approve:
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
@@ -237,7 +289,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
return
_safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor,
approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path))
if st.button("최종 마감", key=f"finalize_{selected_id}"):
if st.button("최종 마감", disabled=not actor, key=f"finalize_{selected_id}"):
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
return
@@ -248,7 +300,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_safe_result(result)
with st.form(f"cancel_{selected_id}"):
cancel_reason = st.text_input("취소 사유")
cancel = st.form_submit_button("초안 취소")
cancel = st.form_submit_button("초안 취소", disabled=not actor)
if cancel:
result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path)
if result.get("success"):