362 lines
18 KiB
GDScript
362 lines
18 KiB
GDScript
class_name OperatingLedgerPanel
|
||
extends VBoxContainer
|
||
|
||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||
|
||
signal status_requested(message: String, success: bool)
|
||
|
||
## Projection only: authoritative close and ledger entries own all amounts.
|
||
var _initial_layer := 0
|
||
var _runtime: Variant
|
||
var _clock: Variant
|
||
var _cash_label: Label
|
||
var _day_picker: OptionButton
|
||
var _closed_label: Label
|
||
var _facility_label: Label
|
||
var _selected_day := -1
|
||
var _picker_days: Array[int] = []
|
||
var loan_option: OptionButton
|
||
var loan_amount: LineEdit
|
||
var repay_button: Button
|
||
var repay_all_button: Button
|
||
var _loan_box: VBoxContainer
|
||
var _loan_hint: Label
|
||
var _loan_result: Label
|
||
var _selected_loan: StringName = &""
|
||
var _loan_pending_command := 0
|
||
var _loan_queue_instance := 0
|
||
var _audio_submitter := Callable()
|
||
|
||
|
||
|
||
func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void:
|
||
_initial_layer = layer
|
||
_runtime = runtime
|
||
_clock = clock
|
||
name = "OperatingLedgerPanel"
|
||
add_theme_constant_override("separation", 8)
|
||
var heading := Label.new()
|
||
heading.text = "帳簿 · 00:00締め"
|
||
heading.add_theme_color_override("font_color", Color("55371e"))
|
||
LayerSkin.register_text(heading, _initial_layer)
|
||
add_child(heading)
|
||
_cash_label = _label()
|
||
_build_repayment_controls(style_button)
|
||
_day_picker = OptionButton.new()
|
||
_day_picker.name = "LedgerDayPicker"
|
||
if style_button.is_valid(): style_button.call(_day_picker)
|
||
_day_picker.item_selected.connect(func(index: int) -> void:
|
||
_selected_day = _day_picker.get_item_id(index)
|
||
refresh())
|
||
add_child(_day_picker)
|
||
_closed_label = _label()
|
||
_facility_label = _label()
|
||
if _runtime != null and _runtime.has_signal(&"command_completed"):
|
||
_runtime.connect(&"command_completed", _on_repayment_completed)
|
||
refresh()
|
||
|
||
|
||
func select_latest_close() -> void:
|
||
_selected_day = -1
|
||
_day_picker.get_popup().hide()
|
||
refresh()
|
||
|
||
|
||
func get_report() -> Dictionary:
|
||
if _runtime == null:
|
||
return {}
|
||
var minute: int = _clock.absolute_minute
|
||
var business_day := minute / 1440 + 1
|
||
var start := (business_day - 1) * 1440
|
||
if _runtime.operations_coordinator != null:
|
||
business_day = _runtime.operations_coordinator.get_open_financial_day()
|
||
start = _runtime.operations_coordinator.get_financial_period_start(business_day)
|
||
var inflow := 0
|
||
var outflow := 0
|
||
var construction_outflow := 0
|
||
var wage_outflow := 0
|
||
var upkeep_outflow := 0
|
||
var back_inflow := 0
|
||
var back_top_up := 0
|
||
var rank_tax_outflow := 0
|
||
var loan_interest_outflow := 0
|
||
for entry: LedgerEntry in _runtime.economy_service.get_entries():
|
||
if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION":
|
||
continue
|
||
if entry.debit_account == FinancePolicy.BACK_CASH:
|
||
back_inflow += entry.amount
|
||
if entry.credit_account == FinancePolicy.BACK_CASH and entry.category == FinancePolicy.TOP_UP_CATEGORY:
|
||
back_top_up += entry.amount
|
||
if entry.debit_account == &"PLAYER_CASH":
|
||
inflow += entry.amount
|
||
if entry.credit_account == &"PLAYER_CASH":
|
||
outflow += entry.amount
|
||
if entry.category == &"CONSTRUCTION":
|
||
construction_outflow += entry.amount
|
||
elif entry.category == &"MAINTENANCE_PAYMENT":
|
||
upkeep_outflow += entry.amount
|
||
elif entry.category == &"WAGE":
|
||
wage_outflow += entry.amount
|
||
elif entry.category == FinancePolicy.RANK_TAX_PAYMENT_CATEGORY:
|
||
rank_tax_outflow += entry.amount
|
||
elif entry.category == FinancePolicy.LOAN_INTEREST_PAYMENT_CATEGORY:
|
||
loan_interest_outflow += entry.amount
|
||
var summaries: Array[Dictionary] = []
|
||
if _runtime.operations_coordinator != null:
|
||
summaries = _runtime.operations_coordinator.get_day_summaries()
|
||
var selected: Dictionary = {}
|
||
for summary: Dictionary in summaries:
|
||
if int(summary.get(&"day", -1)) == _selected_day:
|
||
selected = summary
|
||
if selected.is_empty() and not summaries.is_empty():
|
||
selected = summaries.back()
|
||
return {
|
||
&"business_day": business_day, &"period_start": start,
|
||
&"cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"),
|
||
&"cash_in": inflow, &"cash_out": outflow, &"cash_net": inflow - outflow,
|
||
&"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow,
|
||
&"upkeep_cash_out": upkeep_outflow,
|
||
&"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(),
|
||
&"back_cash": _runtime.economy_service.get_balance(FinancePolicy.BACK_CASH),
|
||
&"back_cash_in": back_inflow, &"back_top_up": back_top_up,
|
||
&"rank_tax_cash_out": rank_tax_outflow, &"loan_interest_cash_out": loan_interest_outflow,
|
||
&"finance_arrears": -_runtime.economy_service.get_balance(FinancePolicy.RANK_TAX_PAYABLE)
|
||
- _runtime.economy_service.get_balance(FinancePolicy.LOAN_INTEREST_PAYABLE),
|
||
&"closed_days": summaries, &"selected_close": selected.duplicate(true),
|
||
}
|
||
|
||
|
||
func refresh() -> void:
|
||
if _runtime == null or _cash_label == null:
|
||
return
|
||
if _day_picker.get_popup().visible:
|
||
return
|
||
_refresh_repayment_controls()
|
||
var report := get_report()
|
||
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]]
|
||
# 表金は上の手持ち。裏金は裏のサービスの売上で、表金で払えない分だけを補う(A09)。
|
||
_cash_label.text += "\n裏金 %dG(本日の入金 +%dG / 表金へ補填 −%dG)\n本日の税 %dG / 利息 %dG(未払い %dG)" % [report[&"back_cash"], report[&"back_cash_in"], report[&"back_top_up"], report[&"rank_tax_cash_out"], report[&"loan_interest_cash_out"], report[&"finance_arrears"]]
|
||
var summary: Dictionary = report[&"selected_close"]
|
||
if summary.is_empty():
|
||
if not _day_picker.disabled or _day_picker.item_count == 0:
|
||
_day_picker.clear()
|
||
_picker_days.clear()
|
||
_day_picker.add_item("決算は翌日00:00に確定します")
|
||
_day_picker.disabled = true
|
||
_closed_label.text = "建築後の手持ち資金だけで営業成績は決まりません。宿泊などの売上と日々の費用をここで確認できます。"
|
||
_facility_label.text = ""
|
||
return
|
||
_day_picker.disabled = false
|
||
var days: Array[int] = []
|
||
for day: Dictionary in report[&"closed_days"]:
|
||
days.append(int(day[&"day"]))
|
||
if not _day_picker.get_popup().visible:
|
||
if days != _picker_days:
|
||
_day_picker.clear()
|
||
_picker_days = days
|
||
for day_number: int in days:
|
||
_day_picker.add_item("第%d営業日 · 確定" % day_number, day_number)
|
||
_day_picker.select(_picker_days.find(int(summary[&"day"])))
|
||
var totals: Dictionary = summary.get(&"category_totals", {})
|
||
var other := 0
|
||
for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
|
||
other += int(totals.get(key, 0))
|
||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n日次利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||
var funds: Dictionary = (summary.get(&"settlement", {}) as Dictionary).get(&"funds", {})
|
||
if not funds.is_empty():
|
||
# The day's every top-up; a report saved before the version mark counted the close's own only.
|
||
var top_up_scope := "" if funds.has(&"fund_report_version") or funds.has("fund_report_version") else "(締めの時の分のみ)"
|
||
_closed_label.text += "\nうち表の売上 %dG / 裏の売上 %dG\n世界樹ランク税 %dG(支払 %dG)/ 利息 %dG(支払 %dG)\n裏金から表金へ補填%s %dG / 未払い 税 %dG・利息 %dG" % [int(funds.get(&"revenue_front", 0)), int(funds.get(&"revenue_back", 0)), int(funds.get(&"rank_tax", 0)), int(funds.get(&"rank_tax_paid", 0)), int(funds.get(&"loan_interest", 0)), int(funds.get(&"loan_interest_paid", 0)), top_up_scope, int(funds.get(&"back_top_up", 0)), int(funds.get(&"rank_tax_arrears", 0)), int(funds.get(&"loan_interest_arrears", 0))]
|
||
if totals.has(&"financing_principal_inflows") or totals.has(&"financing_principal_outflows"):
|
||
_closed_label.text += "\n借入元本の入金 %dG(売上外) / 元本返済 %dG(決算経費)" % [int(totals.get(&"financing_principal_inflows", 0)), int(totals.get(&"financing_principal_outflows", 0))]
|
||
if _runtime.operations_coordinator.is_legacy_principal_profit_history(int(summary[&"day"])):
|
||
_closed_label.text += "\n旧版の確定履歴:元本返済を利益から除外した計算を保持"
|
||
var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"]))
|
||
_closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"]
|
||
var close_minute := int(summary[&"closed_at"]) % 1440
|
||
_closed_label.text += "\n確定時刻 %02d:%02d" % [close_minute / 60, close_minute % 60]
|
||
var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"]
|
||
var rows: Dictionary = summary.get(&"facility_profit_loss", {})
|
||
var ids := rows.keys()
|
||
ids.sort()
|
||
for id: Variant in ids:
|
||
var facility: FacilityInstance = _runtime.facility_service.get_instance(StringName(id))
|
||
var label := String(id)
|
||
if facility != null:
|
||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility.definition_id)
|
||
label = "%s (%d,%d)" % [definition.display_name if definition != null else facility.definition_id, facility.origin.x, facility.origin.y]
|
||
else:
|
||
# Corridors and stairs are completed construction entities without
|
||
# service instances, but their ownership upkeep still appears here.
|
||
var completed: Dictionary = _runtime.construction_service.get_facility(StringName(id))
|
||
if not completed.is_empty():
|
||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(completed[&"definition_id"])
|
||
var origin: Vector3i = completed[&"origin"]
|
||
label = "%s (%d,%d)" % [definition.display_name if definition != null else completed[&"definition_id"], origin.x, origin.y]
|
||
var row: Dictionary = rows[id]
|
||
lines.append("%s\n%dG / %dG / %+dG" % [label, row.get(&"revenue", 0), row.get(&"expenses", 0), row.get(&"net_profit", 0)])
|
||
_facility_label.text = "\n".join(lines) if not rows.is_empty() else "施設別の記帳はまだありません。"
|
||
|
||
|
||
func _label() -> Label:
|
||
var label := Label.new()
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.add_theme_color_override("font_color", Color("55371e"))
|
||
LayerSkin.register_text(label, _initial_layer)
|
||
label.add_theme_font_size_override("font_size", 13)
|
||
add_child(label)
|
||
return label
|
||
|
||
|
||
func configure_audio(submitter: Callable) -> void:
|
||
_audio_submitter = submitter
|
||
|
||
|
||
func _build_repayment_controls(style_button: Callable) -> void:
|
||
_loan_hint = _label()
|
||
_loan_hint.name = "LoanRepaymentHint"
|
||
_loan_box = VBoxContainer.new()
|
||
_loan_box.name = "LoanRepaymentControls"
|
||
_loan_box.add_theme_constant_override("separation", 6)
|
||
add_child(_loan_box)
|
||
loan_option = OptionButton.new()
|
||
loan_option.name = "LoanRepaymentOption"
|
||
if style_button.is_valid(): style_button.call(loan_option)
|
||
_loan_box.add_child(loan_option)
|
||
loan_option.item_selected.connect(func(index: int) -> void:
|
||
_selected_loan = StringName(loan_option.get_item_metadata(index))
|
||
loan_amount.text = ""
|
||
_refresh_repayment_controls())
|
||
loan_amount = LineEdit.new()
|
||
loan_amount.name = "LoanRepaymentAmount"
|
||
loan_amount.placeholder_text = "返済する元本(整数G)"
|
||
loan_amount.tooltip_text = "残元本と表金・裏金の合計を超えない整数額を入力"
|
||
_loan_box.add_child(loan_amount)
|
||
loan_amount.text_changed.connect(func(_text: String) -> void: _refresh_repayment_controls())
|
||
loan_amount.text_submitted.connect(func(_text: String) -> void: submit_repayment(false))
|
||
repay_button = Button.new()
|
||
repay_button.name = "LoanRepaymentButton"
|
||
repay_button.text = "指定額を返済"
|
||
if style_button.is_valid(): style_button.call(repay_button)
|
||
_loan_box.add_child(repay_button)
|
||
repay_button.pressed.connect(submit_repayment.bind(false))
|
||
repay_all_button = Button.new()
|
||
repay_all_button.name = "LoanRepayAllButton"
|
||
repay_all_button.text = "元本を完済"
|
||
if style_button.is_valid(): style_button.call(repay_all_button)
|
||
_loan_box.add_child(repay_all_button)
|
||
repay_all_button.pressed.connect(submit_repayment.bind(true))
|
||
_loan_result = _label()
|
||
_loan_result.name = "LoanRepaymentResult"
|
||
|
||
|
||
func _loan_report() -> Dictionary:
|
||
if _runtime == null or not _runtime.has_method(&"get_loan_principal_report"): return {}
|
||
return _runtime.call(&"get_loan_principal_report")
|
||
|
||
|
||
func _loan_row(report: Dictionary) -> Dictionary:
|
||
for row: Dictionary in report.get(&"loans", []):
|
||
if StringName(row[&"loan_id"]) == _selected_loan: return row
|
||
return {}
|
||
|
||
|
||
func _refresh_repayment_controls() -> void:
|
||
if loan_option == null: return
|
||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||
var instance: int = queue.get_instance_id() if queue != null else 0
|
||
if instance != _loan_queue_instance:
|
||
_loan_queue_instance = instance
|
||
_loan_pending_command = 0
|
||
_loan_result.text = ""
|
||
loan_amount.text = ""
|
||
var report := _loan_report()
|
||
var loans: Array = report.get(&"loans", [])
|
||
_loan_box.visible = not loans.is_empty()
|
||
if not loan_option.get_popup().visible:
|
||
loan_option.clear()
|
||
var selection := -1
|
||
for index: int in loans.size():
|
||
var row: Dictionary = loans[index]
|
||
var remaining := int(row[&"outstanding_principal"])
|
||
loan_option.add_item("借入 %d · %s" % [index + 1, "完済" if remaining == 0 else "残元本 %dG" % remaining])
|
||
loan_option.set_item_metadata(index, row[&"loan_id"])
|
||
if StringName(row[&"loan_id"]) == _selected_loan: selection = index
|
||
if selection < 0 and not loans.is_empty():
|
||
selection = 0
|
||
_selected_loan = StringName(loans[0][&"loan_id"])
|
||
if selection >= 0: loan_option.select(selection)
|
||
var row := _loan_row(report)
|
||
var remaining := int(row.get(&"outstanding_principal", 0))
|
||
var cash := int(report.get(&"cash", 0))
|
||
# A09: the front fund pays first and the hidden fund covers the rest in the same posting.
|
||
var funds := int(report.get(&"available_funds", cash))
|
||
var waiting := not (row.get(&"pending_command_ids", []) as Array).is_empty()
|
||
var allowed := bool(report.get(&"available", false)) and bool(report.get(&"ledger_valid", false)) and not bool(report.get(&"terminal", false)) and remaining > 0 and not waiting
|
||
var text := loan_amount.text.strip_edges()
|
||
var amount := int(text) if text.is_valid_int() else 0
|
||
repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > funds
|
||
repay_all_button.disabled = not allowed or remaining > funds
|
||
loan_amount.editable = allowed
|
||
if not bool(report.get(&"available", false)) or not bool(report.get(&"ledger_valid", false)):
|
||
_loan_hint.text = "返済の帳簿を確認できません。"
|
||
elif bool(report.get(&"terminal", false)):
|
||
_loan_hint.text = "運営は終了しました。借入の記録を確認できます。"
|
||
elif loans.is_empty():
|
||
_loan_hint.text = "借入はありません。"
|
||
elif waiting:
|
||
_loan_hint.text = "返済を受理済みです。次の時間更新で実行し、表金・裏金と残元本を再確認します。"
|
||
elif remaining == 0:
|
||
_loan_hint.text = "この借入は完済しています。"
|
||
else:
|
||
_loan_hint.text = "表金 %dG・裏金 %dG(計 %dG)から元本を返済します。表金で足りない分は裏金から補填します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, funds - cash, funds, remaining]
|
||
if remaining > funds: _loan_hint.text += "\n完済には表金と裏金の合計が不足しています。"
|
||
if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と表金・裏金の合計を超えない正の整数額を入力してください。"
|
||
|
||
|
||
func submit_repayment(full: bool = false) -> void:
|
||
var report := _loan_report()
|
||
var row := _loan_row(report)
|
||
var remaining := int(row.get(&"outstanding_principal", 0))
|
||
var text := loan_amount.text.strip_edges()
|
||
var amount := remaining if full else (int(text) if text.is_valid_int() else 0)
|
||
if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"available_funds", report.get(&"cash", 0))):
|
||
_set_repayment_result("返済できません。表金・裏金・残元本・返済待ちを確認してください。", false)
|
||
_refresh_repayment_controls()
|
||
return
|
||
var key := StringName("ui.loan.repay.%d" % int(report[&"next_command_id"]))
|
||
var payload := {&"loan_id": _selected_loan, &"amount": amount}
|
||
var result: Variant
|
||
if _audio_submitter.is_valid():
|
||
result = _audio_submitter.call(&"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key)
|
||
else:
|
||
result = _runtime.call(&"submit_command", &"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key)
|
||
var after := _loan_row(_loan_report())
|
||
if result.is_success() and (after.get(&"pending_command_ids", []) as Array).has(int(result.command_id)):
|
||
_loan_pending_command = int(result.command_id)
|
||
_set_repayment_result("返済を受理しました。実行時に表金・裏金と残元本を確認します。", true)
|
||
else:
|
||
_show_repayment_result(result)
|
||
_refresh_repayment_controls()
|
||
|
||
|
||
func _show_repayment_result(result: Variant) -> void:
|
||
if result.is_success():
|
||
_set_repayment_result("元本 %dGを返済しました。残元本 %dG。" % [int(result.details.get(&"paid_principal", 0)), int(result.details.get(&"outstanding_principal", 0))], true)
|
||
loan_amount.text = ""
|
||
else:
|
||
_set_repayment_result("返済を実行できませんでした。表金・裏金と残元本を確認してください。", false)
|
||
|
||
|
||
func _on_repayment_completed(result: Variant) -> void:
|
||
if int(result.command_id) != _loan_pending_command: return
|
||
_loan_pending_command = 0
|
||
_show_repayment_result(result)
|
||
_refresh_repayment_controls()
|
||
|
||
|
||
func _set_repayment_result(message: String, success: bool) -> void:
|
||
_loan_result.text = message
|
||
LayerSkin.register_status_text(_loan_result, success, LayerSkin.active_layer(self))
|
||
status_requested.emit(message, success)
|