P16-01-DISMISS: 一般従業員の通常操作での解雇(退職金を含む。P16-01 の補助の作業。原単位への加算 0)
This commit is contained in:
@@ -336,6 +336,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
|
||||
register_command_executor(&"decide_guest", Callable(self, "_execute_decide_guest"))
|
||||
register_command_executor(&"register_staff", Callable(self, "_execute_register_staff"))
|
||||
register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate"))
|
||||
register_command_executor(&"dismiss_staff", Callable(self, "_execute_dismiss_staff"))
|
||||
register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift"))
|
||||
register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override"))
|
||||
register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment"))
|
||||
@@ -2730,6 +2731,7 @@ func _build_operations_daily_financials(
|
||||
&"AUTHORITY_FINE": &"fines",
|
||||
&"OPERATING_EXPENSE": &"cost_of_goods",
|
||||
&"STAFF_SIGNING_COST": &"cost_of_goods",
|
||||
FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods",
|
||||
&"EVENT_ACTION": &"cost_of_goods",
|
||||
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
||||
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
||||
@@ -3410,11 +3412,15 @@ func _parse_operations_runtime_snapshot(
|
||||
var terminal_shape := deferred_runtime_minute == -1 \
|
||||
and not failure_latched and restored_coordinator.is_terminal() \
|
||||
and coordinator_current_matches and bool(restored_clock_paused)
|
||||
# The closed arrears are counted as the live check counts them
|
||||
# (_operations_closed_arrears_total): wages, upkeep and the unpaid rank tax and interest.
|
||||
var last_close := restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1)
|
||||
if not bool(restored_coordinator.validate_invariants().get(&"ok", false)) \
|
||||
or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \
|
||||
or not (normal_shape or failure_shape or pause_shape or terminal_shape) \
|
||||
or restored_coordinator.get_population_count() != restored_guests.guest_count() \
|
||||
or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1))) \
|
||||
or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(last_close)
|
||||
+ _finance_arrears_through(restored_economy, last_close)) \
|
||||
or not _operations_payroll_crosslinks_are_valid(
|
||||
restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement
|
||||
) \
|
||||
@@ -3954,6 +3960,7 @@ func _operations_expected_category_totals(
|
||||
&"CONSTRUCTION_REFUND": &"construction_cash_flow",
|
||||
&"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods",
|
||||
&"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods",
|
||||
FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods",
|
||||
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
||||
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
||||
&"MAINTENANCE_ACCRUAL": &"maintenance",
|
||||
@@ -6925,6 +6932,203 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
|
||||
_runtime_event_minute()
|
||||
)
|
||||
|
||||
|
||||
## P16-01-DISMISS: whether a hired employee can be dismissed now, with the severance
|
||||
## (A14) and the Task the removal would reopen. Nothing is changed.
|
||||
func get_staff_dismissal_preview(staff_id: StringName) -> Dictionary:
|
||||
var staff: StaffRuntime = staff_service.get_staff(staff_id) if staff_service != null else null
|
||||
var preview := {
|
||||
&"staff_id": staff_id, &"can_dismiss": false, &"reason": &"NOT_FOUND",
|
||||
&"severance": 0, &"unpaid_wages": 0, &"reopened_task_ids": [], &"reopened_task_types": [],
|
||||
&"available_funds": FinancePolicy.available_funds(economy_service) if economy_service != null else 0,
|
||||
&"front_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT) if economy_service != null else 0,
|
||||
}
|
||||
if staff == null:
|
||||
return preview
|
||||
var severance := FinancePolicy.severance(staff.get_effective_daily_wage())
|
||||
preview[&"severance"] = severance
|
||||
preview[&"unpaid_wages"] = staff.unpaid_wages
|
||||
var task := staff_service.get_task(staff.current_task_id)
|
||||
if task != null and not task.is_terminal():
|
||||
preview[&"reopened_task_ids"] = [task.task_id]
|
||||
preview[&"reopened_task_types"] = [task.task_type]
|
||||
var reason := _staff_dismissal_block_reason(staff, severance)
|
||||
preview[&"reason"] = reason
|
||||
preview[&"can_dismiss"] = reason.is_empty()
|
||||
return preview
|
||||
|
||||
|
||||
## Every refusal of SPEC 6.3 except the Task Reservation release (R3), which only the
|
||||
## existing removal contract can decide and which then changes nothing.
|
||||
func _staff_dismissal_block_reason(staff: StaffRuntime, severance: int) -> StringName:
|
||||
if staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE:
|
||||
return &"UNIQUE"
|
||||
if staff.employment_kind != StaffRuntime.EmploymentKind.FREE_HIRE:
|
||||
return &"NOT_GENERAL_EMPLOYEE"
|
||||
if staff_service.is_externally_driven_task(staff.current_task_id):
|
||||
return &"EXTERNAL_TASK"
|
||||
# Root's temporary boundary R9: an unfinished raid's defender stays until the raid ends.
|
||||
if not _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty():
|
||||
return &"DEFENSE_LOCKED"
|
||||
# A rescue pair aboard an elevator stays together until a real landing (the
|
||||
# existing rule), so either member is dismissed after that landing.
|
||||
var trip := elevators.get_actor_trip(_staff_path_actor_id(staff.staff_id))
|
||||
if trip.get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED \
|
||||
and not StringName(trip.get(&"companion_id", &"")).is_empty():
|
||||
return &"RESCUE_PAIR_ABOARD"
|
||||
if severance > FinancePolicy.available_funds(economy_service):
|
||||
return &"INSUFFICIENT_FUNDS"
|
||||
return &""
|
||||
|
||||
|
||||
## Dismissal of a general employee (FREE_HIRE). All refusals are decided before any
|
||||
## change (SPEC 4.1 c). The existing removal contract, the severance paid from the front
|
||||
## fund with the hidden fund covering the rest (one posting, A14) and the cleanup of
|
||||
## SPEC 6.2 then commit as one operation and are rolled back together on a failure.
|
||||
func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
|
||||
var raw_staff_id: Variant = command.payload.get(&"staff_id", command.payload.get("staff_id", null))
|
||||
if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw_staff_id).is_empty():
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.INVALID_PAYLOAD, command.command_id, "A Staff id is required."
|
||||
)
|
||||
var staff_id := StringName(raw_staff_id)
|
||||
var staff := staff_service.get_staff(staff_id)
|
||||
if staff == null:
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.NOT_FOUND, command.command_id,
|
||||
"Staff was not found: %s" % staff_id, {&"reason": &"NOT_FOUND", &"staff_id": staff_id}
|
||||
)
|
||||
var severance := FinancePolicy.severance(staff.get_effective_daily_wage())
|
||||
var reason := _staff_dismissal_block_reason(staff, severance)
|
||||
if not reason.is_empty():
|
||||
# R1 and R2 keep the existing removal contract's code and text.
|
||||
var refusals := {
|
||||
&"UNIQUE": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Unique Staff cannot be dismissed, sold or permanently removed."],
|
||||
&"EXTERNAL_TASK": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Finish the employee external operation before removal."],
|
||||
&"NOT_GENERAL_EMPLOYEE": [CommandResult.Code.VALIDATION_FAILED, "Only a general employee can be dismissed."],
|
||||
&"DEFENSE_LOCKED": [CommandResult.Code.VALIDATION_FAILED, "Staff is defending an unfinished raid."],
|
||||
&"RESCUE_PAIR_ABOARD": [CommandResult.Code.VALIDATION_FAILED, "A rescue pair aboard an elevator must reach a landing first."],
|
||||
&"INSUFFICIENT_FUNDS": [CommandResult.Code.VALIDATION_FAILED, "Front and hidden funds cannot pay the severance."],
|
||||
}
|
||||
return CommandResult.failed(refusals[reason][0], command.command_id, refusals[reason][1], {
|
||||
&"reason": reason, &"staff_id": staff_id, &"severance": severance,
|
||||
&"available_funds": FinancePolicy.available_funds(economy_service),
|
||||
})
|
||||
var minute := _runtime_event_minute()
|
||||
var before := {
|
||||
&"economy": economy_service.create_snapshot(),
|
||||
&"game_state": _state.create_snapshot(),
|
||||
&"staff": staff_service.create_snapshot(),
|
||||
&"inventory": inventory_service.create_snapshot(),
|
||||
&"path": path_service.create_snapshot(),
|
||||
&"elevators": elevators.create_snapshot(),
|
||||
&"movement_credits": _movement_credits.duplicate(true),
|
||||
&"domain_events": domain_events.create_snapshot(),
|
||||
&"id_generator": id_generator.create_snapshot(),
|
||||
}
|
||||
var held_task_id := staff.current_task_id
|
||||
# The existing contract refuses an unreleasable Task Reservation (R3) before it
|
||||
# changes anything; the Inventory release rolls itself back.
|
||||
var removal := staff_service.remove_staff(staff_id, &"DISMISSED", true, minute)
|
||||
if not removal.is_success():
|
||||
removal.command_id = command.command_id
|
||||
removal.details[&"reason"] = &"RESERVATION_RELEASE_FAILED"
|
||||
removal.details[&"staff_id"] = staff_id
|
||||
return removal
|
||||
var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
|
||||
var top_up := maxi(severance - cash, 0)
|
||||
var transaction_id: StringName = &""
|
||||
if severance > 0:
|
||||
var entries: Array = []
|
||||
if top_up > 0:
|
||||
entries.append(LedgerEntry.new().configure(&"", minute, PLAYER_CASH_ACCOUNT, FinancePolicy.BACK_CASH,
|
||||
top_up, FinancePolicy.TOP_UP_CATEGORY, staff_id, &"", &"finance.back_fund.top_up"))
|
||||
entries.append(LedgerEntry.new().configure(&"", minute, FinancePolicy.SEVERANCE_EXPENSE, PLAYER_CASH_ACCOUNT,
|
||||
severance, FinancePolicy.SEVERANCE_CATEGORY, staff_id, &"", &"staff.severance"))
|
||||
economy_service.begin_notification_scope()
|
||||
var paid := economy_service.post(entries, StringName("staff.severance.%d" % command.command_id))
|
||||
if paid.is_success():
|
||||
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
||||
transaction_id = StringName(paid.details.get(&"transaction_id", &""))
|
||||
economy_service.end_notification_scope()
|
||||
if not paid.is_success():
|
||||
_rollback_staff_dismissal(before)
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id,
|
||||
"Staff dismissal could not commit its severance with the removal.",
|
||||
{&"reason": &"SEVERANCE_POST_FAILED", &"staff_id": staff_id, &"post": paid.to_dictionary()}
|
||||
)
|
||||
var interrupted: Variant = _interrupt_dismissed_staff_rescue(staff_id, held_task_id, minute)
|
||||
if interrupted == null:
|
||||
_rollback_staff_dismissal(before)
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id,
|
||||
"Staff dismissal could not interrupt the rescue it leaves.",
|
||||
{&"reason": &"RESCUE_CLEANUP_FAILED", &"staff_id": staff_id}
|
||||
)
|
||||
# SPEC 6.2 / 6.2.1: the facility projection and the Events' missing actors are
|
||||
# settled at the command boundary by the existing rules, not only at the next minute.
|
||||
_sync_facility_staffing(minute)
|
||||
event_service.audit_actor_availability(minute)
|
||||
return CommandResult.succeeded(command.command_id, {
|
||||
&"staff_id": staff_id,
|
||||
&"severance": severance,
|
||||
&"severance_top_up": top_up,
|
||||
&"severance_transaction_id": transaction_id,
|
||||
&"unpaid_wages_preserved": int(removal.details.get(&"unpaid_wages_preserved", 0)),
|
||||
&"removed_shift_ids": removal.details.get(&"removed_shift_ids", []),
|
||||
&"removed_override_days": removal.details.get(&"removed_override_days", []),
|
||||
&"reopened_task_ids": [held_task_id] if not held_task_id.is_empty() else [],
|
||||
&"interrupted_rescue_task_ids": interrupted,
|
||||
&"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
||||
&"back_cash": economy_service.get_balance(FinancePolicy.BACK_CASH),
|
||||
})
|
||||
|
||||
|
||||
## SPEC 6.2.1: no live rescue Task keeps the dismissed employee as its patient or
|
||||
## carrier. The next minute's rule (_advance_staff_rescue) is applied to those Tasks
|
||||
## now: each is interrupted and its elevator requests are cancelled, except that a
|
||||
## carrier already aboard rides on to a real landing. A pickup whose carrier was
|
||||
## dismissed was reopened by the removal and stays open for another medic, as that
|
||||
## rule keeps it. Returns null if a Task could not be interrupted.
|
||||
func _interrupt_dismissed_staff_rescue(staff_id: StringName, held_task_id: StringName, minute: int) -> Variant:
|
||||
var interrupted: Array[StringName] = []
|
||||
for task: StaffTask in staff_service.get_tasks():
|
||||
if task.task_type not in [&"rescue_pickup", &"rescue_transport"] or task.is_terminal():
|
||||
continue
|
||||
var carried := task.task_id == held_task_id and task.task_type == &"rescue_transport"
|
||||
if task.target_entity_id != staff_id and not carried:
|
||||
continue
|
||||
var patient := staff_service.get_staff(task.target_entity_id)
|
||||
var carrier := staff_service.get_staff(task.assigned_staff_id)
|
||||
var aboard: bool = carrier != null and elevators.get_actor_trip(_staff_path_actor_id(carrier.staff_id)).get(&"state", &"") \
|
||||
== PathService.ELEVATOR_REQUEST_BOARDED
|
||||
if not staff_service.cancel_task(task.task_id, &"RESCUE_INTERRUPTED", minute).is_success():
|
||||
return null
|
||||
interrupted.append(task.task_id)
|
||||
if patient != null: elevators.cancel_actor(_staff_path_actor_id(patient.staff_id))
|
||||
if carrier != null and not aboard: elevators.cancel_actor(_staff_path_actor_id(carrier.staff_id))
|
||||
return interrupted
|
||||
|
||||
|
||||
func _rollback_staff_dismissal(snapshot: Dictionary) -> bool:
|
||||
var restored := economy_service.restore_snapshot(snapshot[&"economy"])
|
||||
restored = _state.restore_snapshot(snapshot[&"game_state"]) and restored
|
||||
restored = staff_service.restore_snapshot(snapshot[&"staff"]) and restored
|
||||
restored = inventory_service.restore_snapshot(snapshot[&"inventory"]) and restored
|
||||
restored = path_service.restore_snapshot(snapshot[&"path"]) and restored
|
||||
restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored
|
||||
_movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true)
|
||||
restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored
|
||||
restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored
|
||||
return restored and _validate_runtime_path_crosslinks(
|
||||
path_service,
|
||||
guest_service,
|
||||
staff_service,
|
||||
_movement_credits,
|
||||
_runtime_event_minute()
|
||||
)
|
||||
|
||||
func _staff_role_matches_facility(role_id: StringName, definition: FacilityDefinition) -> bool:
|
||||
if definition.required_roles.is_empty() or role_id in definition.required_roles:
|
||||
return true
|
||||
@@ -11316,6 +11520,7 @@ func get_staff_management_report() -> Dictionary:
|
||||
&"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""),
|
||||
&"can_edit": block_reason.is_empty(),
|
||||
&"assignment_block_reason": block_reason,
|
||||
&"dismissal": get_staff_dismissal_preview(staff.staff_id),
|
||||
&"unique_contract": _staff_unique_contract_report(staff),
|
||||
&"original_relationship": relationship_service.get_original_relationship(staff.staff_id),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user