384 lines
17 KiB
GDScript
384 lines
17 KiB
GDScript
class_name BusinessManagementPanel
|
||
extends VBoxContainer
|
||
|
||
## Policy drafts are local; accepted commands and read models belong to Runtime.
|
||
signal status_requested(message: String, success: bool)
|
||
signal action_requested(action: StringName)
|
||
|
||
const KEY_PREFIX := "ui.business."
|
||
const COMMAND: StringName = &"set_facility_policy"
|
||
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与は発生します。"
|
||
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与も発生します。"
|
||
const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"}
|
||
const REASONS := {
|
||
&"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。",
|
||
&"INVALID_POLICY": "営業設定を確認してください。",
|
||
&"INVALID_PRICE_MODE": "料金を選び直してください。",
|
||
&"INVALID_PAYLOAD": "営業設定を確認してください。",
|
||
&"FACILITY_CLOSED": "この施設は利用できません。",
|
||
&"BANKRUPT": "運営終了後は変更できません。",
|
||
}
|
||
var facility_option: OptionButton
|
||
var price_option: OptionButton
|
||
var business_open_button: CheckButton
|
||
var quote_label: Label
|
||
var effect_label: Label
|
||
var facility_status_label: Label
|
||
var today_label: Label
|
||
var explanation_label: Label
|
||
var apply_button: Button
|
||
var result_label: Label
|
||
var selected_facility_id: StringName = &""
|
||
|
||
var _runtime: Variant
|
||
var _style_button: Callable
|
||
var _report: Dictionary = {}
|
||
var _drafts: Dictionary = {}
|
||
var _pending_commands: Dictionary = {}
|
||
var _queue_instance_id := 0
|
||
var _sequence := 1
|
||
var _refreshing := false
|
||
var _built := false
|
||
|
||
|
||
func configure(runtime: Variant, style_button := Callable()) -> void:
|
||
_runtime = runtime
|
||
_style_button = style_button
|
||
|
||
|
||
func _ready() -> void:
|
||
name = "BusinessManagementPanel"
|
||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
add_theme_constant_override("separation", 6)
|
||
_build_interface()
|
||
_built = true
|
||
if _runtime != null:
|
||
if _runtime.has_signal(&"snapshot_published"):
|
||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||
if _runtime.has_signal(&"command_completed"):
|
||
_runtime.connect(&"command_completed", _on_command_completed)
|
||
refresh()
|
||
|
||
|
||
func _build_interface() -> void:
|
||
add_child(_label("営業 · 料金と受付", "BusinessSummary"))
|
||
add_child(_label("設定する施設"))
|
||
facility_option = _option("BusinessFacilityOption")
|
||
facility_option.item_selected.connect(func(index: int) -> void:
|
||
selected_facility_id = StringName(facility_option.get_item_metadata(index))
|
||
refresh())
|
||
add_child(facility_option)
|
||
facility_status_label = _label("", "BusinessFacilityStatus")
|
||
add_child(facility_status_label)
|
||
add_child(_label("新規の料金"))
|
||
price_option = _option("BusinessPriceOption")
|
||
price_option.item_selected.connect(func(_index: int) -> void: _store_draft())
|
||
add_child(price_option)
|
||
quote_label = _label("", "BusinessPriceQuote")
|
||
quote_label.add_theme_font_size_override("font_size", 16)
|
||
add_child(quote_label)
|
||
effect_label = _label("", "BusinessPriceEffect")
|
||
add_child(effect_label)
|
||
business_open_button = CheckButton.new()
|
||
business_open_button.name = "BusinessOpenButton"
|
||
business_open_button.text = "新規の受付を開く"
|
||
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||
business_open_button.add_theme_color_override(color_name, Color("55371e"))
|
||
business_open_button.add_theme_color_override("font_disabled_color", Color("766650"))
|
||
business_open_button.add_theme_font_size_override("font_size", 13)
|
||
business_open_button.toggled.connect(func(_enabled: bool) -> void: _store_draft())
|
||
add_child(business_open_button)
|
||
apply_button = _button("営業設定を適用", "ApplyBusinessPolicyButton", submit_policy)
|
||
add_child(apply_button)
|
||
explanation_label = _label(GUEST_POLICY_EXPLANATION, "BusinessPolicyExplanation")
|
||
add_child(explanation_label)
|
||
add_child(HSeparator.new())
|
||
today_label = _label("", "BusinessTodayResults")
|
||
add_child(today_label)
|
||
var actions := HBoxContainer.new()
|
||
actions.add_theme_constant_override("separation", 4)
|
||
for entry: Array in [[&"staff", "従業員"], [&"product", "商品"], [&"ledger", "帳簿"]]:
|
||
var button := _button(entry[1], "BusinessAction_%s" % entry[0], func() -> void: action_requested.emit(entry[0]))
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
actions.add_child(button)
|
||
add_child(actions)
|
||
result_label = _label("", "BusinessResult")
|
||
add_child(result_label)
|
||
|
||
|
||
func refresh() -> void:
|
||
if not _built:
|
||
return
|
||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||
_queue_instance_id = queue.get_instance_id()
|
||
_drafts.clear()
|
||
facility_option.get_popup().hide()
|
||
price_option.get_popup().hide()
|
||
_synchronize_commands(queue)
|
||
_report = {}
|
||
if _runtime != null and _runtime.has_method(&"get_business_management_report"):
|
||
var value: Variant = _runtime.call(&"get_business_management_report")
|
||
if value is Dictionary:
|
||
_report = value
|
||
_refreshing = true
|
||
_fill_facilities()
|
||
var row := _facility_row(selected_facility_id)
|
||
var draft := _selected_draft(row)
|
||
_fill_modes(StringName(draft.get(&"price_mode", &"standard")))
|
||
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
|
||
var pending := _has_pending(selected_facility_id)
|
||
var can_price := bool(row.get(&"can_price", false))
|
||
var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"]
|
||
business_open_button.text = "営業する" if is_supply else "新規の受付を開く"
|
||
explanation_label.text = SUPPLY_POLICY_EXPLANATION if is_supply else GUEST_POLICY_EXPLANATION
|
||
facility_option.disabled = _rows(&"facilities").is_empty()
|
||
price_option.disabled = row.is_empty() or not can_price or pending or price_option.item_count == 0
|
||
business_open_button.disabled = row.is_empty() or pending
|
||
apply_button.disabled = row.is_empty() or pending or not _is_changed(row, draft)
|
||
apply_button.text = "設定を受理済み…" if pending else "営業設定を適用"
|
||
if row.is_empty():
|
||
facility_status_label.text = "施設が完成すると営業を設定できます。"
|
||
quote_label.text = "施設を選んでください。"
|
||
effect_label.text = ""
|
||
today_label.text = "本日の実績はまだありません。"
|
||
else:
|
||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_count", 0)), int(row.get(&"active_count", 0))]
|
||
if is_supply:
|
||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||
if can_price:
|
||
var mode := StringName(draft.get(&"price_mode", &"standard"))
|
||
var quoted := FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode})
|
||
var unit := String({&"standard_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
|
||
quote_label.text = "設定後 %dG / %s\n現在 %dG · 標準 %dG" % [quoted, unit, int(row.get(&"price", 0)), int(row.get(&"base_price", 0))]
|
||
effect_label.text = _price_effect(mode)
|
||
else:
|
||
quote_label.text = "この施設に利用料金はありません。"
|
||
effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。"
|
||
today_label.text = "本日の記帳 · 06:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌朝の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
|
||
_refreshing = false
|
||
|
||
|
||
func select_facility(id: StringName) -> void:
|
||
selected_facility_id = id
|
||
refresh()
|
||
|
||
|
||
func get_report() -> Dictionary:
|
||
return _report.duplicate(true)
|
||
|
||
|
||
func submit_policy() -> void:
|
||
var row := _facility_row(selected_facility_id)
|
||
if row.is_empty() or _has_pending(selected_facility_id):
|
||
return
|
||
var draft := _selected_draft(row)
|
||
if not _is_changed(row, draft):
|
||
return
|
||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||
_set_result("営業サービスへ接続できません。", false)
|
||
return
|
||
var patch: Dictionary = {&"business_open": bool(draft.get(&"business_open", true))}
|
||
if bool(row.get(&"can_price", false)):
|
||
patch[&"price_mode"] = StringName(draft.get(&"price_mode", &"standard"))
|
||
var payload := {&"facility_id": selected_facility_id, &"patch": patch}
|
||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||
_sequence += 1
|
||
var result: Variant = _runtime.call(&"submit_command", COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||
if result == null or not result.has_method(&"is_success"):
|
||
_set_result("操作に応答がありません。", false)
|
||
return
|
||
var queued := false
|
||
var queue: Variant = _runtime.get(&"command_queue")
|
||
if queue != null:
|
||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
|
||
if result.is_success() and queued:
|
||
_pending_commands[int(result.command_id)] = payload.duplicate(true)
|
||
_set_result("営業設定を受理しました。次の時間更新で反映します。", true)
|
||
else:
|
||
_show_result(result, payload)
|
||
refresh()
|
||
|
||
|
||
func _show_result(result: Variant, payload: Dictionary) -> void:
|
||
if not result.is_success():
|
||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||
var reason := StringName(details.get(&"reason", &"UNAVAILABLE"))
|
||
_set_result(String(REASONS.get(reason, "営業設定を反映できませんでした(%s)。" % reason)), false)
|
||
return
|
||
var id := StringName(payload.get(&"facility_id", &""))
|
||
_drafts.erase(id)
|
||
var row := _facility_row(id)
|
||
_set_result("%sの営業設定を反映しました。" % String(row.get(&"display_name", "施設")), true)
|
||
|
||
|
||
func _selected_draft(row: Dictionary) -> Dictionary:
|
||
for payload: Dictionary in _pending_commands.values():
|
||
if StringName(payload.get(&"facility_id", &"")) == selected_facility_id:
|
||
var draft := row.duplicate(true)
|
||
draft.merge(payload.get(&"patch", {}), true)
|
||
return draft
|
||
return _drafts.get(selected_facility_id, row)
|
||
|
||
|
||
func _is_changed(row: Dictionary, draft: Dictionary) -> bool:
|
||
return bool(row.get(&"business_open", true)) != bool(draft.get(&"business_open", true)) or (bool(row.get(&"can_price", false)) and StringName(row.get(&"price_mode", &"standard")) != StringName(draft.get(&"price_mode", &"standard")))
|
||
|
||
|
||
func _store_draft() -> void:
|
||
if _refreshing or selected_facility_id.is_empty() or _has_pending(selected_facility_id):
|
||
return
|
||
var mode := &"standard"
|
||
if price_option.selected >= 0:
|
||
mode = StringName(price_option.get_item_metadata(price_option.selected))
|
||
_drafts[selected_facility_id] = {&"business_open": business_open_button.button_pressed, &"price_mode": mode}
|
||
refresh()
|
||
|
||
|
||
func _synchronize_commands(queue: Variant) -> void:
|
||
_pending_commands.clear()
|
||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||
var key := String(metadata.get("idempotency_key", ""))
|
||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||
if StringName(command.get("command_type", &"")) == COMMAND:
|
||
_pending_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||
|
||
|
||
func _has_pending(id: StringName) -> bool:
|
||
for payload: Dictionary in _pending_commands.values():
|
||
if StringName(payload.get(&"facility_id", &"")) == id:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _on_command_completed(result: Variant) -> void:
|
||
if not _pending_commands.has(int(result.command_id)):
|
||
return
|
||
var payload: Dictionary = _pending_commands[int(result.command_id)]
|
||
_pending_commands.erase(int(result.command_id))
|
||
_show_result(result, payload)
|
||
refresh()
|
||
|
||
|
||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||
if is_visible_in_tree():
|
||
refresh()
|
||
|
||
|
||
func _fill_facilities() -> void:
|
||
if facility_option.get_popup().visible:
|
||
return
|
||
facility_option.clear()
|
||
var rows := _rows(&"facilities")
|
||
if rows.is_empty():
|
||
facility_option.add_item("完成した施設がありません")
|
||
facility_option.set_item_metadata(0, &"")
|
||
selected_facility_id = &""
|
||
return
|
||
var selected_index := 0
|
||
for row: Dictionary in rows:
|
||
facility_option.add_item(_facility_name(row))
|
||
var index := facility_option.item_count - 1
|
||
var id := StringName(row.get(&"facility_id", &""))
|
||
facility_option.set_item_metadata(index, id)
|
||
if id == selected_facility_id:
|
||
selected_index = index
|
||
facility_option.select(selected_index)
|
||
selected_facility_id = StringName(facility_option.get_item_metadata(selected_index))
|
||
|
||
|
||
func _fill_modes(selected: StringName) -> void:
|
||
if price_option.get_popup().visible:
|
||
return
|
||
price_option.clear()
|
||
var selected_index := 0
|
||
for mode: Dictionary in _rows(&"modes"):
|
||
price_option.add_item("%s(%d%%)" % [String(mode.get(&"label", "料金")), int(mode.get(&"percent", 100))])
|
||
var index := price_option.item_count - 1
|
||
price_option.set_item_metadata(index, StringName(mode.get(&"id", &"standard")))
|
||
if StringName(mode.get(&"id", &"standard")) == selected:
|
||
selected_index = index
|
||
if price_option.item_count > 0:
|
||
price_option.select(selected_index)
|
||
|
||
|
||
func _facility_name(row: Dictionary) -> String:
|
||
var label := String(row.get(&"display_name", row.get(&"facility_id", "施設")))
|
||
var origin: Variant = row.get(&"origin", null)
|
||
var layer := "裏" if int(row.get(&"layer", 0)) == 1 else "表"
|
||
if origin is Vector2i or origin is Vector3i:
|
||
label += " (%d,%d・%s)" % [origin.x, origin.y, layer]
|
||
elif origin is Dictionary:
|
||
label += " (%d,%d・%s)" % [int(origin.get(&"x", 0)), int(origin.get(&"y", 0)), layer]
|
||
return label
|
||
|
||
|
||
func _price_effect(mode: StringName) -> String:
|
||
match mode:
|
||
&"charitable": return "お財布にやさしい料金。満足度が大きく上がる一方、1回の売上は少なくなります。"
|
||
&"affordable": return "手頃な料金。満足度が上がり、1回の売上は少し少なくなります。"
|
||
&"expensive": return "1回の売上が増えますが、満足度は下がります。お客様の懐具合にもご注意を。"
|
||
&"exorbitant": return "とても高い料金。満足度が大きく下がり、お客様が支払えない場合もあります。"
|
||
_: return "基準となる料金。価格による満足度の増減はありません。"
|
||
|
||
|
||
func _rows(key: StringName) -> Array[Dictionary]:
|
||
var rows: Array[Dictionary] = []
|
||
var value: Variant = _report.get(key, [])
|
||
if value is Array:
|
||
for row: Variant in value:
|
||
if row is Dictionary:
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
func _facility_row(id: StringName) -> Dictionary:
|
||
for row: Dictionary in _rows(&"facilities"):
|
||
if StringName(row.get(&"facility_id", &"")) == id:
|
||
return row
|
||
return {}
|
||
|
||
|
||
func _set_result(message: String, success: bool) -> void:
|
||
result_label.text = message
|
||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||
status_requested.emit(message, success)
|
||
|
||
|
||
func _label(value: String, node_name := "") -> Label:
|
||
var label := Label.new()
|
||
if not node_name.is_empty():
|
||
label.name = node_name
|
||
label.text = value
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
label.add_theme_font_size_override("font_size", 13)
|
||
label.add_theme_color_override("font_color", Color("55371e"))
|
||
return label
|
||
|
||
|
||
func _button(value: String, node_name: String, callback: Callable) -> Button:
|
||
var button := Button.new()
|
||
button.name = node_name
|
||
button.text = value
|
||
button.custom_minimum_size.y = 30
|
||
button.add_theme_font_size_override("font_size", 13)
|
||
button.pressed.connect(callback)
|
||
if _style_button.is_valid():
|
||
_style_button.call(button)
|
||
return button
|
||
|
||
|
||
func _option(node_name: String) -> OptionButton:
|
||
var picker := OptionButton.new()
|
||
picker.name = node_name
|
||
picker.fit_to_longest_item = false
|
||
picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
picker.custom_minimum_size.y = 30
|
||
if _style_button.is_valid():
|
||
_style_button.call(picker)
|
||
return picker
|