scriptplan 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scriptplan/__init__.py +22 -0
- scriptplan/cli/__init__.py +7 -0
- scriptplan/cli/main.py +546 -0
- scriptplan/core/__init__.py +0 -0
- scriptplan/core/account.py +125 -0
- scriptplan/core/allocation.py +69 -0
- scriptplan/core/booking.py +39 -0
- scriptplan/core/journal.py +377 -0
- scriptplan/core/leave.py +14 -0
- scriptplan/core/limits.py +354 -0
- scriptplan/core/project.py +924 -0
- scriptplan/core/property.py +1290 -0
- scriptplan/core/resource.py +198 -0
- scriptplan/core/resource_scenario.py +711 -0
- scriptplan/core/scenario.py +5 -0
- scriptplan/core/scenario_data.py +39 -0
- scriptplan/core/shift.py +71 -0
- scriptplan/core/task.py +77 -0
- scriptplan/core/task_scenario.py +1515 -0
- scriptplan/core/timesheet.py +457 -0
- scriptplan/core/working_hours.py +231 -0
- scriptplan/parser/__init__.py +0 -0
- scriptplan/parser/macro_processor.py +264 -0
- scriptplan/parser/tjp.lark +412 -0
- scriptplan/parser/tjp_parser.py +1904 -0
- scriptplan/py.typed +0 -0
- scriptplan/report/__init__.py +75 -0
- scriptplan/report/html_generator.py +477 -0
- scriptplan/report/report.py +466 -0
- scriptplan/report/report_base.py +397 -0
- scriptplan/report/report_context.py +248 -0
- scriptplan/report/resource_report.py +341 -0
- scriptplan/report/table_report.py +693 -0
- scriptplan/report/task_report.py +362 -0
- scriptplan/report/text_report.py +172 -0
- scriptplan/scheduler/__init__.py +0 -0
- scriptplan/scheduler/batch_processor.py +238 -0
- scriptplan/scheduler/scoreboard.py +120 -0
- scriptplan/utils/__init__.py +0 -0
- scriptplan/utils/data_cache.py +46 -0
- scriptplan/utils/logger.py +243 -0
- scriptplan/utils/message_handler.py +515 -0
- scriptplan/utils/time.py +195 -0
- scriptplan-0.9.0.dist-info/METADATA +161 -0
- scriptplan-0.9.0.dist-info/RECORD +49 -0
- scriptplan-0.9.0.dist-info/WHEEL +5 -0
- scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
- scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
- scriptplan-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ResourceScenario - Scenario-specific data for resources.
|
|
3
|
+
|
|
4
|
+
This module implements the ResourceScenario class which holds all
|
|
5
|
+
scenario-specific data for a Resource.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Optional, List, Any, Dict, Callable
|
|
9
|
+
|
|
10
|
+
from scriptplan.core.scenario_data import ScenarioData
|
|
11
|
+
from scriptplan.scheduler.scoreboard import Scoreboard
|
|
12
|
+
from scriptplan.utils.data_cache import DataCache
|
|
13
|
+
from scriptplan.core.leave import Leave
|
|
14
|
+
from scriptplan.core.booking import Booking
|
|
15
|
+
from scriptplan.utils.time import TimeInterval
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from scriptplan.core.resource import Resource
|
|
19
|
+
from scriptplan.core.task import Task
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ResourceScenario(ScenarioData):
|
|
23
|
+
"""
|
|
24
|
+
Scenario-specific data for a Resource.
|
|
25
|
+
|
|
26
|
+
This class holds all scenario-specific attributes and methods for resources,
|
|
27
|
+
including scoreboard management, booking, and effort tracking.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, resource: 'Resource', scenario_idx: int, attributes: Any):
|
|
31
|
+
"""
|
|
32
|
+
Initialize ResourceScenario.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
resource: The parent Resource
|
|
36
|
+
scenario_idx: The scenario index
|
|
37
|
+
attributes: Attribute definitions
|
|
38
|
+
"""
|
|
39
|
+
super().__init__(resource, scenario_idx, attributes)
|
|
40
|
+
|
|
41
|
+
# Scoreboard may be nil, a Task, or a bit vector encoded as an Integer
|
|
42
|
+
# nil: Value has not been determined yet.
|
|
43
|
+
# Task: A reference to a Task object
|
|
44
|
+
# Bit 0: Reserved
|
|
45
|
+
# Bit 1: 0: Work time (as defined by working hours)
|
|
46
|
+
# 1: No work time (as defined by working hours)
|
|
47
|
+
# Bit 2 - 5: See Leave class for actual values.
|
|
48
|
+
# Bit 6 - 7: Reserved
|
|
49
|
+
# Bit 8: 0: No global override
|
|
50
|
+
# 1: Override global setting
|
|
51
|
+
self.scoreboard: Optional[Scoreboard] = None
|
|
52
|
+
|
|
53
|
+
# The index of the earliest booked time slot
|
|
54
|
+
self.firstBookedSlot: Optional[int] = None
|
|
55
|
+
# Same but for each assigned resource
|
|
56
|
+
self.firstBookedSlots: Dict[Any, int] = {}
|
|
57
|
+
# The index of the last booked time slot
|
|
58
|
+
self.lastBookedSlot: Optional[int] = None
|
|
59
|
+
# Same but for each assigned resource
|
|
60
|
+
self.lastBookedSlots: Dict[Any, int] = {}
|
|
61
|
+
|
|
62
|
+
# First available slot of the resource
|
|
63
|
+
self.minslot: Optional[int] = None
|
|
64
|
+
# Last available slot of the resource
|
|
65
|
+
self.maxslot: Optional[int] = None
|
|
66
|
+
|
|
67
|
+
# Internal effort counter
|
|
68
|
+
self._effort = 0
|
|
69
|
+
|
|
70
|
+
# Track partial slot usage: slot_idx -> seconds_used
|
|
71
|
+
# When a task ends mid-slot, this records how much of the slot was used
|
|
72
|
+
# Subsequent tasks can use the remaining time in that slot
|
|
73
|
+
self.slotSecondsUsed: Dict[int, float] = {}
|
|
74
|
+
|
|
75
|
+
# Track which tasks used which slots and how much
|
|
76
|
+
# slot_idx -> list of (task, seconds_used)
|
|
77
|
+
# This allows multiple tasks to share a slot
|
|
78
|
+
self.slotTaskUsage: Dict[int, list] = {}
|
|
79
|
+
|
|
80
|
+
# Data cache
|
|
81
|
+
self.dCache = DataCache.instance()
|
|
82
|
+
|
|
83
|
+
# Ensure required attributes exist
|
|
84
|
+
required_attrs = [
|
|
85
|
+
'alloctdeffort', 'chargeset', 'criticalness', 'directreports',
|
|
86
|
+
'duties', 'efficiency', 'effort', 'limits', 'managers', 'rate',
|
|
87
|
+
'reports', 'shifts', 'leaves', 'leaveallowances', 'workinghours'
|
|
88
|
+
]
|
|
89
|
+
for attr in required_attrs:
|
|
90
|
+
try:
|
|
91
|
+
_ = self.property.get(attr, self.scenarioIdx)
|
|
92
|
+
except (ValueError, KeyError, AttributeError):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def prepareScheduling(self) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Initialize variables used during the scheduling process.
|
|
98
|
+
|
|
99
|
+
This method must be called at the beginning of each scheduling run.
|
|
100
|
+
"""
|
|
101
|
+
self._effort = 0
|
|
102
|
+
if self.property.leaf():
|
|
103
|
+
self.initScoreboard()
|
|
104
|
+
|
|
105
|
+
def initScoreboard(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Initialize the scoreboard for this resource.
|
|
108
|
+
|
|
109
|
+
The scoreboard tracks the availability and bookings for each time slot.
|
|
110
|
+
"""
|
|
111
|
+
start = self.project.attributes.get('start')
|
|
112
|
+
end = self.project.attributes.get('end')
|
|
113
|
+
granularity = self.project.attributes.get('scheduleGranularity', 3600)
|
|
114
|
+
|
|
115
|
+
if not start or not end:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
self.scoreboard = Scoreboard(start, end, granularity, 2)
|
|
119
|
+
size = self.project.scoreboardSize()
|
|
120
|
+
|
|
121
|
+
# Initialize working hours
|
|
122
|
+
for i in range(size):
|
|
123
|
+
if not self.onShift(i):
|
|
124
|
+
# Mark as non-working time (bit 1 set)
|
|
125
|
+
self.scoreboard[i] = 2
|
|
126
|
+
else:
|
|
127
|
+
self.scoreboard[i] = None
|
|
128
|
+
|
|
129
|
+
# Apply global leaves
|
|
130
|
+
leaves = self.project.attributes.get('leaves', [])
|
|
131
|
+
if leaves:
|
|
132
|
+
for leave in leaves:
|
|
133
|
+
if hasattr(leave, 'interval'):
|
|
134
|
+
start_idx = self.project.dateToIdx(leave.interval.start)
|
|
135
|
+
end_idx = self.project.dateToIdx(leave.interval.end)
|
|
136
|
+
for i in range(start_idx, min(end_idx, size)):
|
|
137
|
+
sb = self.scoreboard[i]
|
|
138
|
+
val = 0 if sb is None else (sb & 2)
|
|
139
|
+
leave_type = leave.type_idx if hasattr(leave, 'type_idx') else 0
|
|
140
|
+
self.scoreboard[i] = val | (leave_type << 2)
|
|
141
|
+
|
|
142
|
+
# Apply resource-specific leaves
|
|
143
|
+
res_leaves = self.property.get('leaves', self.scenarioIdx)
|
|
144
|
+
if res_leaves:
|
|
145
|
+
for leave in res_leaves:
|
|
146
|
+
if hasattr(leave, 'interval'):
|
|
147
|
+
start_idx = self.project.dateToIdx(leave.interval.start)
|
|
148
|
+
end_idx = self.project.dateToIdx(leave.interval.end)
|
|
149
|
+
for i in range(start_idx, min(end_idx, size)):
|
|
150
|
+
sb = self.scoreboard[i]
|
|
151
|
+
if sb is not None:
|
|
152
|
+
leave_idx = (sb & 0x3C) >> 2
|
|
153
|
+
leave_type = leave.type_idx if hasattr(leave, 'type_idx') else 0
|
|
154
|
+
if leave_type > leave_idx:
|
|
155
|
+
self.scoreboard[i] = (sb & 0x2) | (leave_type << 2)
|
|
156
|
+
else:
|
|
157
|
+
leave_type = leave.type_idx if hasattr(leave, 'type_idx') else 0
|
|
158
|
+
self.scoreboard[i] = leave_type << 2
|
|
159
|
+
|
|
160
|
+
def calcCriticalness(self) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Calculate the criticalness of the resource.
|
|
163
|
+
|
|
164
|
+
The criticalness is a measure for the probability that all allocations
|
|
165
|
+
can be fulfilled. A value above 1.0 means that statistically some tasks
|
|
166
|
+
will not get their resources.
|
|
167
|
+
"""
|
|
168
|
+
if self.scoreboard is None:
|
|
169
|
+
self.property.set_scenario_attr('criticalness', self.scenarioIdx, 0.0)
|
|
170
|
+
else:
|
|
171
|
+
free_slots = sum(1 for slot in self.scoreboard if slot is None)
|
|
172
|
+
allocated_effort = self.property.get('alloctdeffort', self.scenarioIdx) or 0
|
|
173
|
+
|
|
174
|
+
if free_slots == 0:
|
|
175
|
+
self.property.set_scenario_attr('criticalness', self.scenarioIdx, 1.0)
|
|
176
|
+
else:
|
|
177
|
+
self.property.set_scenario_attr('criticalness', self.scenarioIdx,
|
|
178
|
+
allocated_effort / free_slots)
|
|
179
|
+
|
|
180
|
+
def setDirectReports(self) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Set up the direct reports relationships based on managers.
|
|
183
|
+
"""
|
|
184
|
+
managers = self.property.get('managers', self.scenarioIdx) or []
|
|
185
|
+
new_managers = []
|
|
186
|
+
|
|
187
|
+
for manager_id in managers:
|
|
188
|
+
manager = self.project.resource(manager_id) if isinstance(manager_id, str) else manager_id
|
|
189
|
+
|
|
190
|
+
if manager is None:
|
|
191
|
+
self.error('resource_id_expected',
|
|
192
|
+
f"{manager_id} is not a defined resource.")
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
if not manager.leaf():
|
|
196
|
+
self.error('manager_is_group',
|
|
197
|
+
f"Resource {self.property.fullId} has group "
|
|
198
|
+
f"{manager.fullId} assigned as manager.")
|
|
199
|
+
|
|
200
|
+
if manager == self.property:
|
|
201
|
+
self.error('manager_is_self',
|
|
202
|
+
f"Resource {self.property.fullId} cannot manage itself.")
|
|
203
|
+
|
|
204
|
+
if self.property.leaf():
|
|
205
|
+
direct_reports = manager.get('directreports', self.scenarioIdx) or []
|
|
206
|
+
if self.property not in direct_reports:
|
|
207
|
+
direct_reports.append(self.property)
|
|
208
|
+
|
|
209
|
+
new_managers.append(manager)
|
|
210
|
+
|
|
211
|
+
# Update managers list with unique entries
|
|
212
|
+
seen = set()
|
|
213
|
+
unique_managers = []
|
|
214
|
+
for m in new_managers:
|
|
215
|
+
if m not in seen:
|
|
216
|
+
unique_managers.append(m)
|
|
217
|
+
seen.add(m)
|
|
218
|
+
|
|
219
|
+
self.property.set_scenario_attr('managers', self.scenarioIdx, unique_managers)
|
|
220
|
+
|
|
221
|
+
def setReports(self) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Set up reporting relationships.
|
|
224
|
+
"""
|
|
225
|
+
direct_reports = self.property.get('directreports', self.scenarioIdx)
|
|
226
|
+
if not direct_reports:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
managers = self.property.get('managers', self.scenarioIdx) or []
|
|
230
|
+
for r in managers:
|
|
231
|
+
if hasattr(r, 'setReports_i'):
|
|
232
|
+
r.setReports_i(self.scenarioIdx, [self.property])
|
|
233
|
+
|
|
234
|
+
def preScheduleCheck(self) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Pre-schedule validation check.
|
|
237
|
+
"""
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def finishScheduling(self) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Finish scheduling housekeeping.
|
|
243
|
+
|
|
244
|
+
This method is called after scheduling is completed to do housekeeping
|
|
245
|
+
like updating parent resources with duties from children.
|
|
246
|
+
"""
|
|
247
|
+
# Recursively descend into all child resources
|
|
248
|
+
for resource in self.property.children:
|
|
249
|
+
resource.finishScheduling(self.scenarioIdx)
|
|
250
|
+
|
|
251
|
+
# Add parent tasks of each task to the duties list
|
|
252
|
+
duties = self.property.get('duties', self.scenarioIdx) or []
|
|
253
|
+
current_duties = list(duties)
|
|
254
|
+
for task in current_duties:
|
|
255
|
+
if hasattr(task, 'ancestors'):
|
|
256
|
+
for p_task in task.ancestors(True):
|
|
257
|
+
if p_task not in duties:
|
|
258
|
+
duties.append(p_task)
|
|
259
|
+
|
|
260
|
+
# Add assigned tasks to parent resource duties
|
|
261
|
+
parents = self.property.parents() if callable(self.property.parents) else self.property.parents
|
|
262
|
+
for p_resource in (parents or []):
|
|
263
|
+
p_duties = p_resource.get('duties', self.scenarioIdx) or []
|
|
264
|
+
for task in duties:
|
|
265
|
+
if task not in p_duties:
|
|
266
|
+
p_duties.append(task)
|
|
267
|
+
|
|
268
|
+
def available(self, sb_idx: int) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
Check if resource is available at the given time slot.
|
|
271
|
+
|
|
272
|
+
A slot is available if:
|
|
273
|
+
1. It's during working hours for this resource
|
|
274
|
+
2. Not fully booked by another task, OR
|
|
275
|
+
3. Partially used and has remaining time
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
sb_idx: Scoreboard index
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if available (fully or partially), False otherwise
|
|
282
|
+
"""
|
|
283
|
+
if self.scoreboard is None:
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
# Check if slot is during working hours for this resource
|
|
287
|
+
if not self.onShift(sb_idx):
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
# Check if slot has any available time
|
|
291
|
+
available_seconds = self.getAvailableSecondsInSlot(sb_idx)
|
|
292
|
+
if available_seconds <= 0:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
# If scoreboard shows a booking but there's available time, it's a partial slot
|
|
296
|
+
# that was released - allow booking
|
|
297
|
+
if self.scoreboard[sb_idx] is not None and available_seconds < self.project.attributes.get('scheduleGranularity', 3600):
|
|
298
|
+
# Partial slot available - allow it
|
|
299
|
+
pass
|
|
300
|
+
elif self.scoreboard[sb_idx] is not None:
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
limits = self.property.get('limits', self.scenarioIdx)
|
|
304
|
+
if limits and hasattr(limits, 'ok') and not limits.ok(sb_idx):
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
# Check parent resource limits (hierarchical limit propagation)
|
|
308
|
+
# When a child resource is booked, parent limits must also be checked
|
|
309
|
+
parent = self.property.parent
|
|
310
|
+
while parent:
|
|
311
|
+
parent_limits = parent.get('limits', self.scenarioIdx)
|
|
312
|
+
if parent_limits and hasattr(parent_limits, 'ok') and not parent_limits.ok(sb_idx):
|
|
313
|
+
return False
|
|
314
|
+
parent = parent.parent
|
|
315
|
+
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def booked(self, sb_idx: int) -> bool:
|
|
319
|
+
"""
|
|
320
|
+
Check if resource is booked at the given time slot.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
sb_idx: Scoreboard index
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True if booked for a task, False otherwise
|
|
327
|
+
"""
|
|
328
|
+
if self.scoreboard is None:
|
|
329
|
+
return False
|
|
330
|
+
# Import here to avoid circular import
|
|
331
|
+
from scriptplan.core.task import Task
|
|
332
|
+
return isinstance(self.scoreboard[sb_idx], Task)
|
|
333
|
+
|
|
334
|
+
def bookedTask(self, sb_idx: int) -> Optional['Task']:
|
|
335
|
+
"""
|
|
336
|
+
Get the task booked at the given time slot.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
sb_idx: Scoreboard index
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
The Task or None
|
|
343
|
+
"""
|
|
344
|
+
from scriptplan.core.task import Task
|
|
345
|
+
if self.scoreboard is None:
|
|
346
|
+
return None
|
|
347
|
+
sb = self.scoreboard[sb_idx]
|
|
348
|
+
return sb if isinstance(sb, Task) else None
|
|
349
|
+
|
|
350
|
+
def getAvailableSecondsInSlot(self, sb_idx: int) -> float:
|
|
351
|
+
"""
|
|
352
|
+
Get the available seconds in a slot, accounting for partial usage.
|
|
353
|
+
|
|
354
|
+
If a previous task ended mid-slot, only the remaining time is available.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
sb_idx: Scoreboard index
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Available seconds in the slot (0 to slot_duration)
|
|
361
|
+
"""
|
|
362
|
+
slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
|
|
363
|
+
seconds_used = self.slotSecondsUsed.get(sb_idx, 0.0)
|
|
364
|
+
return max(0.0, slot_duration - seconds_used)
|
|
365
|
+
|
|
366
|
+
def markSlotPartiallyUsed(self, sb_idx: int, seconds_used: float) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Record that a task used only part of a slot.
|
|
369
|
+
|
|
370
|
+
This allows subsequent tasks to use the remaining time.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
sb_idx: Scoreboard index
|
|
374
|
+
seconds_used: Seconds of the slot that were used
|
|
375
|
+
"""
|
|
376
|
+
current_used = self.slotSecondsUsed.get(sb_idx, 0.0)
|
|
377
|
+
self.slotSecondsUsed[sb_idx] = current_used + seconds_used
|
|
378
|
+
|
|
379
|
+
def book(self, sb_idx: int, task: 'Task', force: bool = False) -> float:
|
|
380
|
+
"""
|
|
381
|
+
Book a time slot for a task.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
sb_idx: Scoreboard index
|
|
385
|
+
task: The task to book
|
|
386
|
+
force: If True, overwrite existing booking
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Effort gained from this booking (hours), or 0 if booking failed.
|
|
390
|
+
This accounts for partial slot usage.
|
|
391
|
+
"""
|
|
392
|
+
if not force and not self.available(sb_idx):
|
|
393
|
+
return 0.0
|
|
394
|
+
|
|
395
|
+
# Make sure task is in duties list
|
|
396
|
+
duties = self.property.get('duties', self.scenarioIdx) or []
|
|
397
|
+
if task not in duties:
|
|
398
|
+
duties.append(task)
|
|
399
|
+
|
|
400
|
+
# Initialize scoreboard if needed
|
|
401
|
+
if self.scoreboard is None:
|
|
402
|
+
self.initScoreboard()
|
|
403
|
+
|
|
404
|
+
# Calculate effort based on available time in slot (for partial slots)
|
|
405
|
+
slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
|
|
406
|
+
available_seconds = self.getAvailableSecondsInSlot(sb_idx)
|
|
407
|
+
efficiency = self.property.get('efficiency', self.scenarioIdx) or 1.0
|
|
408
|
+
|
|
409
|
+
# Effort = (available_seconds / 3600) * efficiency
|
|
410
|
+
effort_gained = (available_seconds / 3600.0) * efficiency
|
|
411
|
+
|
|
412
|
+
# Track effort
|
|
413
|
+
self._effort += effort_gained
|
|
414
|
+
|
|
415
|
+
# Track per-task slot usage for cost calculation
|
|
416
|
+
if sb_idx not in self.slotTaskUsage:
|
|
417
|
+
self.slotTaskUsage[sb_idx] = []
|
|
418
|
+
self.slotTaskUsage[sb_idx].append((task, available_seconds))
|
|
419
|
+
|
|
420
|
+
# Update total seconds used in this slot
|
|
421
|
+
current_used = self.slotSecondsUsed.get(sb_idx, 0.0)
|
|
422
|
+
self.slotSecondsUsed[sb_idx] = current_used + available_seconds
|
|
423
|
+
|
|
424
|
+
# Update scoreboard (may be overwritten if multiple tasks share slot)
|
|
425
|
+
self.scoreboard[sb_idx] = task
|
|
426
|
+
|
|
427
|
+
# Update resource limits
|
|
428
|
+
limits = self.property.get('limits', self.scenarioIdx)
|
|
429
|
+
if limits and hasattr(limits, 'inc'):
|
|
430
|
+
limits.inc(sb_idx)
|
|
431
|
+
|
|
432
|
+
# Propagate to parent resource limits (hierarchical limit propagation)
|
|
433
|
+
# When a child resource is booked, parent limits must also be incremented
|
|
434
|
+
parent = self.property.parent
|
|
435
|
+
while parent:
|
|
436
|
+
parent_limits = parent.get('limits', self.scenarioIdx)
|
|
437
|
+
if parent_limits and hasattr(parent_limits, 'inc'):
|
|
438
|
+
parent_limits.inc(sb_idx)
|
|
439
|
+
parent = parent.parent
|
|
440
|
+
|
|
441
|
+
# Update task limits (including parent task limits)
|
|
442
|
+
task_scenario = task.data[self.scenarioIdx] if hasattr(task, 'data') and task.data else None
|
|
443
|
+
if task_scenario and hasattr(task_scenario, 'incLimits'):
|
|
444
|
+
task_scenario.incLimits(sb_idx, self.property)
|
|
445
|
+
|
|
446
|
+
# Track booked slot ranges
|
|
447
|
+
if self.firstBookedSlot is None or self.firstBookedSlot > sb_idx:
|
|
448
|
+
self.firstBookedSlot = sb_idx
|
|
449
|
+
self.firstBookedSlots[task] = sb_idx
|
|
450
|
+
elif task not in self.firstBookedSlots or self.firstBookedSlots[task] > sb_idx:
|
|
451
|
+
self.firstBookedSlots[task] = sb_idx
|
|
452
|
+
|
|
453
|
+
if self.lastBookedSlot is None or self.lastBookedSlot < sb_idx:
|
|
454
|
+
self.lastBookedSlot = sb_idx
|
|
455
|
+
self.lastBookedSlots[task] = sb_idx
|
|
456
|
+
elif task not in self.lastBookedSlots or self.lastBookedSlots[task] < sb_idx:
|
|
457
|
+
self.lastBookedSlots[task] = sb_idx
|
|
458
|
+
|
|
459
|
+
return effort_gained
|
|
460
|
+
|
|
461
|
+
def releasePartialSlot(self, sb_idx: int, seconds_to_release: float) -> None:
|
|
462
|
+
"""
|
|
463
|
+
Release part of a slot back for other tasks to use.
|
|
464
|
+
|
|
465
|
+
Called when a task ends mid-slot to make the remaining time available.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
sb_idx: Scoreboard index
|
|
469
|
+
seconds_to_release: Seconds to release back
|
|
470
|
+
"""
|
|
471
|
+
slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
|
|
472
|
+
current_used = self.slotSecondsUsed.get(sb_idx, slot_duration)
|
|
473
|
+
# Reduce the used time, making more available
|
|
474
|
+
self.slotSecondsUsed[sb_idx] = max(0.0, current_used - seconds_to_release)
|
|
475
|
+
# Clear the booking so another task can use it
|
|
476
|
+
if self.scoreboard is not None:
|
|
477
|
+
self.scoreboard[sb_idx] = None
|
|
478
|
+
|
|
479
|
+
def bookedEffort(self) -> float:
|
|
480
|
+
"""
|
|
481
|
+
Get the total booked effort for this resource.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
The effort value
|
|
485
|
+
"""
|
|
486
|
+
if self.property.leaf():
|
|
487
|
+
return self._effort
|
|
488
|
+
else:
|
|
489
|
+
effort = 0.0
|
|
490
|
+
for r in self.property.kids():
|
|
491
|
+
if r.data and r.data[self.scenarioIdx]:
|
|
492
|
+
effort += r.data[self.scenarioIdx].bookedEffort()
|
|
493
|
+
return effort
|
|
494
|
+
|
|
495
|
+
def onShift(self, sb_idx: int) -> bool:
|
|
496
|
+
"""
|
|
497
|
+
Check if the resource is on shift at the given time slot.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
sb_idx: Scoreboard index
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
True if on shift, False otherwise
|
|
504
|
+
"""
|
|
505
|
+
date = self.project.idxToDate(sb_idx)
|
|
506
|
+
|
|
507
|
+
# First check global vacations - they override everything
|
|
508
|
+
vacations = self.project.attributes.get('vacations', [])
|
|
509
|
+
if vacations:
|
|
510
|
+
for vac in vacations:
|
|
511
|
+
if hasattr(vac, 'interval') and vac.interval:
|
|
512
|
+
if vac.interval.start <= date < vac.interval.end:
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
# Check resource-level leaves/vacations
|
|
516
|
+
leaves = self.property.get('leaves', self.scenarioIdx)
|
|
517
|
+
if leaves:
|
|
518
|
+
for leave in leaves:
|
|
519
|
+
if hasattr(leave, 'interval') and leave.interval:
|
|
520
|
+
if leave.interval.start <= date < leave.interval.end:
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
# Get resource's timezone for local time conversion
|
|
524
|
+
# Working hours are defined in local time, but slots are in UTC
|
|
525
|
+
resource_tz = self.property.get('timezone', self.scenarioIdx)
|
|
526
|
+
|
|
527
|
+
# Check if resource has a shift reference
|
|
528
|
+
shift = self.property.get('shifts', self.scenarioIdx)
|
|
529
|
+
if shift:
|
|
530
|
+
# Use the shift's working hours
|
|
531
|
+
shift_wh = shift.get('workinghours', self.scenarioIdx)
|
|
532
|
+
if shift_wh and hasattr(shift_wh, 'onShift'):
|
|
533
|
+
return shift_wh.onShift(sb_idx, timezone=resource_tz)
|
|
534
|
+
|
|
535
|
+
# Check if resource has direct working hours
|
|
536
|
+
workinghours = self.property.get('workinghours', self.scenarioIdx)
|
|
537
|
+
if workinghours and hasattr(workinghours, 'onShift'):
|
|
538
|
+
return workinghours.onShift(sb_idx, timezone=resource_tz)
|
|
539
|
+
|
|
540
|
+
# Default: use project's working time
|
|
541
|
+
return self.project.isWorkingTime(sb_idx)
|
|
542
|
+
|
|
543
|
+
def setReports_i(self, reports: List) -> None:
|
|
544
|
+
"""
|
|
545
|
+
Internal method to set reports relationship.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
reports: List of resources reporting to this one
|
|
549
|
+
"""
|
|
550
|
+
if self.property in reports:
|
|
551
|
+
self.error('manager_loop',
|
|
552
|
+
f"Management loop detected. {self.property.fullId} "
|
|
553
|
+
"has self in list of reports")
|
|
554
|
+
|
|
555
|
+
current_reports = self.property.get('reports', self.scenarioIdx) or []
|
|
556
|
+
for r in reports:
|
|
557
|
+
if r not in current_reports:
|
|
558
|
+
current_reports.append(r)
|
|
559
|
+
|
|
560
|
+
managers = self.property.get('managers', self.scenarioIdx) or []
|
|
561
|
+
for r in managers:
|
|
562
|
+
if hasattr(r, 'setReports_i'):
|
|
563
|
+
r.setReports_i(self.scenarioIdx, current_reports)
|
|
564
|
+
|
|
565
|
+
def treeSum(self, start_idx: int, end_idx: int, *args,
|
|
566
|
+
block: Callable[['ResourceScenario'], float]) -> float:
|
|
567
|
+
"""
|
|
568
|
+
Generic tree iterator that recursively accumulates results.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
start_idx: Start scoreboard index
|
|
572
|
+
end_idx: End scoreboard index
|
|
573
|
+
*args: Additional arguments
|
|
574
|
+
block: Callable to execute on leaf nodes
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
Accumulated sum
|
|
578
|
+
"""
|
|
579
|
+
cache_tag = "treeSum"
|
|
580
|
+
return self.treeSumR(cache_tag, start_idx, end_idx, *args, block=block)
|
|
581
|
+
|
|
582
|
+
def treeSumR(self, cache_tag: str, start_idx: int, end_idx: int, *args,
|
|
583
|
+
block: Callable[['ResourceScenario'], float]) -> float:
|
|
584
|
+
"""
|
|
585
|
+
Recursive implementation of treeSum.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
cache_tag: Cache key tag
|
|
589
|
+
start_idx: Start scoreboard index
|
|
590
|
+
end_idx: End scoreboard index
|
|
591
|
+
*args: Additional arguments
|
|
592
|
+
block: Callable to execute on leaf nodes
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Accumulated sum
|
|
596
|
+
"""
|
|
597
|
+
if self.property.container():
|
|
598
|
+
sum_val = 0.0
|
|
599
|
+
for resource in self.property.kids():
|
|
600
|
+
if resource.data and resource.data[self.scenarioIdx]:
|
|
601
|
+
res_scenario = resource.data[self.scenarioIdx]
|
|
602
|
+
sum_val += res_scenario.treeSumR(cache_tag, start_idx, end_idx,
|
|
603
|
+
*args, block=block)
|
|
604
|
+
return sum_val
|
|
605
|
+
else:
|
|
606
|
+
return block(self)
|
|
607
|
+
|
|
608
|
+
def getEffectiveWork(self, start_idx: int, end_idx: int,
|
|
609
|
+
task: Optional['Task'] = None) -> float:
|
|
610
|
+
"""
|
|
611
|
+
Get the effective work done by this resource.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
start_idx: Start scoreboard index
|
|
615
|
+
end_idx: End scoreboard index
|
|
616
|
+
task: Optional task filter
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
Work in daily load units
|
|
620
|
+
"""
|
|
621
|
+
duties = self.property.get('duties', self.scenarioIdx) or []
|
|
622
|
+
if start_idx >= end_idx or (task and task not in duties):
|
|
623
|
+
return 0.0
|
|
624
|
+
|
|
625
|
+
def calculate(res_scen: 'ResourceScenario') -> float:
|
|
626
|
+
if res_scen.scoreboard is None:
|
|
627
|
+
return 0.0
|
|
628
|
+
allocated = res_scen.getAllocatedSlots(start_idx, end_idx, task)
|
|
629
|
+
granularity = res_scen.project.attributes.get('scheduleGranularity', 3600)
|
|
630
|
+
efficiency = res_scen.property.get('efficiency', res_scen.scenarioIdx) or 1.0
|
|
631
|
+
return res_scen.project.convertToDailyLoad(allocated * granularity) * efficiency
|
|
632
|
+
|
|
633
|
+
return self.treeSum(start_idx, end_idx, task, block=calculate)
|
|
634
|
+
|
|
635
|
+
def getAllocatedSlots(self, start_idx: int, end_idx: int,
|
|
636
|
+
task: Optional['Task'] = None) -> int:
|
|
637
|
+
"""
|
|
638
|
+
Count booked slots in the given range.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
start_idx: Start scoreboard index
|
|
642
|
+
end_idx: End scoreboard index
|
|
643
|
+
task: Optional task filter
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
Number of allocated slots
|
|
647
|
+
"""
|
|
648
|
+
if self.scoreboard is None:
|
|
649
|
+
return 0
|
|
650
|
+
|
|
651
|
+
if start_idx >= end_idx:
|
|
652
|
+
return 0
|
|
653
|
+
|
|
654
|
+
from scriptplan.core.task import Task
|
|
655
|
+
|
|
656
|
+
booked_slots = 0
|
|
657
|
+
task_list = task.all() if task and hasattr(task, 'all') else []
|
|
658
|
+
|
|
659
|
+
actual_end = min(end_idx, len(self.scoreboard))
|
|
660
|
+
for i in range(start_idx, actual_end):
|
|
661
|
+
slot = self.scoreboard[i]
|
|
662
|
+
if isinstance(slot, Task):
|
|
663
|
+
if task is None or slot in task_list or slot == task:
|
|
664
|
+
booked_slots += 1
|
|
665
|
+
|
|
666
|
+
return booked_slots
|
|
667
|
+
|
|
668
|
+
def getFreeSlots(self, start_idx: int, end_idx: int) -> int:
|
|
669
|
+
"""
|
|
670
|
+
Count free slots in the given range.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
start_idx: Start scoreboard index
|
|
674
|
+
end_idx: End scoreboard index
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Number of free slots
|
|
678
|
+
"""
|
|
679
|
+
if self.scoreboard is None:
|
|
680
|
+
return 0
|
|
681
|
+
|
|
682
|
+
count = 0
|
|
683
|
+
actual_end = min(end_idx, len(self.scoreboard))
|
|
684
|
+
for i in range(start_idx, actual_end):
|
|
685
|
+
if self.scoreboard[i] is None:
|
|
686
|
+
count += 1
|
|
687
|
+
return count
|
|
688
|
+
|
|
689
|
+
def getWorkSlots(self, start_idx: int, end_idx: int) -> int:
|
|
690
|
+
"""
|
|
691
|
+
Count work slots (free + allocated) in the given range.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
start_idx: Start scoreboard index
|
|
695
|
+
end_idx: End scoreboard index
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
Number of work slots
|
|
699
|
+
"""
|
|
700
|
+
from scriptplan.core.task import Task
|
|
701
|
+
|
|
702
|
+
if self.scoreboard is None:
|
|
703
|
+
return 0
|
|
704
|
+
|
|
705
|
+
count = 0
|
|
706
|
+
actual_end = min(end_idx, len(self.scoreboard))
|
|
707
|
+
for i in range(start_idx, actual_end):
|
|
708
|
+
slot = self.scoreboard[i]
|
|
709
|
+
if slot is None or isinstance(slot, Task):
|
|
710
|
+
count += 1
|
|
711
|
+
return count
|