1977 lines
79 KiB
GDScript
1977 lines
79 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()
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
) -> 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 != 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:
|
|
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
|
|
) -> CommandResult:
|
|
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)
|
|
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)
|
|
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)
|