Files
TheTree_mock/scripts/staff/staff_service.gd
T

2033 lines
81 KiB
GDScript

class_name StaffService
extends RefCounted
signal staff_registered(staff: StaffRuntime)
signal staff_removed(staff_id: StringName, reason: StringName)
signal shift_registered(shift: ShiftInstance)
signal shift_state_changed(staff_id: StringName, shift_id: StringName, state: int)
signal task_registered(task: StaffTask)
signal task_assigned(task_id: StringName, staff_id: StringName)
signal task_reopened(task_id: StringName, reason: StringName)
signal task_completed(task_id: StringName, staff_id: StringName)
const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd")
const ShiftInstanceType = preload("res://scripts/staff/shift_instance.gd")
const StaffTaskType = preload("res://scripts/staff/staff_task.gd")
const TaskDispatcherType = preload("res://scripts/staff/task_dispatcher.gd")
const CommandResultType = preload("res://scripts/core/command_result.gd")
const LEGACY_SNAPSHOT_VERSION := 1
const PAYROLL_SNAPSHOT_VERSION := 2
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
const MAX_PAYROLL_SETTLEMENTS := 10_000
const MAX_PAYROLL_ROWS := 2_000
const MAX_PAYROLL_AMOUNT := 9_000_000_000_000
const MINUTES_PER_DAY := 1_440
var current_minute := 0
var last_error := ""
var last_dispatch_report: Dictionary = {}
var last_recovery_repair_report: Dictionary = {}
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 = {}
var _next_shift_sequence := 1
var _next_task_sequence := 1
var _last_advanced_minute := -1
var _dispatcher := TaskDispatcherType.new()
var _item_reserver := Callable()
var _item_releaser := Callable()
var _travel_estimator := Callable()
var _external_task_driver := Callable()
func configure_callbacks(
item_reserver: Callable = Callable(),
item_releaser: Callable = Callable(),
travel_estimator: Callable = Callable()
) -> StaffService:
_item_reserver = item_reserver
_item_releaser = item_releaser
_travel_estimator = travel_estimator
_dispatcher.configure_callbacks(item_reserver, item_releaser, travel_estimator)
return self
## External operations own their work-completion transaction while retaining
## ordinary Staff movement, shifts, and bidirectional Task ownership.
func configure_external_task_driver(driver: Callable = Callable()) -> void:
_external_task_driver = driver
func is_externally_driven_task(task_id: StringName) -> bool:
return _external_task_driver.is_valid() and bool(_external_task_driver.call(task_id))
func assign_task_to_staff(task_id: StringName, staff_id: StringName, absolute_minute: int) -> CommandResult:
var task := get_task(task_id)
var staff := get_staff(staff_id)
if task == null or staff == null or not task.is_dispatchable():
return _conflict("Task or employee is unavailable for assignment.")
if not task.required_items.is_empty() or not task.reservation_ids.is_empty():
return _conflict("Explicit assignment requires externally reserved operation inputs.")
if not staff.is_available_for_dispatch() or not staff.meets_task_requirements(task):
return _conflict("Employee is busy or does not meet the Task requirements.")
if not task.reserve(staff_id, [], absolute_minute):
return _conflict("Task could not reserve its employee.")
if not staff.reserve_task(task_id, absolute_minute):
task.reopen(&"ASSIGNMENT_ROLLED_BACK", absolute_minute, true)
return _conflict("Employee could not reserve its Task.")
task_assigned.emit(task_id, staff_id)
return _success({&"task_id": task_id, &"staff_id": staff_id})
func retarget_external_task(task_id: StringName, target_cell: Vector3i, absolute_minute: int) -> CommandResult:
var task := get_task(task_id)
if task == null or not is_externally_driven_task(task_id) or task.is_terminal():
return _conflict("Only an active externally driven Task can be retargeted.")
var staff := get_staff(task.assigned_staff_id)
if staff == null or staff.current_task_id != task_id:
return _conflict("Task and employee ownership differ.")
task.target_cell = target_cell
task.state = StaffTaskType.State.MOVING
task.progress_minutes = 0
task.state_changed_at = absolute_minute
staff.transition_to(StaffRuntimeType.State.MOVING_TO_TASK, &"TASK_RETARGETED", absolute_minute)
return _success({&"task_id": task_id})
func register_staff(value: Variant) -> CommandResult:
if value == null or not value is StaffRuntime:
return _invalid("Staff runtime is missing or has an invalid type.")
var staff: StaffRuntime = value
if not staff.is_valid():
return _invalid("Staff runtime is invalid.", {&"staff_id": staff.staff_id})
if _staff.has(staff.staff_id):
return _duplicate("Staff", staff.staff_id)
if _departed_payroll.has(staff.staff_id):
return _conflict("Staff ID has an unsettled departed payroll obligation.")
if not staff.current_task_id.is_empty() or not staff.shift_id.is_empty():
return _invalid("New Staff must register before Task or Shift assignment.")
_staff[staff.staff_id] = staff
staff_registered.emit(staff)
last_error = ""
return _success({&"staff_id": staff.staff_id})
func create_staff(
staff_id: StringName,
role_id: StringName,
options: Dictionary = {}
) -> CommandResult:
var staff := StaffRuntimeType.new().configure(staff_id, role_id, options)
return register_staff(staff)
func has_staff(staff_id: StringName) -> bool:
return _staff.has(staff_id)
func get_staff(staff_id: StringName) -> StaffRuntime:
return _staff.get(staff_id) as StaffRuntime
func get_staff_members() -> Array[StaffRuntime]:
var result: Array[StaffRuntime] = []
for staff_id: StringName in _sorted_keys(_staff):
result.append(_staff[staff_id])
return result
## Stable input for the Operations daily-close adapter. Departed wage debt is
## intentionally not mixed into this daily expense: it remains in
## departed_payroll until a separate settlement transaction clears it.
func get_daily_payroll_rows() -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(_staff):
var staff: StaffRuntime = _staff[staff_id]
rows.append({
&"staff_id": staff.staff_id,
&"employment_kind": staff.get_employment_kind_name(),
&"daily_wage": staff.daily_wage,
})
return rows
func get_daily_payroll_total() -> int:
var total := 0
for row: Dictionary in get_daily_payroll_rows():
total += int(row[&"daily_wage"])
return total
## Returns the currently carried wage liabilities without accruing a new day.
## Every returned row has the same shape and the whole result is ordered by
## Staff ID so Operations never needs access to the private payroll ledgers.
func get_payroll_obligations() -> Array[Dictionary]:
var by_staff_id: Dictionary = {}
for staff_id: StringName in _sorted_keys(_staff):
var staff: StaffRuntime = _staff[staff_id]
by_staff_id[staff_id] = {
&"staff_id": staff_id,
&"payroll_status": &"ACTIVE",
&"employment_kind": staff.get_employment_kind_name(),
&"daily_wage": staff.daily_wage,
&"unpaid_wages": staff.unpaid_wages,
}
for staff_id: StringName in _sorted_keys(_departed_payroll):
if int(_departed_payroll[staff_id]) <= 0:
continue
by_staff_id[staff_id] = {
&"staff_id": staff_id,
&"payroll_status": &"DEPARTED",
&"employment_kind": &"",
&"daily_wage": 0,
&"unpaid_wages": int(_departed_payroll[staff_id]),
}
var rows: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(by_staff_id):
rows.append((by_staff_id[staff_id] as Dictionary).duplicate(true))
return rows
func get_departed_staff_ids() -> Array[StringName]:
var result: Array[StringName] = []
for staff_id: StringName in _sorted_keys(_departed_payroll):
if int(_departed_payroll[staff_id]) > 0:
result.append(staff_id)
return result
func get_payroll_settlement(settlement_id: StringName) -> Dictionary:
var record: Variant = _payroll_settlements.get(settlement_id, {})
return record.duplicate(true) if record is Dictionary else {}
## Pure preview for one daily close. Active Staff accrue exactly one daily wage;
## departed Staff contribute only debt already preserved at departure.
func preview_daily_payroll(day: Variant) -> Dictionary:
if typeof(day) != TYPE_INT or int(day) <= 0 or int(day) > MAX_PAYROLL_DAY:
return _payroll_preview_error(&"INVALID_DAY", "Payroll day must be a positive integer.")
var payroll_day := int(day)
if _payroll_days.has(payroll_day):
return _payroll_preview_error(
&"DAY_ALREADY_SETTLED",
"Payroll day already has a settlement.",
{&"day": payroll_day, &"settlement_id": _payroll_days[payroll_day]}
)
var built := _build_payroll_preview(payroll_day)
if not bool(built.get(&"ok", false)):
return built
return built.duplicate(true)
## Atomically accrues the day's wages and applies the caller's allocation. A
## settlement ID is an idempotency key: exact input replays, conflicting input
## or another settlement for the same day is rejected without mutation.
func apply_daily_payroll_settlement(
settlement_id: Variant,
day: Variant,
payments_by_staff: Variant
) -> CommandResult:
if not _stable_settlement_id_is_valid(settlement_id):
return _invalid("Payroll settlement ID is invalid.")
if typeof(day) != TYPE_INT or int(day) <= 0 or int(day) > MAX_PAYROLL_DAY:
return _invalid("Payroll day must be a positive integer.")
if not payments_by_staff is Dictionary:
return _invalid("Payroll payments must be a Dictionary.")
var stable_id := StringName(settlement_id)
var payroll_day := int(day)
var parsed_payments := _parse_payroll_payments(payments_by_staff)
if not bool(parsed_payments.get(&"ok", false)):
return _invalid(String(parsed_payments.get(&"message", "Payroll payments are invalid.")))
var input_payments: Array[Dictionary] = parsed_payments[&"payments"]
var fingerprint := _payroll_fingerprint(payroll_day, input_payments)
if _payroll_settlements.has(stable_id):
var existing: Dictionary = _payroll_settlements[stable_id]
if int(existing[&"day"]) != payroll_day or String(existing[&"fingerprint"]) != fingerprint:
return _conflict("Payroll settlement ID conflicts with an earlier input.")
var replay_details := existing.duplicate(true)
replay_details[&"replayed"] = true
return _success(replay_details)
if _payroll_days.has(payroll_day):
return _conflict("Payroll day already has a different settlement.")
if _payroll_settlements.size() >= MAX_PAYROLL_SETTLEMENTS:
return _conflict("Payroll settlement history budget is exhausted.")
var preview := _build_payroll_preview(payroll_day)
if not bool(preview.get(&"ok", false)):
return _invalid(String(preview.get(&"message", "Payroll preview is invalid.")))
var due_by_staff: Dictionary = {}
for row: Dictionary in preview[&"rows"]:
due_by_staff[row[&"staff_id"]] = int(row[&"total_due"])
for payment: Dictionary in input_payments:
var staff_id: StringName = payment[&"staff_id"]
var amount := int(payment[&"amount"])
if not due_by_staff.has(staff_id):
return _invalid("Payroll payment references an unknown Staff ID.", {&"staff_id": staff_id})
if amount > int(due_by_staff[staff_id]):
return _invalid("Payroll payment exceeds the Staff obligation.", {&"staff_id": staff_id})
var payment_index: Dictionary = {}
for payment: Dictionary in input_payments:
payment_index[payment[&"staff_id"]] = int(payment[&"amount"])
var settlement_rows: Array[Dictionary] = []
var paid_total := 0
var unpaid_after_total := 0
for preview_row: Dictionary in preview[&"rows"]:
var staff_id: StringName = preview_row[&"staff_id"]
var payment := int(payment_index.get(staff_id, 0))
var unpaid_after := int(preview_row[&"total_due"]) - payment
var row := preview_row.duplicate(true)
row[&"payment"] = payment
row[&"unpaid_after"] = unpaid_after
settlement_rows.append(row)
paid_total += payment
unpaid_after_total += unpaid_after
# All validation and derived calculations finish before the first live write.
for row: Dictionary in settlement_rows:
var staff_id: StringName = row[&"staff_id"]
var unpaid_after := int(row[&"unpaid_after"])
if row[&"payroll_status"] == &"ACTIVE":
(_staff[staff_id] as StaffRuntime).unpaid_wages = unpaid_after
elif unpaid_after > 0:
_departed_payroll[staff_id] = unpaid_after
else:
_departed_payroll.erase(staff_id)
var record := {
&"settlement_id": stable_id,
&"day": payroll_day,
&"fingerprint": fingerprint,
&"input_payments": input_payments.duplicate(true),
&"rows": settlement_rows,
&"new_wages_total": int(preview[&"new_wages_total"]),
&"prior_arrears_total": int(preview[&"prior_arrears_total"]),
&"total_due": int(preview[&"total_due"]),
&"paid_total": paid_total,
&"unpaid_after_total": unpaid_after_total,
}
_payroll_settlements[stable_id] = record
_payroll_days[payroll_day] = stable_id
var details := record.duplicate(true)
details[&"replayed"] = false
return _success(details)
func register_shift(value: Variant) -> CommandResult:
if value == null or not value is ShiftInstance:
return _invalid("Shift instance is missing or has an invalid type.")
var shift: ShiftInstance = value
if not shift.is_valid():
return _invalid("Shift instance is invalid.", {&"shift_id": shift.shift_instance_id})
if _shifts.has(shift.shift_instance_id):
return _duplicate("Shift", shift.shift_instance_id)
var staff := get_staff(shift.staff_id)
if staff == null:
return _not_found("Staff", shift.staff_id)
if not staff.can_take_shift():
return _conflict("Staff cannot take a Shift in the current state.")
_apply_override_to_shift(shift)
for existing_shift_id: StringName in _sorted_keys(_shifts):
var existing: ShiftInstance = _shifts[existing_shift_id]
if existing.staff_id != shift.staff_id:
continue
if _shifts_overlap(existing, shift):
return _conflict("Staff already has an overlapping Shift.")
_shifts[shift.shift_instance_id] = shift
shift_registered.emit(shift)
# Future Shift instances are schedule records, not the Staff's active pointer.
# Reconciliation selects the correct one when its commute window begins. This
# lets non-overlapping future Shifts be registered in any order.
_update_shift_states(current_minute)
last_error = ""
return _success({&"shift_id": shift.shift_instance_id})
func create_standard_shift(
staff_id: StringName,
template_id: StringName,
start_day: int,
assigned_facility_id: StringName = &"",
shift_id: StringName = &""
) -> CommandResult:
var effective_id := shift_id
if effective_id.is_empty():
effective_id = StringName(
"shift_%08d_d%05d" % [_next_shift_sequence, maxi(start_day, 0)]
)
var shift := ShiftInstanceType.create_standard(
effective_id,
staff_id,
template_id,
start_day,
assigned_facility_id
)
if shift == null:
return _invalid("Only DAY and NIGHT are standard Shift templates.")
var result := register_shift(shift)
if result.is_success() and shift_id.is_empty():
_next_shift_sequence += 1
return result
func create_flex_shift(
staff_id: StringName,
start_day: int,
start_minute_of_day: int,
end_minute_of_day: int,
assigned_facility_id: StringName = &"",
shift_id: StringName = &""
) -> CommandResult:
var effective_id := shift_id
if effective_id.is_empty():
effective_id = StringName(
"shift_%08d_d%05d" % [_next_shift_sequence, maxi(start_day, 0)]
)
var shift := ShiftInstanceType.new().configure(
effective_id,
staff_id,
&"FLEX",
start_day,
start_minute_of_day,
end_minute_of_day,
assigned_facility_id
)
var result := register_shift(shift)
if result.is_success() and shift_id.is_empty():
_next_shift_sequence += 1
return result
func get_shift(shift_id: StringName) -> ShiftInstance:
return _shifts.get(shift_id) as ShiftInstance
func get_shifts() -> Array[ShiftInstance]:
var result: Array[ShiftInstance] = []
for shift_id: StringName in _sorted_keys(_shifts):
result.append(_shifts[shift_id])
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,
patch: Dictionary
) -> CommandResult:
if not _staff.has(staff_id):
return _not_found("Staff", staff_id)
if start_day < 0 or patch.is_empty():
return _invalid("ScheduleOverride requires a day and a non-empty patch.")
var key := _override_key(staff_id, start_day)
_schedule_overrides[key] = {
&"staff_id": staff_id,
&"start_day": start_day,
&"patch": patch.duplicate(true),
}
last_error = ""
return _success({&"staff_id": staff_id, &"start_day": start_day})
func get_schedule_override(staff_id: StringName, start_day: int) -> Dictionary:
var value: Variant = _schedule_overrides.get(_override_key(staff_id, start_day), {})
return value.duplicate(true) if value is Dictionary else {}
func register_task(value: Variant) -> CommandResult:
if value == null or not value is StaffTask:
return _invalid("Task is missing or has an invalid type.")
var task: StaffTask = value
if not task.is_valid() or not task.is_dispatchable():
return _invalid("A new Task must be a valid OPEN Task.")
if _tasks.has(task.task_id):
return _duplicate("Task", task.task_id)
_tasks[task.task_id] = task
task_registered.emit(task)
last_error = ""
return _success({&"task_id": task.task_id})
func create_task(
task_type: StringName,
priority: int,
created_at: int,
options: Dictionary = {},
task_id: StringName = &""
) -> CommandResult:
var effective_id := task_id
if effective_id.is_empty():
effective_id = StringName("task_%08d" % _next_task_sequence)
var task := StaffTaskType.new().configure(
effective_id,
task_type,
priority,
created_at,
options
)
var result := register_task(task)
if result.is_success() and task_id.is_empty():
_next_task_sequence += 1
return result
func get_task(task_id: StringName) -> StaffTask:
return _tasks.get(task_id) as StaffTask
func get_tasks() -> Array[StaffTask]:
var result: Array[StaffTask] = []
for task_id: StringName in _sorted_keys(_tasks):
result.append(_tasks[task_id])
return result
func dispatch_now(absolute_minute: int = -1) -> Dictionary:
var minute := current_minute if absolute_minute < 0 else absolute_minute
last_dispatch_report = _dispatcher.dispatch(get_tasks(), _staff, minute)
for assignment: Dictionary in last_dispatch_report.get(&"assigned", []):
task_assigned.emit(assignment[&"task_id"], assignment[&"staff_id"])
for interruption: Dictionary in last_dispatch_report.get(&"interruptions", []):
task_reopened.emit(interruption[&"interrupted_task_id"], &"INTERRUPTED")
last_error = ""
return last_dispatch_report.duplicate(true)
func advance_one_minute(
absolute_minute: int = -1,
force_dispatch := false
) -> Dictionary:
if absolute_minute < 0:
absolute_minute = current_minute + 1
if absolute_minute < current_minute:
return {&"ok": false, &"reason": &"TIME_REGRESSION"}
if absolute_minute <= _last_advanced_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()
var dispatched := false
if force_dispatch or _dispatcher.should_dispatch(current_minute, high_priority):
dispatch_now(current_minute)
dispatched = true
_last_advanced_minute = current_minute
return {
&"ok": true,
&"minute": current_minute,
&"repairs": repairs,
&"task_actions": task_actions,
&"dispatched": dispatched,
&"dispatch_report": last_dispatch_report.duplicate(true) if dispatched else {},
}
func begin_task(task_id: StringName, absolute_minute: int = -1) -> CommandResult:
var task := get_task(task_id)
if task == null:
return _not_found("Task", task_id)
var staff := get_staff(task.assigned_staff_id)
if staff == null or staff.current_task_id != task_id:
return _conflict("Task and Staff reservation do not match.")
var minute := current_minute if absolute_minute < 0 else absolute_minute
if task.state not in [StaffTaskType.State.RESERVED, StaffTaskType.State.MOVING]:
return _conflict("Task cannot begin from its current state.")
if staff.state not in [StaffRuntimeType.State.MOVING_TO_TASK, StaffRuntimeType.State.AVAILABLE]:
return _conflict("Staff cannot begin the reserved Task from its current state.")
# Both state preconditions are checked before either side is mutated.
var task_state_before: StaffTask.State = task.state
var task_changed_at_before := task.state_changed_at
if not task.begin_working(minute):
return _conflict("Task cannot begin from its current state.")
if not staff.begin_task(minute):
# Defensive rollback; this branch should be unreachable after the preflight.
task.state = task_state_before
task.state_changed_at = task_changed_at_before
return _conflict("Task cannot begin from its current state.")
last_error = ""
return _success({&"task_id": task_id, &"staff_id": staff.staff_id})
func progress_task(
task_id: StringName,
minutes: int = 1,
absolute_minute: int = -1,
externally_driven := false
) -> CommandResult:
if is_externally_driven_task(task_id) and not externally_driven:
return _conflict("Task completion belongs to its external operation.")
var task := get_task(task_id)
if task == null:
return _not_found("Task", task_id)
var staff := get_staff(task.assigned_staff_id)
if staff == null or staff.current_task_id != task_id:
return _conflict("Task and Staff reservation do not match.")
var minute := current_minute if absolute_minute < 0 else absolute_minute
if task.state != StaffTaskType.State.WORKING:
return _conflict("Only a WORKING Task can progress.")
if minutes <= 0:
return _invalid("Task progress minutes must be positive.")
if minute <= task.state_changed_at:
return _conflict("Task progress was already applied at this minute.")
var progress_before := task.progress_minutes
var changed_at_before := task.state_changed_at
var completed := task.advance_work(minutes, minute)
if completed:
var completed_by := staff.staff_id
var ids := task.reservation_ids.duplicate()
if not _release_reservations(ids, task, minute):
task.progress_minutes = progress_before
task.state_changed_at = changed_at_before
return _conflict("Completed Task inventory Reservations could not be released.")
if not task.complete(minute):
task.progress_minutes = progress_before
task.state_changed_at = changed_at_before
return _conflict("Task reached its duration but failed to complete.")
staff.release_task(minute, &"TASK_COMPLETED")
task_completed.emit(task_id, completed_by)
return _success({
&"task_id": task_id,
&"completed": completed,
&"progress_minutes": task.progress_minutes,
})
## Advances only work that existed before this minute. Dispatch runs after this
## pass, so a newly reserved Task cannot begin and consume work in the same tick.
## Runtime path travel can move MOVING Tasks independently; once their actor is
## at target, this service begins them on the next deterministic minute.
func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]:
var actions: Array[Dictionary] = []
for task_id: StringName in _sorted_keys(_tasks):
var task: StaffTask = _tasks[task_id]
if task.assigned_staff_id.is_empty() or task.state_changed_at >= absolute_minute:
continue
var staff := get_staff(task.assigned_staff_id)
if staff == null or staff.current_task_id != task.task_id:
continue
match task.state:
StaffTaskType.State.RESERVED:
if staff.current_cell == task.target_cell:
var begin_result := begin_task(task.task_id, absolute_minute)
if begin_result.is_success():
actions.append({
&"task_id": task.task_id,
&"staff_id": staff.staff_id,
&"action": &"BEGAN_WORK",
})
elif task.begin_moving(absolute_minute):
actions.append({
&"task_id": task.task_id,
&"staff_id": staff.staff_id,
&"action": &"BEGAN_MOVING",
})
StaffTaskType.State.MOVING:
if staff.current_cell == task.target_cell:
var begin_result := begin_task(task.task_id, absolute_minute)
if begin_result.is_success():
actions.append({
&"task_id": task.task_id,
&"staff_id": staff.staff_id,
&"action": &"BEGAN_WORK",
})
StaffTaskType.State.WORKING:
if is_externally_driven_task(task_id):
continue
var progress_result := progress_task(task.task_id, 1, absolute_minute)
if progress_result.is_success():
actions.append({
&"task_id": task.task_id,
&"staff_id": staff.staff_id,
&"action": (
&"COMPLETED"
if bool(progress_result.details.get(&"completed", false))
else &"PROGRESSED"
),
&"progress_minutes": int(progress_result.details.get(&"progress_minutes", 0)),
})
return actions
func cancel_task(
task_id: StringName,
reason: StringName = &"CANCELLED",
absolute_minute: int = -1,
externally_driven := false
) -> CommandResult:
if is_externally_driven_task(task_id) and not externally_driven:
return _conflict("External operation must release its cargo before cancellation.")
var task := get_task(task_id)
if task == null:
return _not_found("Task", task_id)
if task.is_terminal():
return _conflict("Terminal Task cannot be cancelled again.")
var minute := current_minute if absolute_minute < 0 else absolute_minute
var ids := task.reservation_ids.duplicate()
if not _release_reservations(ids, task, minute):
return _conflict("Task inventory Reservation could not be released.")
var staff := get_staff(task.assigned_staff_id)
if staff != null and staff.current_task_id == task_id:
staff.release_task(minute, reason)
task.cancel(reason, minute)
last_error = ""
return _success({&"task_id": task_id})
func remove_staff(
staff_id: StringName,
reason: StringName = &"REMOVED",
preserve_unpaid_wages := true,
absolute_minute: int = -1
) -> CommandResult:
var staff := get_staff(staff_id)
if staff == null:
return _not_found("Staff", staff_id)
if is_externally_driven_task(staff.current_task_id):
return _conflict("Finish the employee external operation before removal.")
var minute := current_minute if absolute_minute < 0 else absolute_minute
if not staff.current_task_id.is_empty():
var task := get_task(staff.current_task_id)
if task != null:
var ids := task.reservation_ids.duplicate()
if not _release_reservations(ids, task, minute):
return _conflict("Staff removal could not release Task Reservations.")
task.reopen(&"STAFF_REMOVED", minute, task.preserve_progress_on_interrupt)
task_reopened.emit(task.task_id, &"STAFF_REMOVED")
if preserve_unpaid_wages and staff.unpaid_wages > 0:
_departed_payroll[staff_id] = int(_departed_payroll.get(staff_id, 0)) + staff.unpaid_wages
var removed_shift_ids: Array[StringName] = []
for shift_id: StringName in _sorted_keys(_shifts):
var shift: ShiftInstance = _shifts[shift_id]
if shift.staff_id != staff_id:
continue
removed_shift_ids.append(shift_id)
_shifts.erase(shift_id)
var removed_override_days: Array[int] = []
for override_id: StringName in _sorted_keys(_schedule_overrides):
var entry: Dictionary = _schedule_overrides[override_id]
if StringName(entry.get(&"staff_id", &"")) != staff_id:
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 = ""
return _success({
&"staff_id": staff_id,
&"unpaid_wages_preserved": int(_departed_payroll.get(staff_id, 0)),
&"removed_shift_ids": removed_shift_ids,
&"removed_override_days": removed_override_days,
})
func retire_staff(staff_id: StringName, absolute_minute: int = -1) -> CommandResult:
var staff := get_staff(staff_id)
if staff == null:
return _not_found("Staff", staff_id)
if is_externally_driven_task(staff.current_task_id):
return _conflict("Finish the employee external operation before retirement.")
staff.is_retiring = true
return remove_staff(staff_id, &"RETIRED", true, absolute_minute)
func get_departed_unpaid_wages(staff_id: StringName) -> int:
return int(_departed_payroll.get(staff_id, 0))
func audit_orphan_tasks(absolute_minute: int = -1) -> Dictionary:
var minute := current_minute if absolute_minute < 0 else absolute_minute
var reopened: Array[StringName] = []
var released_staff: Array[StringName] = []
var release_failed: Array[StringName] = []
for task_id: StringName in _sorted_keys(_tasks):
var task: StaffTask = _tasks[task_id]
if task.assigned_staff_id.is_empty():
continue
var staff := get_staff(task.assigned_staff_id)
if staff != null and staff.current_task_id == task.task_id:
continue
var ids := task.reservation_ids.duplicate()
if not _release_reservations(ids, task, minute):
release_failed.append(task.task_id)
continue
task.reopen(&"ORPHAN_STAFF", minute, task.preserve_progress_on_interrupt)
reopened.append(task.task_id)
task_reopened.emit(task.task_id, &"ORPHAN_STAFF")
for staff_id: StringName in _sorted_keys(_staff):
var staff: StaffRuntime = _staff[staff_id]
if staff.current_task_id.is_empty():
continue
var task := get_task(staff.current_task_id)
if task == null or task.assigned_staff_id != staff.staff_id:
if staff.release_task(minute, &"ORPHAN_TASK"):
released_staff.append(staff.staff_id)
return {
&"reopened_task_ids": reopened,
&"released_staff_ids": released_staff,
&"release_failed_task_ids": release_failed,
}
## Recovery-only repair used on an isolated candidate service. All orphan Task
## Reservation IDs are handed to the external releaser in one call, allowing
## the Inventory boundary to roll the entire release set back atomically. Staff
## and Task objects are mutated only after that one transaction succeeds.
func _audit_orphan_tasks_atomically(absolute_minute: int) -> Dictionary:
var orphan_tasks: Array[StaffTask] = []
var orphan_staff: Array[StaffRuntime] = []
var all_reservation_ids: Array[StringName] = []
for task_id: StringName in _sorted_keys(_tasks):
var task: StaffTask = _tasks[task_id]
if task.assigned_staff_id.is_empty():
continue
var assigned_staff := get_staff(task.assigned_staff_id)
if assigned_staff != null and assigned_staff.current_task_id == task.task_id:
continue
orphan_tasks.append(task)
for reservation_id: StringName in task.reservation_ids:
if not all_reservation_ids.has(reservation_id):
all_reservation_ids.append(reservation_id)
for staff_id: StringName in _sorted_keys(_staff):
var staff: StaffRuntime = _staff[staff_id]
if staff.current_task_id.is_empty():
continue
var task := get_task(staff.current_task_id)
if task == null or task.assigned_staff_id != staff.staff_id:
orphan_staff.append(staff)
all_reservation_ids.sort_custom(
func(left: StringName, right: StringName) -> bool:
return String(left) < String(right)
)
if not all_reservation_ids.is_empty():
if orphan_tasks.is_empty() or not _release_reservations(
all_reservation_ids,
orphan_tasks[0],
absolute_minute
):
var failed_ids: Array[StringName] = []
for task: StaffTask in orphan_tasks:
if not task.reservation_ids.is_empty():
failed_ids.append(task.task_id)
return {
&"reopened_task_ids": [],
&"released_staff_ids": [],
&"release_failed_task_ids": failed_ids,
&"released_reservation_ids": [],
}
var reopened: Array[StringName] = []
for task: StaffTask in orphan_tasks:
if task.reopen(&"ORPHAN_STAFF", absolute_minute, task.preserve_progress_on_interrupt):
reopened.append(task.task_id)
var released_staff: Array[StringName] = []
for staff: StaffRuntime in orphan_staff:
if staff.release_task(absolute_minute, &"ORPHAN_TASK"):
released_staff.append(staff.staff_id)
return {
&"reopened_task_ids": reopened,
&"released_staff_ids": released_staff,
&"release_failed_task_ids": [],
&"released_reservation_ids": all_reservation_ids,
}
func validate_invariants() -> Dictionary:
var core := _validate_candidate_invariants(_staff, _tasks, _shifts, _departed_payroll)
var payroll := _validate_payroll_history(_payroll_settlements)
var errors: Array[String] = []
for error: String in core[&"errors"]:
errors.append(error)
for error: String in payroll[&"errors"]:
errors.append(error)
if _payroll_days != payroll.get(&"day_index", {}):
errors.append("Payroll day index does not match settlement history.")
return {&"ok": errors.is_empty(), &"errors": errors}
func create_snapshot() -> Dictionary:
var staff_snapshots: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(_staff):
staff_snapshots.append((_staff[staff_id] as StaffRuntime).create_snapshot())
var shift_snapshots: Array[Dictionary] = []
for shift_id: StringName in _sorted_keys(_shifts):
shift_snapshots.append((_shifts[shift_id] as ShiftInstance).create_snapshot())
var task_snapshots: Array[Dictionary] = []
for task_id: StringName in _sorted_keys(_tasks):
task_snapshots.append((_tasks[task_id] as StaffTask).create_snapshot())
var override_snapshots: Array[Dictionary] = []
for override_id: StringName in _sorted_keys(_schedule_overrides):
var entry: Dictionary = _schedule_overrides[override_id]
override_snapshots.append({
&"staff_id": String(entry[&"staff_id"]),
&"start_day": int(entry[&"start_day"]),
&"patch": (entry[&"patch"] as Dictionary).duplicate(true),
})
var departed_payroll: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(_departed_payroll):
departed_payroll.append({
&"staff_id": String(staff_id),
&"unpaid_wages": int(_departed_payroll[staff_id]),
})
var payroll_settlements: Array[Dictionary] = []
var settlement_records: Array[Dictionary] = []
for settlement_id: StringName in _sorted_keys(_payroll_settlements):
settlement_records.append(_payroll_settlements[settlement_id])
settlement_records.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
if int(left[&"day"]) != int(right[&"day"]):
return int(left[&"day"]) < int(right[&"day"])
return String(left[&"settlement_id"]) < String(right[&"settlement_id"])
)
for record: Dictionary in settlement_records:
payroll_settlements.append(_serialize_payroll_settlement(record))
return {
&"version": SNAPSHOT_VERSION,
&"current_minute": current_minute,
&"last_advanced_minute": _last_advanced_minute,
&"next_shift_sequence": _next_shift_sequence,
&"next_task_sequence": _next_task_sequence,
&"staff": staff_snapshots,
&"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(),
}
func restore_snapshot(snapshot: Dictionary) -> bool:
return _restore_snapshot_internal(snapshot, false)
func restore_snapshot_with_orphan_recovery(snapshot: Dictionary) -> bool:
# Parse and repair on an isolated service. No live Staff object, Dispatcher,
# or signal is touched until the candidate and its external Reservation release
# transaction have both completed and passed final invariants.
var candidate := StaffService.new()
candidate.configure_callbacks(_item_reserver, _item_releaser, _travel_estimator)
if not candidate._restore_snapshot_internal(snapshot, true):
last_error = candidate.last_error
last_recovery_repair_report = candidate.last_recovery_repair_report.duplicate(true)
return false
_staff = candidate._staff
_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
current_minute = candidate.current_minute
_last_advanced_minute = candidate._last_advanced_minute
_next_shift_sequence = candidate._next_shift_sequence
_next_task_sequence = candidate._next_task_sequence
_dispatcher = candidate._dispatcher
_dispatcher.configure_callbacks(_item_reserver, _item_releaser, _travel_estimator)
last_dispatch_report = {}
last_recovery_repair_report = candidate.last_recovery_repair_report.duplicate(true)
last_error = ""
for task_id: StringName in last_recovery_repair_report.get(&"reopened_task_ids", []):
task_reopened.emit(task_id, &"ORPHAN_STAFF")
return true
func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool) -> bool:
last_error = ""
last_recovery_repair_report = {
&"automatic_repairs_applied": false,
&"reopened_task_ids": [],
&"released_staff_ids": [],
&"release_failed_task_ids": [],
}
var raw_version: Variant = snapshot.get(&"version", null)
if (
typeof(raw_version) != TYPE_INT
or int(raw_version) not in [
LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, EMPLOYMENT_SNAPSHOT_VERSION, SNAPSHOT_VERSION,
]
):
last_error = "Unsupported StaffService snapshot version."
return false
var snapshot_version := int(raw_version)
for key: StringName in [&"staff", &"shifts", &"tasks", &"schedule_overrides", &"departed_payroll"]:
if not snapshot.get(key, null) is Array:
last_error = "StaffService snapshot collection is malformed: %s" % key
return false
if (
snapshot_version >= PAYROLL_SNAPSHOT_VERSION
and not snapshot.get(&"payroll_settlements", null) is Array
):
last_error = "StaffService payroll settlement history is malformed."
return false
if (
snapshot_version == LEGACY_SNAPSHOT_VERSION
and (
snapshot.has(&"payroll_settlements")
or snapshot.has("payroll_settlements")
or snapshot.has(&"last_advanced_minute")
or snapshot.has("last_advanced_minute")
)
):
last_error = "Legacy StaffService snapshot contains fields from a later schema."
return false
if not snapshot.get(&"dispatcher", null) is Dictionary:
last_error = "StaffService dispatcher snapshot is malformed."
return false
var next_staff: Dictionary = {}
for raw_staff: Variant in snapshot[&"staff"]:
if not raw_staff is Dictionary:
last_error = "Staff snapshot entry must be a Dictionary."
return false
var has_row_version: bool = (
raw_staff.has(&"snapshot_version") or raw_staff.has("snapshot_version")
)
var has_effective_minute: bool = (
raw_staff.has(&"employment_effective_minute")
or raw_staff.has("employment_effective_minute")
)
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
elif has_row_version or has_effective_minute:
last_error = "Legacy Staff snapshot entry contains employment-time schema fields."
return false
var staff := StaffRuntimeType.from_snapshot(raw_staff)
if staff == null or next_staff.has(staff.staff_id):
last_error = "Staff snapshot entry is invalid or duplicated."
return false
next_staff[staff.staff_id] = staff
var next_shifts: Dictionary = {}
for raw_shift: Variant in snapshot[&"shifts"]:
if not raw_shift is Dictionary:
last_error = "Shift snapshot entry must be a Dictionary."
return false
var shift := ShiftInstanceType.from_snapshot(raw_shift)
if shift == null or next_shifts.has(shift.shift_instance_id):
last_error = "Shift snapshot entry is invalid or duplicated."
return false
next_shifts[shift.shift_instance_id] = shift
var next_tasks: Dictionary = {}
for raw_task: Variant in snapshot[&"tasks"]:
if not raw_task is Dictionary:
last_error = "Task snapshot entry must be a Dictionary."
return false
var task := StaffTaskType.from_snapshot(raw_task)
if task == null or next_tasks.has(task.task_id):
last_error = "Task snapshot entry is invalid or duplicated."
return false
next_tasks[task.task_id] = task
var next_overrides: Dictionary = {}
for raw_override: Variant in snapshot[&"schedule_overrides"]:
if not raw_override is Dictionary or not raw_override.get(&"patch", null) is Dictionary:
last_error = "ScheduleOverride snapshot entry is malformed."
return false
var staff_id := StringName(raw_override.get(&"staff_id", ""))
var start_day := int(raw_override.get(&"start_day", -1))
if staff_id.is_empty() or start_day < 0 or not next_staff.has(staff_id):
last_error = "ScheduleOverride references invalid Staff or day."
return false
var key := _override_key(staff_id, start_day)
if next_overrides.has(key):
last_error = "ScheduleOverride is duplicated."
return false
next_overrides[key] = {
&"staff_id": staff_id,
&"start_day": start_day,
&"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:
last_error = "Departed payroll snapshot entry is malformed."
return false
var raw_staff_id: Variant = raw_payroll.get(&"staff_id", null)
if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
last_error = "Departed payroll Staff ID must be a string."
return false
var staff_id := StringName(raw_staff_id)
var raw_amount: Variant = raw_payroll.get(&"unpaid_wages", null)
if typeof(raw_amount) != TYPE_INT:
last_error = "Departed payroll amount must be a non-negative integer."
return false
var amount := int(raw_amount)
if staff_id.is_empty() or amount < 0 or next_payroll.has(staff_id):
last_error = "Departed payroll snapshot entry is invalid or duplicated."
return false
next_payroll[staff_id] = amount
var next_settlements: Dictionary = {}
var next_payroll_days: Dictionary = {}
if snapshot_version >= PAYROLL_SNAPSHOT_VERSION:
var parsed_history := _parse_payroll_history_snapshot(snapshot[&"payroll_settlements"])
if not bool(parsed_history.get(&"ok", false)):
last_error = String(parsed_history.get(&"message", "Payroll settlement history is invalid."))
return false
next_settlements = parsed_history[&"settlements"]
next_payroll_days = parsed_history[&"day_index"]
var invariant_report := _validate_candidate_invariants(
next_staff, next_tasks, next_shifts, next_payroll
)
if not bool(invariant_report[&"ok"]):
if not recover_orphan_tasks:
last_error = "StaffService snapshot violates invariants: %s" % [str(invariant_report[&"errors"])]
return false
var recovery_scope := _validate_recovery_candidate_scope(next_staff, next_tasks, next_shifts)
if not bool(recovery_scope[&"ok"]):
last_error = "StaffService recovery contains non-repairable damage: %s" % [str(recovery_scope[&"errors"])]
return false
var next_dispatcher := TaskDispatcherType.new()
if not next_dispatcher.restore_snapshot(snapshot[&"dispatcher"]):
last_error = next_dispatcher.last_restore_error
return false
if (
typeof(snapshot.get(&"current_minute", null)) != TYPE_INT
or typeof(snapshot.get(&"next_shift_sequence", null)) != TYPE_INT
or typeof(snapshot.get(&"next_task_sequence", null)) != TYPE_INT
):
last_error = "StaffService snapshot counters must be integers."
return false
var next_current_minute := int(snapshot[&"current_minute"])
var next_shift_sequence := int(snapshot[&"next_shift_sequence"])
var next_task_sequence := int(snapshot[&"next_task_sequence"])
if next_current_minute < 0 or next_shift_sequence <= 0 or next_task_sequence <= 0:
last_error = "StaffService snapshot counters are invalid."
return false
var next_last_advanced_minute := next_current_minute
if snapshot_version >= PAYROLL_SNAPSHOT_VERSION:
var raw_last_advanced: Variant = snapshot.get(&"last_advanced_minute", null)
if typeof(raw_last_advanced) != TYPE_INT:
last_error = "StaffService last advanced minute must be an integer."
return false
next_last_advanced_minute = int(raw_last_advanced)
if next_last_advanced_minute < -1 or next_last_advanced_minute > next_current_minute:
last_error = "StaffService last advanced minute is invalid."
return false
_staff = next_staff
_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
current_minute = next_current_minute
_last_advanced_minute = next_last_advanced_minute
_next_shift_sequence = next_shift_sequence
_next_task_sequence = next_task_sequence
_dispatcher = next_dispatcher
_dispatcher.configure_callbacks(_item_reserver, _item_releaser, _travel_estimator)
if recover_orphan_tasks:
var repair_report := _audit_orphan_tasks_atomically(current_minute)
last_recovery_repair_report = repair_report.duplicate(true)
last_recovery_repair_report[&"automatic_repairs_applied"] = (
not repair_report[&"reopened_task_ids"].is_empty()
or not repair_report[&"released_staff_ids"].is_empty()
)
if not repair_report[&"release_failed_task_ids"].is_empty():
last_error = "StaffService recovery could not release orphan Task reservations."
return false
var repaired_invariants := _validate_candidate_invariants(
_staff, _tasks, _shifts, _departed_payroll
)
if not bool(repaired_invariants[&"ok"]):
last_error = "StaffService recovery left invalid state: %s" % [str(repaired_invariants[&"errors"])]
return false
last_dispatch_report = {}
last_error = ""
return true
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:
shift = null
staff.shift_id = &""
if shift != null and shift.has_ended(absolute_minute):
var current_task := get_task(staff.current_task_id)
if current_task != null and current_task.interrupt_level == 0:
staff.is_overtime = true
shift.record_duty_minute(absolute_minute, true)
continue
if current_task != null:
var ids := current_task.reservation_ids.duplicate()
if not _release_reservations(ids, current_task, absolute_minute):
staff.is_overtime = true
shift.record_duty_minute(absolute_minute, true)
continue
current_task.reopen(
&"SHIFT_ENDED",
absolute_minute,
current_task.preserve_progress_on_interrupt
)
task_reopened.emit(current_task.task_id, &"SHIFT_ENDED")
staff.current_task_id = &""
staff.is_overtime = false
staff.shift_id = &""
if staff.assigned_facility_id == shift.assigned_facility_id:
staff.assigned_facility_id = &""
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"SHIFT_ENDED", absolute_minute)
shift.close(absolute_minute)
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
shift = null
# A large clock jump may skip an entire Shift. Close such records at their
# scheduled boundary so they cannot later be mistaken for pending work.
for skipped: ShiftInstance in _shifts_for_staff(staff.staff_id):
if skipped.actual_ended_at < 0 and skipped.has_ended(absolute_minute):
skipped.close(skipped.end_at)
if shift == null:
shift = _find_relevant_shift(staff.staff_id, absolute_minute)
if shift != null and staff.can_take_shift():
staff.shift_id = shift.shift_instance_id
else:
shift = null
if shift == null or not staff.can_take_shift():
continue
if not shift.assigned_facility_id.is_empty():
staff.assigned_facility_id = shift.assigned_facility_id
if shift.is_commute_window(absolute_minute):
if staff.current_task_id.is_empty() and staff.state == StaffRuntimeType.State.OFF_DUTY:
staff.transition_to(StaffRuntimeType.State.COMMUTING, &"SHIFT_COMMUTE", absolute_minute)
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
continue
if shift.contains_minute(absolute_minute):
staff.is_overtime = false
if staff.current_task_id.is_empty() and staff.state in [
StaffRuntimeType.State.OFF_DUTY,
StaffRuntimeType.State.COMMUTING,
]:
staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute)
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
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)
func _find_relevant_shift(staff_id: StringName, absolute_minute: int) -> ShiftInstance:
for shift: ShiftInstance in _shifts_for_staff(staff_id):
if shift.actual_ended_at >= 0:
continue
if shift.is_commute_window(absolute_minute) or shift.contains_minute(absolute_minute):
return shift
return null
func _shifts_for_staff(staff_id: StringName) -> Array[ShiftInstance]:
var result: Array[ShiftInstance] = []
for shift_id: StringName in _sorted_keys(_shifts):
var shift: ShiftInstance = _shifts[shift_id]
if shift.staff_id == staff_id:
result.append(shift)
result.sort_custom(
func(a: ShiftInstance, b: ShiftInstance) -> bool:
if a.start_at != b.start_at:
return a.start_at < b.start_at
return String(a.shift_instance_id) < String(b.shift_instance_id)
)
return result
static func _shifts_overlap(a: ShiftInstance, b: ShiftInstance) -> bool:
if a.cancelled or b.cancelled:
return false
return a.start_at < b.end_at and b.start_at < a.end_at
func _apply_override_to_shift(shift: ShiftInstance) -> void:
var entry := get_schedule_override(shift.staff_id, shift.start_day)
if entry.is_empty():
return
var patch: Dictionary = entry[&"patch"]
var start_minute := int(patch.get(&"start_minute_of_day", shift.start_minute_of_day))
var end_minute := int(patch.get(&"end_minute_of_day", shift.end_minute_of_day))
shift.configure(
shift.shift_instance_id,
shift.staff_id,
shift.template_id,
shift.start_day,
start_minute,
end_minute,
StringName(patch.get(&"assigned_facility_id", shift.assigned_facility_id)),
{&"cancelled": patch.get(&"cancelled", shift.cancelled)}
)
func _release_reservations(ids: Variant, task: StaffTask, absolute_minute: int) -> bool:
var reservation_ids: Array[StringName] = []
if ids is Array:
for value: Variant in ids:
reservation_ids.append(StringName(value))
if reservation_ids.is_empty():
return true
if not _item_releaser.is_valid():
return false
var response: Variant = _item_releaser.call(reservation_ids, task, absolute_minute)
if response is Dictionary:
return bool(response.get(&"ok", false))
return bool(response)
func _has_high_priority_open_task() -> bool:
for task_id: StringName in _sorted_keys(_tasks):
var task: StaffTask = _tasks[task_id]
if task.is_dispatchable() and task.priority >= HIGH_PRIORITY_THRESHOLD:
return true
return false
func _build_payroll_preview(day: int) -> Dictionary:
var rows_by_staff: Dictionary = {}
var new_wages_total := 0
var prior_arrears_total := 0
var total_due := 0
var period_end := day * MINUTES_PER_DAY
if _staff.size() + _departed_payroll.size() > MAX_PAYROLL_ROWS:
return _payroll_preview_error(&"ROW_BUDGET_EXCEEDED", "Payroll row budget is exceeded.")
for staff_id: StringName in _sorted_keys(_staff):
var staff: StaffRuntime = _staff[staff_id]
var prior := staff.unpaid_wages
if staff.employment_effective_minute < 0:
return _payroll_preview_error(
&"INVALID_EMPLOYMENT_EFFECTIVE_MINUTE",
"Staff employment effective minute is invalid."
)
# Daily close covers [0, day * 1440). A hire exactly on the end
# boundary belongs to the next close; carried debt remains due now.
var newly_accrued := (
staff.daily_wage if staff.employment_effective_minute < period_end else 0
)
if (
prior < 0
or newly_accrued < 0
or prior > MAX_PAYROLL_AMOUNT - newly_accrued
):
return _payroll_preview_error(&"AMOUNT_OUT_OF_RANGE", "Active payroll amount is out of range.")
var due := prior + newly_accrued
if (
new_wages_total > MAX_PAYROLL_AMOUNT - newly_accrued
or prior_arrears_total > MAX_PAYROLL_AMOUNT - prior
or total_due > MAX_PAYROLL_AMOUNT - due
):
return _payroll_preview_error(&"TOTAL_OUT_OF_RANGE", "Payroll total is out of range.")
rows_by_staff[staff_id] = {
&"staff_id": staff_id,
&"payroll_status": &"ACTIVE",
&"employment_kind": staff.get_employment_kind_name(),
&"daily_wage": newly_accrued,
&"prior_arrears": prior,
&"new_wages": newly_accrued,
&"total_due": due,
}
new_wages_total += newly_accrued
prior_arrears_total += prior
total_due += due
for staff_id: StringName in _sorted_keys(_departed_payroll):
var prior := int(_departed_payroll[staff_id])
if prior <= 0:
continue
if rows_by_staff.has(staff_id):
return _payroll_preview_error(&"STAFF_ID_COLLISION", "Active and departed payroll IDs collide.")
if prior > MAX_PAYROLL_AMOUNT:
return _payroll_preview_error(&"AMOUNT_OUT_OF_RANGE", "Departed payroll amount is out of range.")
if prior_arrears_total > MAX_PAYROLL_AMOUNT - prior or total_due > MAX_PAYROLL_AMOUNT - prior:
return _payroll_preview_error(&"TOTAL_OUT_OF_RANGE", "Payroll total is out of range.")
rows_by_staff[staff_id] = {
&"staff_id": staff_id,
&"payroll_status": &"DEPARTED",
&"employment_kind": &"",
&"daily_wage": 0,
&"prior_arrears": prior,
&"new_wages": 0,
&"total_due": prior,
}
prior_arrears_total += prior
total_due += prior
var rows: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(rows_by_staff):
rows.append((rows_by_staff[staff_id] as Dictionary).duplicate(true))
return {
&"ok": true,
&"day": day,
&"rows": rows,
&"new_wages_total": new_wages_total,
&"prior_arrears_total": prior_arrears_total,
&"total_due": total_due,
}
func _payroll_preview_error(
reason: StringName,
message: String,
details: Dictionary = {}
) -> Dictionary:
var result := {
&"ok": false,
&"reason": reason,
&"message": message,
&"rows": [],
&"new_wages_total": 0,
&"prior_arrears_total": 0,
&"total_due": 0,
}
for key: Variant in details:
result[key] = details[key]
return result
static func _stable_settlement_id_is_valid(value: Variant) -> bool:
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
var text := String(value)
if text.is_empty() or text.length() > 128:
return false
for index: int in range(text.length()):
var code := text.unicode_at(index)
var allowed := (
code >= 48 and code <= 57
or code >= 65 and code <= 90
or code >= 97 and code <= 122
or code in [45, 46, 58, 64, 95]
)
if not allowed:
return false
return true
static func _parse_payroll_payments(source: Dictionary) -> Dictionary:
if source.size() > MAX_PAYROLL_ROWS:
return {&"ok": false, &"message": "Payroll payment row budget is exceeded."}
var payments_by_id: Dictionary = {}
for raw_staff_id: Variant in source:
if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {&"ok": false, &"message": "Payroll Staff IDs must be strings."}
var staff_id := StringName(raw_staff_id)
var raw_amount: Variant = source[raw_staff_id]
if staff_id.is_empty() or payments_by_id.has(staff_id):
return {&"ok": false, &"message": "Payroll Staff ID is empty or duplicated."}
if typeof(raw_amount) != TYPE_INT or int(raw_amount) < 0 or int(raw_amount) > MAX_PAYROLL_AMOUNT:
return {&"ok": false, &"message": "Payroll payment must be a bounded non-negative integer."}
payments_by_id[staff_id] = int(raw_amount)
var payments: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(payments_by_id):
payments.append({&"staff_id": staff_id, &"amount": int(payments_by_id[staff_id])})
return {&"ok": true, &"payments": payments}
static func _payroll_fingerprint(day: int, payments: Array[Dictionary]) -> String:
var canonical := "day=%d|" % day
for payment: Dictionary in payments:
var staff_text := String(payment[&"staff_id"])
canonical += "%d:%s=%d;" % [staff_text.length(), staff_text, int(payment[&"amount"])]
return canonical.sha256_text()
static func _serialize_payroll_settlement(record: Dictionary) -> Dictionary:
var payments: Array[Dictionary] = []
for payment: Dictionary in record[&"input_payments"]:
payments.append({
&"staff_id": String(payment[&"staff_id"]),
&"amount": int(payment[&"amount"]),
})
var rows: Array[Dictionary] = []
for source_row: Dictionary in record[&"rows"]:
var row := source_row.duplicate(true)
row[&"staff_id"] = String(source_row[&"staff_id"])
row[&"payroll_status"] = String(source_row[&"payroll_status"])
row[&"employment_kind"] = String(source_row[&"employment_kind"])
rows.append(row)
return {
&"settlement_id": String(record[&"settlement_id"]),
&"day": int(record[&"day"]),
&"fingerprint": String(record[&"fingerprint"]),
&"input_payments": payments,
&"rows": rows,
&"new_wages_total": int(record[&"new_wages_total"]),
&"prior_arrears_total": int(record[&"prior_arrears_total"]),
&"total_due": int(record[&"total_due"]),
&"paid_total": int(record[&"paid_total"]),
&"unpaid_after_total": int(record[&"unpaid_after_total"]),
}
static func _parse_payroll_history_snapshot(source: Array) -> Dictionary:
if source.size() > MAX_PAYROLL_SETTLEMENTS:
return {&"ok": false, &"message": "Payroll settlement history budget is exceeded."}
var settlements: Dictionary = {}
var day_index: Dictionary = {}
var previous_day := 0
for raw_record: Variant in source:
if not raw_record is Dictionary:
return {&"ok": false, &"message": "Payroll settlement entry must be a Dictionary."}
var record: Dictionary = raw_record
if not _dictionary_has_exact_keys(record, [
&"settlement_id", &"day", &"fingerprint", &"input_payments", &"rows",
&"new_wages_total", &"prior_arrears_total", &"total_due",
&"paid_total", &"unpaid_after_total",
]):
return {&"ok": false, &"message": "Payroll settlement fields are malformed."}
if not _stable_settlement_id_is_valid(record[&"settlement_id"]):
return {&"ok": false, &"message": "Payroll settlement ID is invalid."}
if typeof(record[&"day"]) != TYPE_INT:
return {&"ok": false, &"message": "Payroll settlement day must be an integer."}
var settlement_id := StringName(record[&"settlement_id"])
var day := int(record[&"day"])
if day <= 0 or day > MAX_PAYROLL_DAY or day <= previous_day:
return {&"ok": false, &"message": "Payroll settlement days must be unique and strictly ordered."}
if settlements.has(settlement_id) or day_index.has(day):
return {&"ok": false, &"message": "Payroll settlement ID or day is duplicated."}
if not record[&"input_payments"] is Array or not record[&"rows"] is Array:
return {&"ok": false, &"message": "Payroll settlement collections are malformed."}
if record[&"input_payments"].size() > MAX_PAYROLL_ROWS or record[&"rows"].size() > MAX_PAYROLL_ROWS:
return {&"ok": false, &"message": "Payroll settlement row budget is exceeded."}
var input_payments: Array[Dictionary] = []
var payment_index: Dictionary = {}
var previous_payment_id := ""
for raw_payment: Variant in record[&"input_payments"]:
if not raw_payment is Dictionary or not _dictionary_has_exact_keys(raw_payment, [&"staff_id", &"amount"]):
return {&"ok": false, &"message": "Payroll input payment row is malformed."}
if typeof(raw_payment[&"staff_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {&"ok": false, &"message": "Payroll input payment Staff ID is malformed."}
var staff_id := StringName(raw_payment[&"staff_id"])
var staff_text := String(staff_id)
var raw_amount: Variant = raw_payment[&"amount"]
if (
staff_id.is_empty()
or (not previous_payment_id.is_empty() and staff_text <= previous_payment_id)
or typeof(raw_amount) != TYPE_INT
or int(raw_amount) < 0
or int(raw_amount) > MAX_PAYROLL_AMOUNT
):
return {&"ok": false, &"message": "Payroll input payments are invalid or unordered."}
input_payments.append({&"staff_id": staff_id, &"amount": int(raw_amount)})
payment_index[staff_id] = int(raw_amount)
previous_payment_id = staff_text
var rows: Array[Dictionary] = []
var row_ids: Dictionary = {}
var previous_row_id := ""
var derived_new := 0
var derived_prior := 0
var derived_due := 0
var derived_paid := 0
var derived_unpaid := 0
for raw_row: Variant in record[&"rows"]:
if not raw_row is Dictionary or not _dictionary_has_exact_keys(raw_row, [
&"staff_id", &"payroll_status", &"employment_kind", &"daily_wage",
&"prior_arrears", &"new_wages", &"total_due", &"payment", &"unpaid_after",
]):
return {&"ok": false, &"message": "Payroll settlement result row is malformed."}
if typeof(raw_row[&"staff_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {&"ok": false, &"message": "Payroll settlement row Staff ID is malformed."}
var staff_id := StringName(raw_row[&"staff_id"])
var staff_text := String(staff_id)
if staff_id.is_empty() or (not previous_row_id.is_empty() and staff_text <= previous_row_id):
return {&"ok": false, &"message": "Payroll settlement rows are duplicated or unordered."}
var status_value: Variant = raw_row[&"payroll_status"]
var kind_value: Variant = raw_row[&"employment_kind"]
if typeof(status_value) not in [TYPE_STRING, TYPE_STRING_NAME] or typeof(kind_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {&"ok": false, &"message": "Payroll settlement row labels are malformed."}
var status := StringName(status_value)
var employment_kind := StringName(kind_value)
if status not in [&"ACTIVE", &"DEPARTED"]:
return {&"ok": false, &"message": "Payroll settlement row status is invalid."}
if status == &"ACTIVE" and StaffRuntimeType.EmploymentKind.keys().find(String(employment_kind)) < 0:
return {&"ok": false, &"message": "Active payroll employment kind is invalid."}
if status == &"DEPARTED" and not employment_kind.is_empty():
return {&"ok": false, &"message": "Departed payroll employment kind must be empty."}
var numeric_keys: Array[StringName] = [
&"daily_wage", &"prior_arrears", &"new_wages", &"total_due", &"payment", &"unpaid_after",
]
for numeric_key: StringName in numeric_keys:
var raw_number: Variant = raw_row[numeric_key]
if typeof(raw_number) != TYPE_INT or int(raw_number) < 0 or int(raw_number) > MAX_PAYROLL_AMOUNT:
return {&"ok": false, &"message": "Payroll settlement row amount is invalid."}
var daily_wage := int(raw_row[&"daily_wage"])
var prior := int(raw_row[&"prior_arrears"])
var newly_accrued := int(raw_row[&"new_wages"])
var due := int(raw_row[&"total_due"])
var payment := int(raw_row[&"payment"])
var unpaid_after := int(raw_row[&"unpaid_after"])
if (
due != prior + newly_accrued
or payment > due
or unpaid_after != due - payment
or (status == &"ACTIVE" and newly_accrued != daily_wage)
or (status == &"DEPARTED" and (daily_wage != 0 or newly_accrued != 0))
or payment != int(payment_index.get(staff_id, 0))
):
return {&"ok": false, &"message": "Payroll settlement row derivation is invalid."}
var row: Dictionary = (raw_row as Dictionary).duplicate(true)
row[&"staff_id"] = staff_id
row[&"payroll_status"] = status
row[&"employment_kind"] = employment_kind
rows.append(row)
row_ids[staff_id] = true
previous_row_id = staff_text
derived_new += newly_accrued
derived_prior += prior
derived_due += due
derived_paid += payment
derived_unpaid += unpaid_after
if (
derived_new > MAX_PAYROLL_AMOUNT
or derived_prior > MAX_PAYROLL_AMOUNT
or derived_due > MAX_PAYROLL_AMOUNT
or derived_paid > MAX_PAYROLL_AMOUNT
or derived_unpaid > MAX_PAYROLL_AMOUNT
):
return {&"ok": false, &"message": "Payroll settlement derived total is out of range."}
for payment: Dictionary in input_payments:
if not row_ids.has(payment[&"staff_id"]):
return {&"ok": false, &"message": "Payroll input payment references an unknown result row."}
var totals := {
&"new_wages_total": derived_new,
&"prior_arrears_total": derived_prior,
&"total_due": derived_due,
&"paid_total": derived_paid,
&"unpaid_after_total": derived_unpaid,
}
for total_key: StringName in totals:
if typeof(record[total_key]) != TYPE_INT or int(record[total_key]) != int(totals[total_key]):
return {&"ok": false, &"message": "Payroll settlement total does not match its rows."}
var expected_fingerprint := _payroll_fingerprint(day, input_payments)
if typeof(record[&"fingerprint"]) != TYPE_STRING or String(record[&"fingerprint"]) != expected_fingerprint:
return {&"ok": false, &"message": "Payroll settlement fingerprint is invalid."}
var parsed := {
&"settlement_id": settlement_id,
&"day": day,
&"fingerprint": expected_fingerprint,
&"input_payments": input_payments,
&"rows": rows,
&"new_wages_total": derived_new,
&"prior_arrears_total": derived_prior,
&"total_due": derived_due,
&"paid_total": derived_paid,
&"unpaid_after_total": derived_unpaid,
}
settlements[settlement_id] = parsed
day_index[day] = settlement_id
previous_day = day
return {&"ok": true, &"settlements": settlements, &"day_index": day_index}
static func _validate_payroll_history(settlements: Dictionary) -> Dictionary:
var records: Array[Dictionary] = []
for settlement_id: StringName in _sorted_keys(settlements):
var value: Variant = settlements[settlement_id]
if not value is Dictionary:
return {&"ok": false, &"errors": ["Payroll settlement record is malformed."], &"day_index": {}}
records.append(value)
records.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
if int(left.get(&"day", -1)) != int(right.get(&"day", -1)):
return int(left.get(&"day", -1)) < int(right.get(&"day", -1))
return String(left.get(&"settlement_id", "")) < String(right.get(&"settlement_id", ""))
)
var parsed := _parse_payroll_history_snapshot(records)
if not bool(parsed.get(&"ok", false)):
return {
&"ok": false,
&"errors": [String(parsed.get(&"message", "Payroll settlement history is invalid."))],
&"day_index": {},
}
return {&"ok": true, &"errors": [], &"day_index": parsed[&"day_index"]}
static func _dictionary_has_exact_keys(source: Dictionary, expected: Array) -> bool:
if source.size() != expected.size():
return false
for key: Variant in expected:
if not source.has(key):
return false
return true
static func _validate_candidate_invariants(
staff_by_id: Dictionary,
tasks_by_id: Dictionary,
shifts_by_id: Dictionary,
departed_payroll: Dictionary = {}
) -> Dictionary:
var errors: Array[String] = []
var reservation_owners: Dictionary = {}
for task_id: StringName in _sorted_keys(tasks_by_id):
var task: StaffTask = tasks_by_id[task_id]
if not task.is_valid():
errors.append("Task %s is invalid." % task_id)
continue
for reservation_id: StringName in task.reservation_ids:
if reservation_owners.has(reservation_id):
errors.append(
"Reservation %s is claimed by Tasks %s and %s."
% [reservation_id, reservation_owners[reservation_id], task_id]
)
else:
reservation_owners[reservation_id] = task_id
if task.assigned_staff_id.is_empty():
continue
var staff: StaffRuntime = staff_by_id.get(task.assigned_staff_id) as StaffRuntime
if staff == null:
errors.append("Task %s references missing Staff." % task_id)
elif staff.current_task_id != task.task_id:
errors.append("Task %s and Staff %s do not reference each other." % [task_id, staff.staff_id])
for staff_id: StringName in _sorted_keys(staff_by_id):
var staff: StaffRuntime = staff_by_id[staff_id]
if not staff.is_valid():
errors.append("Staff %s is invalid." % staff_id)
elif staff.daily_wage > MAX_PAYROLL_AMOUNT or staff.unpaid_wages > MAX_PAYROLL_AMOUNT:
errors.append("Staff %s payroll amount exceeds the service budget." % staff_id)
if not staff.current_task_id.is_empty():
var task: StaffTask = tasks_by_id.get(staff.current_task_id) as StaffTask
if task == null or task.assigned_staff_id != staff.staff_id:
errors.append("Staff %s references an orphan Task." % staff_id)
if not staff.shift_id.is_empty():
var shift: ShiftInstance = shifts_by_id.get(staff.shift_id) as ShiftInstance
if shift == null or shift.staff_id != staff.staff_id:
errors.append("Staff %s references an invalid Shift." % staff_id)
for shift_id: StringName in _sorted_keys(shifts_by_id):
var shift: ShiftInstance = shifts_by_id[shift_id]
if not shift.is_valid():
errors.append("Shift %s is invalid." % shift_id)
elif not staff_by_id.has(shift.staff_id):
errors.append("Shift %s references missing Staff." % shift_id)
for departed_staff_id: StringName in _sorted_keys(departed_payroll):
var raw_amount: Variant = departed_payroll[departed_staff_id]
if (
departed_staff_id.is_empty()
or typeof(raw_amount) != TYPE_INT
or int(raw_amount) < 0
or int(raw_amount) > MAX_PAYROLL_AMOUNT
):
errors.append("Departed payroll %s is invalid." % departed_staff_id)
if staff_by_id.has(departed_staff_id):
errors.append("Staff %s exists in both active and departed payroll." % departed_staff_id)
var shift_ids := _sorted_keys(shifts_by_id)
for left_index: int in range(shift_ids.size()):
var left: ShiftInstance = shifts_by_id[shift_ids[left_index]]
for right_index: int in range(left_index + 1, shift_ids.size()):
var right: ShiftInstance = shifts_by_id[shift_ids[right_index]]
if left.staff_id == right.staff_id and _shifts_overlap(left, right):
errors.append(
"Shifts %s and %s overlap for Staff %s."
% [left.shift_instance_id, right.shift_instance_id, left.staff_id]
)
return {&"ok": errors.is_empty(), &"errors": errors}
## Only bidirectional Staff/Task orphan links are recoverable. Invalid objects,
## Shift ownership, missing Shift staff, and overlaps remain hard corruption and
## are rejected before any external Reservation release is attempted.
static func _validate_recovery_candidate_scope(
staff_by_id: Dictionary,
tasks_by_id: Dictionary,
shifts_by_id: Dictionary
) -> Dictionary:
var errors: Array[String] = []
var reservation_owners: Dictionary = {}
for task_id: StringName in _sorted_keys(tasks_by_id):
var task: StaffTask = tasks_by_id[task_id]
if not task.is_valid():
errors.append("Task %s is invalid." % task_id)
continue
for reservation_id: StringName in task.reservation_ids:
if reservation_owners.has(reservation_id):
errors.append(
"Reservation %s is claimed by Tasks %s and %s."
% [reservation_id, reservation_owners[reservation_id], task_id]
)
else:
reservation_owners[reservation_id] = task_id
for staff_id: StringName in _sorted_keys(staff_by_id):
var staff: StaffRuntime = staff_by_id[staff_id]
if not staff.is_valid():
errors.append("Staff %s is invalid." % staff_id)
if not staff.shift_id.is_empty():
var shift: ShiftInstance = shifts_by_id.get(staff.shift_id) as ShiftInstance
if shift == null or shift.staff_id != staff.staff_id:
errors.append("Staff %s references an invalid Shift." % staff_id)
for shift_id: StringName in _sorted_keys(shifts_by_id):
var shift: ShiftInstance = shifts_by_id[shift_id]
if not shift.is_valid():
errors.append("Shift %s is invalid." % shift_id)
elif not staff_by_id.has(shift.staff_id):
errors.append("Shift %s references missing Staff." % shift_id)
var shift_ids := _sorted_keys(shifts_by_id)
for left_index: int in range(shift_ids.size()):
var left: ShiftInstance = shifts_by_id[shift_ids[left_index]]
for right_index: int in range(left_index + 1, shift_ids.size()):
var right: ShiftInstance = shifts_by_id[shift_ids[right_index]]
if left.staff_id == right.staff_id and _shifts_overlap(left, right):
errors.append(
"Shifts %s and %s overlap for Staff %s."
% [left.shift_instance_id, right.shift_instance_id, left.staff_id]
)
return {&"ok": errors.is_empty(), &"errors": errors}
static func _override_key(staff_id: StringName, start_day: int) -> StringName:
return StringName("%s@%d" % [staff_id, start_day])
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
var result: Array[StringName] = []
for value: Variant in source.keys():
result.append(StringName(value))
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return result
func _success(details: Dictionary = {}) -> CommandResult:
last_error = ""
return CommandResultType.succeeded(0, details)
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
last_error = message
return CommandResultType.failed(CommandResultType.Code.VALIDATION_FAILED, 0, message, details)
func _not_found(label: String, entity_id: StringName) -> CommandResult:
var message := "%s was not found: %s" % [label, entity_id]
last_error = message
return CommandResultType.failed(
CommandResultType.Code.NOT_FOUND,
0,
message,
{&"entity_id": entity_id}
)
func _duplicate(label: String, entity_id: StringName) -> CommandResult:
var message := "%s ID already exists: %s" % [label, entity_id]
last_error = message
return CommandResultType.failed(
CommandResultType.Code.DUPLICATE_COMMAND_ID,
0,
message,
{&"entity_id": entity_id}
)
func _conflict(message: String) -> CommandResult:
last_error = message
return CommandResultType.failed(CommandResultType.Code.TRANSACTION_APPLY_FAILED, 0, message)