Enable first operating day with staff assignments, opening guide, and ledger
This commit is contained in:
@@ -18,7 +18,8 @@ const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const LEGACY_SNAPSHOT_VERSION := 1
|
||||
const PAYROLL_SNAPSHOT_VERSION := 2
|
||||
const SNAPSHOT_VERSION := 3
|
||||
const EMPLOYMENT_SNAPSHOT_VERSION := 3
|
||||
const SNAPSHOT_VERSION := 4
|
||||
const HIGH_PRIORITY_THRESHOLD := 90
|
||||
const DEFAULT_LATE_REASON := &"PERSONAL"
|
||||
const MAX_PAYROLL_DAY := 1_000_000
|
||||
@@ -36,6 +37,7 @@ var _staff: Dictionary = {}
|
||||
var _shifts: Dictionary = {}
|
||||
var _tasks: Dictionary = {}
|
||||
var _schedule_overrides: Dictionary = {}
|
||||
var _work_assignments: Dictionary = {}
|
||||
var _departed_payroll: Dictionary = {}
|
||||
var _payroll_settlements: Dictionary = {}
|
||||
var _payroll_days: Dictionary = {}
|
||||
@@ -375,6 +377,164 @@ func get_shifts() -> Array[ShiftInstance]:
|
||||
return result
|
||||
|
||||
|
||||
## A work assignment is a repeating plan; concrete Shift records retain the
|
||||
## facility and elapsed interval used by historical wage allocation.
|
||||
func get_work_assignments() -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for staff_id: StringName in _sorted_keys(_work_assignments):
|
||||
rows.append((_work_assignments[staff_id] as Dictionary).duplicate(true))
|
||||
return rows
|
||||
|
||||
|
||||
func get_work_assignment(staff_id: StringName) -> Dictionary:
|
||||
return (_work_assignments.get(staff_id, {}) as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func set_work_assignment(staff_id: StringName, facility_id: StringName, template_id: StringName, absolute_minute := -1) -> CommandResult:
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
if minute < current_minute:
|
||||
return _invalid("Assignment time cannot move backwards.", {&"reason": &"TIME_REGRESSION"})
|
||||
var staff := get_staff(staff_id)
|
||||
if staff == null:
|
||||
return _invalid("Staff was not found.", {&"reason": &"STAFF_NOT_FOUND"})
|
||||
if template_id not in [&"DAY", &"NIGHT"]:
|
||||
return _invalid("Choose DAY or NIGHT.", {&"reason": &"INVALID_TEMPLATE"})
|
||||
if not staff.current_task_id.is_empty():
|
||||
return _invalid("Finish the current task before changing assignment.", {&"reason": &"STAFF_BUSY"})
|
||||
if not staff.can_take_shift() or staff.state not in [
|
||||
StaffRuntimeType.State.OFF_DUTY, StaffRuntimeType.State.COMMUTING, StaffRuntimeType.State.AVAILABLE,
|
||||
]:
|
||||
return _invalid("Staff cannot change assignment in the current state.", {&"reason": &"STAFF_UNAVAILABLE"})
|
||||
var previous := get_work_assignment(staff_id)
|
||||
if not previous.is_empty() and StringName(previous[&"facility_id"]) == facility_id \
|
||||
and StringName(previous[&"template_id"]) == template_id:
|
||||
return _success(_work_assignment_result(staff_id, &"ASSIGNMENT_UNCHANGED"))
|
||||
|
||||
# Build every replacement before the first live write. Never repurpose the
|
||||
# facility of elapsed work, including a NIGHT segment crossing midnight.
|
||||
var replacements: Dictionary = {}
|
||||
for shift: ShiftInstance in _shifts_for_staff(staff_id):
|
||||
if shift.cancelled or shift.end_at <= minute:
|
||||
continue
|
||||
var replacement := ShiftInstanceType.from_snapshot(shift.create_snapshot())
|
||||
if replacement.start_at >= minute:
|
||||
replacement.cancelled = true
|
||||
else:
|
||||
replacement.end_at = minute
|
||||
replacement.end_minute_of_day = posmod(minute, MINUTES_PER_DAY)
|
||||
replacement.worked_minutes = mini(replacement.worked_minutes, minute - replacement.start_at)
|
||||
replacement.close(minute)
|
||||
if not replacement.is_valid():
|
||||
return _invalid("Existing shift could not be split.", {&"reason": &"SHIFT_CONFLICT"})
|
||||
replacements[replacement.shift_instance_id] = replacement
|
||||
var interval := _work_interval(template_id, minute)
|
||||
var start_at := maxi(interval.x, minute)
|
||||
var new_shift: ShiftInstance = null
|
||||
if not facility_id.is_empty():
|
||||
new_shift = _make_work_shift(staff_id, facility_id, template_id, start_at, interval.y)
|
||||
if _shifts.has(new_shift.shift_instance_id):
|
||||
return _invalid("Generated shift ID conflicts.", {&"reason": &"SHIFT_CONFLICT"})
|
||||
for shift_id: StringName in _sorted_keys(replacements):
|
||||
_shifts[shift_id] = replacements[shift_id]
|
||||
# An explicit repeating plan supersedes future one-day overrides. Keeping
|
||||
# them would silently move an employee back to an old facility later.
|
||||
for override_id: StringName in _sorted_keys(_schedule_overrides):
|
||||
var entry: Dictionary = _schedule_overrides[override_id]
|
||||
if StringName(entry[&"staff_id"]) == staff_id \
|
||||
and int(entry[&"start_day"]) >= minute / MINUTES_PER_DAY:
|
||||
_schedule_overrides.erase(override_id)
|
||||
_work_assignments[staff_id] = {
|
||||
&"staff_id": String(staff_id), &"facility_id": String(facility_id),
|
||||
&"template_id": String(template_id), &"changed_at": minute,
|
||||
&"effective_from": start_at if new_shift != null else minute,
|
||||
&"next_start_at": interval.x + MINUTES_PER_DAY,
|
||||
}
|
||||
staff.shift_id = &""
|
||||
staff.assigned_facility_id = &""
|
||||
staff.is_overtime = false
|
||||
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"WORK_ASSIGNMENT_CHANGED", minute)
|
||||
if new_shift != null:
|
||||
_shifts[new_shift.shift_instance_id] = new_shift
|
||||
_next_shift_sequence += 1
|
||||
# APPLY_COMMANDS precedes the Staff AI phase in a running tick. The AI
|
||||
# records that new minute later; paused edits record the boundary here.
|
||||
_update_shift_states(minute, staff_id, minute == current_minute)
|
||||
if new_shift != null:
|
||||
shift_registered.emit(new_shift)
|
||||
return _success(_work_assignment_result(staff_id, &"ASSIGNMENT_UPDATED"))
|
||||
|
||||
|
||||
func _work_assignment_result(staff_id: StringName, reason: StringName) -> Dictionary:
|
||||
var row := get_work_assignment(staff_id)
|
||||
row[&"reason"] = reason
|
||||
row[&"shift_id"] = get_staff(staff_id).shift_id
|
||||
row[&"unassigned"] = String(row[&"facility_id"]).is_empty()
|
||||
return row
|
||||
|
||||
|
||||
static func _work_interval(template_id: StringName, absolute_minute: int) -> Vector2i:
|
||||
var day := absolute_minute / MINUTES_PER_DAY
|
||||
var minute_of_day := posmod(absolute_minute, MINUTES_PER_DAY)
|
||||
if template_id == &"DAY":
|
||||
if minute_of_day >= ShiftInstanceType.DAY_END:
|
||||
day += 1
|
||||
return Vector2i(day * MINUTES_PER_DAY + ShiftInstanceType.DAY_START, day * MINUTES_PER_DAY + ShiftInstanceType.DAY_END)
|
||||
if minute_of_day < ShiftInstanceType.NIGHT_END:
|
||||
day -= 1
|
||||
return Vector2i(day * MINUTES_PER_DAY + ShiftInstanceType.NIGHT_START, (day + 1) * MINUTES_PER_DAY + ShiftInstanceType.NIGHT_END)
|
||||
|
||||
|
||||
func _make_work_shift(staff_id: StringName, facility_id: StringName, template_id: StringName, start_at: int, end_at: int) -> ShiftInstance:
|
||||
return ShiftInstanceType.new().configure(
|
||||
StringName("shift_%08d_d%05d" % [_next_shift_sequence, start_at / MINUTES_PER_DAY]),
|
||||
staff_id, template_id, start_at / MINUTES_PER_DAY,
|
||||
posmod(start_at, MINUTES_PER_DAY), posmod(end_at, MINUTES_PER_DAY), facility_id
|
||||
)
|
||||
|
||||
|
||||
func _ensure_work_assignment_shifts(absolute_minute: int) -> void:
|
||||
for staff_id: StringName in _sorted_keys(_work_assignments):
|
||||
var row: Dictionary = _work_assignments[staff_id]
|
||||
if String(row[&"facility_id"]).is_empty() \
|
||||
or absolute_minute < int(row[&"next_start_at"]) - ShiftInstanceType.COMMUTE_LEAD_MINUTES:
|
||||
continue
|
||||
var interval := _work_interval(StringName(row[&"template_id"]), absolute_minute)
|
||||
# During a running shift the next occurrence still lies in the future.
|
||||
if interval.x < int(row[&"next_start_at"]):
|
||||
continue
|
||||
var shift := _make_work_shift(staff_id, StringName(row[&"facility_id"]), StringName(row[&"template_id"]), maxi(interval.x, 0), interval.y)
|
||||
_apply_override_to_shift(shift)
|
||||
var overlaps := false
|
||||
for existing: ShiftInstance in _shifts_for_staff(staff_id):
|
||||
if _shifts_overlap(existing, shift):
|
||||
overlaps = true
|
||||
break
|
||||
row[&"next_start_at"] = interval.x + MINUTES_PER_DAY
|
||||
# Explicit one-day shifts win over the recurring template on that day.
|
||||
if overlaps or _shifts.has(shift.shift_instance_id) or not shift.is_valid():
|
||||
continue
|
||||
_shifts[shift.shift_instance_id] = shift
|
||||
_next_shift_sequence += 1
|
||||
shift_registered.emit(shift)
|
||||
|
||||
|
||||
static func _work_assignment_shape_is_valid(row: Dictionary) -> bool:
|
||||
if row.size() != 6:
|
||||
return false
|
||||
for key: StringName in [&"staff_id", &"facility_id", &"template_id"]:
|
||||
if typeof(row.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
for key: StringName in [&"changed_at", &"effective_from", &"next_start_at"]:
|
||||
if typeof(row.get(key, null)) != TYPE_INT or int(row[key]) < 0:
|
||||
return false
|
||||
if String(row[&"staff_id"]).is_empty() or StringName(row[&"template_id"]) not in [&"DAY", &"NIGHT"]:
|
||||
return false
|
||||
var standard_start := ShiftInstanceType.DAY_START if StringName(row[&"template_id"]) == &"DAY" else ShiftInstanceType.NIGHT_START
|
||||
return int(row[&"effective_from"]) >= int(row[&"changed_at"]) \
|
||||
and int(row[&"effective_from"]) < int(row[&"next_start_at"]) \
|
||||
and posmod(int(row[&"next_start_at"]), MINUTES_PER_DAY) == standard_start
|
||||
|
||||
|
||||
func set_schedule_override(
|
||||
staff_id: StringName,
|
||||
start_day: int,
|
||||
@@ -470,6 +630,7 @@ func advance_one_minute(
|
||||
return {&"ok": false, &"reason": &"MINUTE_ALREADY_APPLIED"}
|
||||
current_minute = absolute_minute
|
||||
var repairs := audit_orphan_tasks(current_minute)
|
||||
_ensure_work_assignment_shifts(current_minute)
|
||||
_update_shift_states(current_minute)
|
||||
var task_actions := _advance_assigned_tasks(current_minute)
|
||||
var high_priority := _has_high_priority_open_task()
|
||||
@@ -665,6 +826,7 @@ func remove_staff(
|
||||
continue
|
||||
removed_override_days.append(int(entry.get(&"start_day", -1)))
|
||||
_schedule_overrides.erase(override_id)
|
||||
_work_assignments.erase(staff_id)
|
||||
_staff.erase(staff_id)
|
||||
staff_removed.emit(staff_id, reason)
|
||||
last_error = ""
|
||||
@@ -843,6 +1005,7 @@ func create_snapshot() -> Dictionary:
|
||||
&"shifts": shift_snapshots,
|
||||
&"tasks": task_snapshots,
|
||||
&"schedule_overrides": override_snapshots,
|
||||
&"work_assignments": get_work_assignments(),
|
||||
&"departed_payroll": departed_payroll,
|
||||
&"payroll_settlements": payroll_settlements,
|
||||
&"dispatcher": _dispatcher.create_snapshot(),
|
||||
@@ -867,6 +1030,7 @@ func restore_snapshot_with_orphan_recovery(snapshot: Dictionary) -> bool:
|
||||
_shifts = candidate._shifts
|
||||
_tasks = candidate._tasks
|
||||
_schedule_overrides = candidate._schedule_overrides
|
||||
_work_assignments = candidate._work_assignments
|
||||
_departed_payroll = candidate._departed_payroll
|
||||
_payroll_settlements = candidate._payroll_settlements
|
||||
_payroll_days = candidate._payroll_days
|
||||
@@ -896,7 +1060,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
|
||||
if (
|
||||
typeof(raw_version) != TYPE_INT
|
||||
or int(raw_version) not in [
|
||||
LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, SNAPSHOT_VERSION,
|
||||
LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, EMPLOYMENT_SNAPSHOT_VERSION, SNAPSHOT_VERSION,
|
||||
]
|
||||
):
|
||||
last_error = "Unsupported StaffService snapshot version."
|
||||
@@ -939,7 +1103,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
|
||||
raw_staff.has(&"employment_effective_minute")
|
||||
or raw_staff.has("employment_effective_minute")
|
||||
)
|
||||
if snapshot_version == SNAPSHOT_VERSION:
|
||||
if snapshot_version >= EMPLOYMENT_SNAPSHOT_VERSION:
|
||||
if not has_row_version or not has_effective_minute:
|
||||
last_error = "Current Staff snapshot entry is missing employment-time schema fields."
|
||||
return false
|
||||
@@ -994,6 +1158,38 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
|
||||
&"patch": (raw_override[&"patch"] as Dictionary).duplicate(true),
|
||||
}
|
||||
|
||||
var next_assignments: Dictionary = {}
|
||||
if snapshot_version >= SNAPSHOT_VERSION:
|
||||
if not snapshot.get(&"work_assignments", null) is Array:
|
||||
last_error = "Staff work assignments must be an Array."
|
||||
return false
|
||||
for raw_assignment: Variant in snapshot[&"work_assignments"]:
|
||||
if not raw_assignment is Dictionary or not _work_assignment_shape_is_valid(raw_assignment):
|
||||
last_error = "Staff work assignment is malformed."
|
||||
return false
|
||||
var owner := StringName(raw_assignment[&"staff_id"])
|
||||
if not next_staff.has(owner) or next_assignments.has(owner) \
|
||||
or int(raw_assignment[&"changed_at"]) > int(snapshot.get(&"current_minute", -1)):
|
||||
last_error = "Staff work assignment owner or time is invalid."
|
||||
return false
|
||||
var changed_at := int(raw_assignment[&"changed_at"])
|
||||
var assigned_facility := StringName(raw_assignment[&"facility_id"])
|
||||
var template := StringName(raw_assignment[&"template_id"])
|
||||
var expected_effective := changed_at if assigned_facility.is_empty() else maxi(changed_at, _work_interval(template, changed_at).x)
|
||||
var next_start := int(raw_assignment[&"next_start_at"])
|
||||
var current_interval := _work_interval(template, int(snapshot.get(&"current_minute", 0)))
|
||||
if int(raw_assignment[&"effective_from"]) != expected_effective \
|
||||
or (not assigned_facility.is_empty() and (
|
||||
next_start <= int(snapshot.get(&"current_minute", 0))
|
||||
or next_start not in [current_interval.x, current_interval.x + MINUTES_PER_DAY]
|
||||
)):
|
||||
last_error = "Staff work assignment cadence is invalid."
|
||||
return false
|
||||
next_assignments[owner] = raw_assignment.duplicate(true)
|
||||
elif snapshot.has(&"work_assignments"):
|
||||
last_error = "Legacy Staff snapshot contains work assignments from a later schema."
|
||||
return false
|
||||
|
||||
var next_payroll: Dictionary = {}
|
||||
for raw_payroll: Variant in snapshot[&"departed_payroll"]:
|
||||
if not raw_payroll is Dictionary:
|
||||
@@ -1067,6 +1263,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
|
||||
_shifts = next_shifts
|
||||
_tasks = next_tasks
|
||||
_schedule_overrides = next_overrides
|
||||
_work_assignments = next_assignments
|
||||
_departed_payroll = next_payroll
|
||||
_payroll_settlements = next_settlements
|
||||
_payroll_days = next_payroll_days
|
||||
@@ -1097,8 +1294,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
|
||||
return true
|
||||
|
||||
|
||||
func _update_shift_states(absolute_minute: int) -> void:
|
||||
func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", record_duty := true) -> void:
|
||||
for staff_id: StringName in _sorted_keys(_staff):
|
||||
if not only_staff.is_empty() and only_staff != staff_id:
|
||||
continue
|
||||
var staff: StaffRuntime = _staff[staff_id]
|
||||
var shift := get_shift(staff.shift_id)
|
||||
if shift != null and shift.staff_id != staff.staff_id:
|
||||
@@ -1161,7 +1360,8 @@ func _update_shift_states(absolute_minute: int) -> void:
|
||||
]:
|
||||
staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute)
|
||||
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
|
||||
shift.record_duty_minute(absolute_minute)
|
||||
if record_duty:
|
||||
shift.record_duty_minute(absolute_minute)
|
||||
if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING:
|
||||
shift.mark_late(absolute_minute, DEFAULT_LATE_REASON)
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
class_name InnOpeningGuide
|
||||
extends PanelContainer
|
||||
|
||||
## Read-only guidance. Every action goes back through the normal dashboard.
|
||||
signal action_requested(action: StringName)
|
||||
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _heading: Button
|
||||
var _body: VBoxContainer
|
||||
var _detail: Label
|
||||
var _action: Button
|
||||
var _progress: Dictionary = {}
|
||||
var _sale_economy_id := 0
|
||||
var _first_sale_minute := -1
|
||||
|
||||
|
||||
func setup(runtime: Variant, clock: Variant) -> void:
|
||||
_runtime = runtime
|
||||
_clock = clock
|
||||
name = "InnOpeningGuide"
|
||||
custom_minimum_size.x = 290
|
||||
var skin := StyleBoxFlat.new()
|
||||
skin.bg_color = Color("30261be8")
|
||||
skin.border_color = Color("b58a45")
|
||||
skin.set_border_width_all(1)
|
||||
skin.set_content_margin_all(8)
|
||||
add_theme_stylebox_override("panel", skin)
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 5)
|
||||
add_child(column)
|
||||
_heading = Button.new()
|
||||
_heading.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
_heading.add_theme_font_size_override("font_size", 14)
|
||||
_heading.pressed.connect(func() -> void: _body.visible = not _body.visible)
|
||||
column.add_child(_heading)
|
||||
_body = VBoxContainer.new()
|
||||
column.add_child(_body)
|
||||
_detail = Label.new()
|
||||
_detail.custom_minimum_size.x = 270
|
||||
_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_detail.add_theme_font_size_override("font_size", 13)
|
||||
_detail.add_theme_color_override("font_color", Color("fff0cf"))
|
||||
_body.add_child(_detail)
|
||||
_action = Button.new()
|
||||
_action.add_theme_font_size_override("font_size", 13)
|
||||
_action.pressed.connect(func() -> void: action_requested.emit(StringName(_progress.get(&"action", &""))))
|
||||
_body.add_child(_action)
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _runtime == null or _heading == null:
|
||||
return
|
||||
_progress = _read_progress()
|
||||
_heading.text = "開業の手引き · %s" % _progress[&"title"]
|
||||
_detail.text = _progress[&"detail"]
|
||||
_action.text = _progress[&"button"]
|
||||
_action.visible = not String(_progress[&"action"]).is_empty()
|
||||
# A collapsed guide must shrink as soon as its body is hidden.
|
||||
reset_size()
|
||||
|
||||
|
||||
func get_progress() -> Dictionary:
|
||||
return _read_progress().duplicate(true) if _runtime != null else {}
|
||||
|
||||
|
||||
func _read_progress() -> Dictionary:
|
||||
var first_sale := _first_lodging_sale()
|
||||
if first_sale >= 0 and _runtime.operations_coordinator != null:
|
||||
for summary: Dictionary in _runtime.operations_coordinator.get_day_summaries():
|
||||
if int(summary.get(&"closed_at", -1)) > first_sale:
|
||||
return _step(&"complete", "初日の営業を達成", "宿泊売上と翌朝の決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る")
|
||||
var reception: FacilityInstance
|
||||
var rooms: Array[FacilityInstance] = []
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"reception":
|
||||
if reception == null or _reception_score(facility) > _reception_score(reception):
|
||||
reception = facility
|
||||
elif facility.definition_id == &"standard_room":
|
||||
rooms.append(facility)
|
||||
if reception == null:
|
||||
return _build_step(&"reception", &"reception", "1/6 受付をつくる", "表の地表階に受付を配置します。完成した受付の上に客室を増築できます。", &"build_reception")
|
||||
if rooms.is_empty():
|
||||
return _build_step(&"room", &"standard_room", "2/6 客室をつくる", "受付が支えになる位置に一般客室を配置します。", &"build_room")
|
||||
var accessible_room := false
|
||||
for room: FacilityInstance in rooms:
|
||||
accessible_room = accessible_room or room.access_available
|
||||
if not reception.access_available or not accessible_room:
|
||||
return _step(&"access", "3/6 道をつなぐ", "客室の出入口まで客用通路をつなぎます。施設を選択すると、通行不可など営業できない理由を確認できます。", &"build_corridor", "客用通路を選ぶ")
|
||||
if not _has_reception_assignment(reception):
|
||||
return _step(&"staff", "4/6 受付係を配属", "従業員から受付係を雇い、雇用済み一覧でこの受付と勤務帯を選び「配属・勤務帯を変更」を押します。", &"staff", "従業員を開く")
|
||||
if first_sale < 0:
|
||||
return _step(&"sale", "5/6 最初の宿泊売上", "時間を進めて、お客さまの到着と宿泊を見守りましょう。売上は現在のサービスが完了すると記帳されます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
|
||||
return _step(&"close", "6/6 翌朝の決算へ", "最初の宿泊売上が入りました。06:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
|
||||
|
||||
|
||||
func _reception_score(facility: FacilityInstance) -> int:
|
||||
return (2 if facility.access_available else 0) + (1 if _has_reception_assignment(facility) else 0)
|
||||
|
||||
|
||||
func _has_reception_assignment(reception: FacilityInstance) -> bool:
|
||||
var assigned := not reception.active_staff_ids.is_empty()
|
||||
# A repeating plan remains valid off-duty. Historical or cancelled shifts
|
||||
# cannot conceal a deliberate unassignment made before the first sale.
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
var plan: Dictionary = _runtime.staff_service.get_work_assignment(staff.staff_id)
|
||||
if not plan.is_empty():
|
||||
assigned = assigned or StringName(plan.get(&"facility_id", &"")) == reception.instance_id
|
||||
continue
|
||||
for shift: ShiftInstance in _runtime.staff_service.get_shifts():
|
||||
if shift.staff_id == staff.staff_id and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id:
|
||||
assigned = true
|
||||
return assigned
|
||||
|
||||
|
||||
func _first_lodging_sale() -> int:
|
||||
# Once found, an append-only ledger cannot revoke historical first revenue.
|
||||
# Restore/reset replaces EconomyService, which invalidates this visual cache.
|
||||
var economy_id: int = _runtime.economy_service.get_instance_id()
|
||||
if economy_id != _sale_economy_id:
|
||||
_sale_economy_id = economy_id
|
||||
_first_sale_minute = -1
|
||||
if _first_sale_minute >= 0:
|
||||
return _first_sale_minute
|
||||
var room_ids: Dictionary = {}
|
||||
for job: ConstructionJob in _runtime.construction_service.get_jobs():
|
||||
if job.definition_id == &"standard_room" and not job.facility_instance_id.is_empty():
|
||||
room_ids[job.facility_instance_id] = true
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"standard_room":
|
||||
room_ids[facility.instance_id] = true
|
||||
if room_ids.is_empty():
|
||||
return -1
|
||||
for entry: LedgerEntry in _runtime.economy_service.get_entries():
|
||||
if entry.category == &"SERVICE_SALE" and room_ids.has(entry.source_entity_id):
|
||||
_first_sale_minute = entry.absolute_minute if _first_sale_minute < 0 else mini(_first_sale_minute, entry.absolute_minute)
|
||||
return _first_sale_minute
|
||||
|
||||
|
||||
func _build_step(stage: StringName, definition_id: StringName, title: String, detail: String, action: StringName) -> Dictionary:
|
||||
for job: ConstructionJob in _runtime.construction_service.get_jobs():
|
||||
if job.definition_id == definition_id and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]:
|
||||
return _step(stage, title, "工事中 %d%%。時間を進めると完成します。" % (job.progress / 100), &"resume" if _clock.paused else &"", "時間を進める")
|
||||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id)
|
||||
if definition != null:
|
||||
detail += "\n費用 %dG / 資材 %d" % [int(definition.construction_cost.get(&"gold", 0)), int(definition.construction_cost.get(&"materials", 0))]
|
||||
return _step(stage, title, detail, action, "受付を選ぶ" if definition_id == &"reception" else "一般客室を選ぶ")
|
||||
|
||||
|
||||
func _step(stage: StringName, title: String, detail: String, action: StringName, button: String) -> Dictionary:
|
||||
return {&"stage": stage, &"title": title, &"detail": detail, &"action": action, &"button": button}
|
||||
@@ -0,0 +1 @@
|
||||
uid://fr1l6mxlidk
|
||||
@@ -0,0 +1,129 @@
|
||||
class_name OperatingLedgerPanel
|
||||
extends VBoxContainer
|
||||
|
||||
## Projection only: authoritative close and ledger entries own all amounts.
|
||||
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] = []
|
||||
|
||||
|
||||
func setup(runtime: Variant, clock: Variant) -> void:
|
||||
_runtime = runtime
|
||||
_clock = clock
|
||||
name = "OperatingLedgerPanel"
|
||||
add_theme_constant_override("separation", 8)
|
||||
var heading := Label.new()
|
||||
heading.text = "帳簿 · 06:00締め"
|
||||
heading.add_theme_color_override("font_color", Color("55371e"))
|
||||
add_child(heading)
|
||||
_cash_label = _label()
|
||||
_day_picker = OptionButton.new()
|
||||
_day_picker.name = "LedgerDayPicker"
|
||||
_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()
|
||||
refresh()
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
if _runtime == null:
|
||||
return {}
|
||||
var minute: int = _clock.absolute_minute
|
||||
var business_day := maxi(1, int(floor(float(minute - 360) / 1440.0)) + 1)
|
||||
var start := (business_day - 1) * 1440 + 360
|
||||
var inflow := 0
|
||||
var outflow := 0
|
||||
var construction_outflow := 0
|
||||
var wage_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 == &"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 == &"WAGE":
|
||||
wage_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,
|
||||
&"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
|
||||
var report := get_report()
|
||||
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"cash_net"]]
|
||||
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("決算は翌朝06: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 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]
|
||||
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"))
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
add_child(label)
|
||||
return label
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqh1r7py3s52i
|
||||
@@ -0,0 +1,409 @@
|
||||
class_name StaffManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
## Roster and assignment editing only. Runtime owns Shift, staffing, movement,
|
||||
## and persistence; this panel never edits a Staff or Shift instance directly.
|
||||
signal status_requested(message: String, success: bool)
|
||||
|
||||
const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment"
|
||||
const KEY_PREFIX := "ui.staff_assignment."
|
||||
const ROLE_NAMES := {
|
||||
&"attendant": "接客", &"bartender": "酒場", &"clerk": "会計",
|
||||
&"cleaner": "清掃", &"cook": "調理", &"guard": "警備",
|
||||
&"maintenance": "保守", &"masseur": "施術", &"medic": "医療",
|
||||
&"porter": "物流", &"receptionist": "受付",
|
||||
}
|
||||
const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"]
|
||||
|
||||
var summary_label: Label
|
||||
var roster_list: VBoxContainer
|
||||
var roster_buttons: Dictionary = {}
|
||||
var editor_panel: VBoxContainer
|
||||
var staff_detail_label: Label
|
||||
var facility_option: OptionButton
|
||||
var shift_option: OptionButton
|
||||
var apply_button: Button
|
||||
var result_label: Label
|
||||
var selected_staff_id: StringName = &""
|
||||
|
||||
var _runtime: Variant
|
||||
var _style_button: Callable
|
||||
var _staff_rows: Array[Dictionary] = []
|
||||
var _facility_rows: Array[Dictionary] = []
|
||||
var _draft_facility_id: StringName = &""
|
||||
var _draft_template_id: StringName = &"DAY"
|
||||
var _draft_dirty := false
|
||||
var _sequence := 1
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _built := false
|
||||
|
||||
|
||||
func configure(runtime: Variant, style_button := Callable()) -> void:
|
||||
_runtime = runtime
|
||||
_style_button = style_button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "StaffManagementPanel"
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 5)
|
||||
_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:
|
||||
summary_label = _label("まだ従業員を雇っていません。候補から雇用してください。")
|
||||
summary_label.name = "HiredStaffSummary"
|
||||
add_child(summary_label)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.name = "HiredStaffScroll"
|
||||
scroll.custom_minimum_size.y = 120
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
roster_list = VBoxContainer.new()
|
||||
roster_list.name = "HiredStaffList"
|
||||
roster_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
roster_list.add_theme_constant_override("separation", 3)
|
||||
scroll.add_child(roster_list)
|
||||
editor_panel = VBoxContainer.new()
|
||||
editor_panel.name = "StaffAssignmentEditor"
|
||||
editor_panel.add_theme_constant_override("separation", 5)
|
||||
add_child(editor_panel)
|
||||
staff_detail_label = _label("")
|
||||
staff_detail_label.name = "StaffAssignmentDetail"
|
||||
editor_panel.add_child(staff_detail_label)
|
||||
editor_panel.add_child(_label("配属先"))
|
||||
facility_option = OptionButton.new()
|
||||
facility_option.name = "StaffFacilityOption"
|
||||
facility_option.fit_to_longest_item = false
|
||||
facility_option.custom_minimum_size = Vector2(0, 30)
|
||||
facility_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
facility_option.item_selected.connect(_on_facility_changed)
|
||||
_style(facility_option)
|
||||
editor_panel.add_child(facility_option)
|
||||
editor_panel.add_child(_label("勤務帯"))
|
||||
shift_option = OptionButton.new()
|
||||
shift_option.name = "StaffShiftOption"
|
||||
shift_option.fit_to_longest_item = false
|
||||
shift_option.custom_minimum_size.y = 30
|
||||
shift_option.add_item("日勤 06:00〜18:00")
|
||||
shift_option.set_item_metadata(0, &"DAY")
|
||||
shift_option.add_item("夜勤 18:00〜翌06:00")
|
||||
shift_option.set_item_metadata(1, &"NIGHT")
|
||||
shift_option.item_selected.connect(_on_shift_changed)
|
||||
_style(shift_option)
|
||||
editor_panel.add_child(shift_option)
|
||||
apply_button = Button.new()
|
||||
apply_button.name = "ApplyStaffAssignment"
|
||||
apply_button.text = "配属・勤務帯を変更"
|
||||
apply_button.custom_minimum_size.y = 32
|
||||
apply_button.pressed.connect(submit_assignment)
|
||||
_style(apply_button)
|
||||
editor_panel.add_child(apply_button)
|
||||
result_label = _label("")
|
||||
result_label.name = "StaffAssignmentResult"
|
||||
add_child(result_label)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not _built or _runtime == null:
|
||||
return
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null and int(queue.get_instance_id()) != _queue_instance_id:
|
||||
_queue_instance_id = int(queue.get_instance_id())
|
||||
_draft_dirty = false
|
||||
facility_option.get_popup().hide()
|
||||
shift_option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
_read_roster()
|
||||
var live_ids: Array[StringName] = []
|
||||
var unassigned := 0
|
||||
for row: Dictionary in _staff_rows:
|
||||
var staff_id := StringName(row.get(&"staff_id", &""))
|
||||
live_ids.append(staff_id)
|
||||
if StringName(row.get(&"assigned_facility_id", &"")).is_empty():
|
||||
unassigned += 1
|
||||
var button := roster_buttons.get(staff_id) as Button
|
||||
if button == null:
|
||||
button = Button.new()
|
||||
button.name = "HiredStaff_%s" % staff_id
|
||||
button.custom_minimum_size.y = 32
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
button.toggle_mode = true
|
||||
button.pressed.connect(select_staff.bind(staff_id))
|
||||
_style(button)
|
||||
roster_list.add_child(button)
|
||||
roster_buttons[staff_id] = button
|
||||
button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""]
|
||||
button.tooltip_text = "%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
|
||||
for raw_id: Variant in roster_buttons.keys():
|
||||
if not live_ids.has(StringName(raw_id)):
|
||||
var stale := roster_buttons[raw_id] as Button
|
||||
roster_list.remove_child(stale)
|
||||
stale.queue_free()
|
||||
roster_buttons.erase(raw_id)
|
||||
if not live_ids.has(selected_staff_id):
|
||||
selected_staff_id = live_ids[0] if not live_ids.is_empty() else &""
|
||||
_draft_dirty = false
|
||||
for raw_id: Variant in roster_buttons:
|
||||
(roster_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == selected_staff_id)
|
||||
summary_label.text = "雇用済み %d人 / 未配属 %d人" % [_staff_rows.size(), unassigned] if not _staff_rows.is_empty() else "従業員はまだいません。「候補を見る」から雇用してください。"
|
||||
var scroll := find_child("HiredStaffScroll", true, false) as ScrollContainer
|
||||
scroll.visible = not _staff_rows.is_empty()
|
||||
scroll.custom_minimum_size.y = mini(maxi(_staff_rows.size(), 1) * 35, 140)
|
||||
editor_panel.visible = not selected_staff_id.is_empty()
|
||||
if editor_panel.visible:
|
||||
_refresh_editor()
|
||||
|
||||
|
||||
func select_staff(staff_id: StringName) -> void:
|
||||
selected_staff_id = staff_id
|
||||
_draft_dirty = false
|
||||
result_label.text = ""
|
||||
refresh()
|
||||
|
||||
|
||||
func _refresh_editor() -> void:
|
||||
if facility_option.get_popup().visible or shift_option.get_popup().visible:
|
||||
return
|
||||
var row := _selected_row()
|
||||
if row.is_empty():
|
||||
return
|
||||
var current_facility := StringName(row.get(&"assigned_facility_id", &""))
|
||||
var template := StringName(String(row.get(&"template_id", "DAY")).to_upper())
|
||||
if not _draft_dirty:
|
||||
_draft_facility_id = current_facility
|
||||
_draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY"
|
||||
staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 疲労 %d / 未払 G%d" % [
|
||||
row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)),
|
||||
_facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務",
|
||||
_state_name(row), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)),
|
||||
]
|
||||
var effective_from := int(row.get(&"effective_from", -1))
|
||||
var now := int(get_node("/root/GameClock").get("absolute_minute"))
|
||||
if effective_from > now:
|
||||
staff_detail_label.text += "\n反映:%sから" % _minute_text(effective_from)
|
||||
if row.has(&"recurring") and bool(row.get(&"recurring", false)):
|
||||
staff_detail_label.text += "\nこの勤務帯を毎日くり返します。"
|
||||
facility_option.clear()
|
||||
facility_option.add_item("未配属(施設担当を外す)")
|
||||
facility_option.set_item_metadata(0, &"")
|
||||
var selected_index := 0 if _draft_facility_id.is_empty() else -1
|
||||
for facility: Dictionary in _facility_rows:
|
||||
var facility_id := StringName(facility.get(&"facility_id", &""))
|
||||
facility_option.add_item(_facility_name(facility_id))
|
||||
var index := facility_option.item_count - 1
|
||||
facility_option.set_item_metadata(index, facility_id)
|
||||
if facility_id == _draft_facility_id:
|
||||
selected_index = index
|
||||
if selected_index < 0:
|
||||
facility_option.add_item("選択した施設は現在存在しません")
|
||||
selected_index = facility_option.item_count - 1
|
||||
facility_option.set_item_metadata(selected_index, _draft_facility_id)
|
||||
facility_option.set_item_disabled(selected_index, true)
|
||||
facility_option.select(selected_index)
|
||||
shift_option.select(1 if _draft_template_id == &"NIGHT" else 0)
|
||||
var pending := _pending_commands.values().has(selected_staff_id)
|
||||
facility_option.disabled = pending
|
||||
shift_option.disabled = pending
|
||||
var can_edit := bool(row.get(&"can_edit", true))
|
||||
apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index)
|
||||
apply_button.tooltip_text = "現在の作業や拘束が終わってから変更できます。" if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
|
||||
if not can_edit:
|
||||
staff_detail_label.text += "\n現在の作業や拘束が終わってから変更できます。"
|
||||
apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更"
|
||||
|
||||
|
||||
func _on_facility_changed(index: int) -> void:
|
||||
_draft_facility_id = StringName(facility_option.get_item_metadata(index))
|
||||
_draft_dirty = true
|
||||
|
||||
|
||||
func _on_shift_changed(index: int) -> void:
|
||||
_draft_template_id = StringName(shift_option.get_item_metadata(index))
|
||||
_draft_dirty = true
|
||||
|
||||
|
||||
func submit_assignment() -> void:
|
||||
if _runtime == null or selected_staff_id.is_empty():
|
||||
return
|
||||
if _pending_commands.values().has(selected_staff_id):
|
||||
_set_result("この従業員の変更はすでに受理されています。", false)
|
||||
return
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", ASSIGNMENT_COMMAND, {
|
||||
&"staff_id": selected_staff_id,
|
||||
&"facility_id": _draft_facility_id,
|
||||
&"template_id": _draft_template_id,
|
||||
}, &"player", selected_staff_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("配属変更に応答がありません。", false)
|
||||
return
|
||||
var pending := false
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null:
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
if int(command.get("command_id", 0)) == int(result.command_id):
|
||||
pending = true
|
||||
break
|
||||
if result.is_success() and pending:
|
||||
_pending_commands[int(result.command_id)] = selected_staff_id
|
||||
_set_result("変更を受理しました。次の時間更新で反映します。", true)
|
||||
else:
|
||||
_show_command_result(result)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_command_result(result: Variant, staff_id: StringName = &"") -> void:
|
||||
var target_id := staff_id
|
||||
if target_id.is_empty():
|
||||
target_id = StringName(result.details.get(&"staff_id", selected_staff_id))
|
||||
var employee_name := String(target_id)
|
||||
for row: Dictionary in _staff_rows:
|
||||
if StringName(row.get(&"staff_id", &"")) == target_id:
|
||||
employee_name = String(row.get(&"display_name", target_id))
|
||||
break
|
||||
if result.is_success():
|
||||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||||
if target_id == selected_staff_id:
|
||||
_draft_dirty = false
|
||||
var facility_id := StringName(details.get(&"facility_id", _draft_facility_id))
|
||||
var template := StringName(details.get(&"template_id", _draft_template_id))
|
||||
var start := int(details.get(&"effective_from", -1))
|
||||
var when := "%sから" % _minute_text(start) if start >= 0 else ""
|
||||
_set_result("%s:%s%s・毎日%sに変更しました。" % [employee_name, when, _facility_name(facility_id), "夜勤" if template == &"NIGHT" else "日勤"], true)
|
||||
else:
|
||||
_set_result("%s:%s" % [employee_name, _failure_message(result)], false)
|
||||
|
||||
|
||||
func _failure_message(result: Variant) -> String:
|
||||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||||
var reason := StringName(details.get(&"reason", details.get("reason", &"")))
|
||||
var reasons := {
|
||||
&"STAFF_NOT_FOUND": "従業員が見つかりません。",
|
||||
&"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。",
|
||||
&"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。",
|
||||
&"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。",
|
||||
&"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。",
|
||||
&"FACILITY_FULL": "配属先の人員上限を超えています。",
|
||||
&"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"INVALID_TEMPLATE": "日勤または夜勤を選択してください。",
|
||||
&"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。",
|
||||
&"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。",
|
||||
&"EVENT_LOCKED": "イベント参加中は勤務を変更できません。",
|
||||
&"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。",
|
||||
&"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。",
|
||||
&"BANKRUPT": "運営終了後は配属を変更できません。",
|
||||
}
|
||||
if reasons.has(reason):
|
||||
return reasons[reason]
|
||||
return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。"
|
||||
|
||||
|
||||
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", &"")) != ASSIGNMENT_COMMAND:
|
||||
continue
|
||||
var payload: Dictionary = command.get("payload", {})
|
||||
_pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||||
if is_visible_in_tree():
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if not _pending_commands.has(int(result.command_id)):
|
||||
return
|
||||
var staff_id := StringName(_pending_commands[int(result.command_id)])
|
||||
_pending_commands.erase(int(result.command_id))
|
||||
_show_command_result(result, staff_id)
|
||||
refresh()
|
||||
|
||||
|
||||
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 _read_roster() -> void:
|
||||
_staff_rows.clear()
|
||||
_facility_rows.clear()
|
||||
if _runtime.has_method(&"get_staff_management_report"):
|
||||
var report: Dictionary = _runtime.call(&"get_staff_management_report")
|
||||
_staff_rows.assign(report.get(&"staff", []))
|
||||
_facility_rows.assign(report.get(&"facilities", []))
|
||||
return
|
||||
_set_result("従業員データを読み込めません。", false)
|
||||
|
||||
|
||||
func _selected_row() -> Dictionary:
|
||||
for row: Dictionary in _staff_rows:
|
||||
if StringName(row.get(&"staff_id", &"")) == selected_staff_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _facility_name(facility_id: StringName) -> String:
|
||||
if facility_id.is_empty():
|
||||
return "未配属"
|
||||
for facility: Dictionary in _facility_rows:
|
||||
if StringName(facility.get(&"facility_id", &"")) == facility_id:
|
||||
var suffix := String(facility_id).trim_prefix("facility_").lstrip("0")
|
||||
var location := ""
|
||||
var origin: Variant = facility.get(&"origin", null)
|
||||
if origin is Vector3i:
|
||||
var floor_name := "%dF" % (origin.y + 1) if origin.y >= 0 else "B%d" % -origin.y
|
||||
location = " %s・%s" % [floor_name, "客用" if int(facility.get(&"layer", 0)) == 0 else "裏方"]
|
||||
return "%s%s #%s" % [facility.get(&"display_name", facility_id), location, suffix if not suffix.is_empty() else facility_id]
|
||||
return "撤去済みの施設"
|
||||
|
||||
|
||||
func _role_name(row: Dictionary) -> String:
|
||||
var role := StringName(row.get(&"role_id", &""))
|
||||
return String(ROLE_NAMES.get(role, String(role)))
|
||||
|
||||
|
||||
func _state_name(row: Dictionary) -> String:
|
||||
var value := int(row.get(&"state", 0))
|
||||
return STATE_NAMES[value] if value >= 0 and value < STATE_NAMES.size() else "状態を確認中"
|
||||
|
||||
|
||||
func _minute_text(absolute_minute: int) -> String:
|
||||
var day := absolute_minute / 1440 + 1
|
||||
var hour := posmod(absolute_minute, 1440) / 60
|
||||
var minute := posmod(absolute_minute, 60)
|
||||
return "%d日目 %02d:%02d" % [day, hour, minute]
|
||||
|
||||
|
||||
func _label(text_value: String) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text_value
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", Color("394937"))
|
||||
return label
|
||||
|
||||
|
||||
func _style(button: Button) -> void:
|
||||
button.add_theme_font_size_override("font_size", 12)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(button)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c337ritas4qat
|
||||
Reference in New Issue
Block a user