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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user