cycle.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. # Copyright 2021-2025 Avaiga Private Limited
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  4. # the License. You may obtain a copy of the License at
  5. #
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. #
  8. # Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  9. # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  10. # specific language governing permissions and limitations under the License.
  11. import re
  12. import uuid
  13. from datetime import datetime
  14. from typing import Any, Dict, Optional
  15. from .._entity._entity import _Entity
  16. from .._entity._labeled import _Labeled
  17. from .._entity._properties import _Properties
  18. from .._entity._reload import _Reloader, _self_reload, _self_setter
  19. from ..common.frequency import Frequency
  20. from ..exceptions.exceptions import _SuspiciousFileOperation
  21. from ..notification.event import Event, EventEntityType, EventOperation, _make_event
  22. from .cycle_id import CycleId
  23. class Cycle(_Entity, _Labeled):
  24. """An iteration of a recurrent work pattern.
  25. Many business operations are periodic, such as weekly predictions of sales data, monthly
  26. master planning of supply chains, quarterly financial reports, yearly budgeting, etc.
  27. The data applications to solve these business problems often require modeling the
  28. corresponding periods (i.e., cycles).
  29. For this purpose, a `Cycle` represents a single iteration of such a time pattern.
  30. Each _cycle_ has a start date and a duration. Examples of cycles are:
  31. - Monday, 2. January 2023 as a daily cycle
  32. - Week 01 2023, from 2. January as a weekly cycle
  33. - January 2023 as a monthly cycle
  34. - etc.
  35. `Cycle`s are created along with the `Scenario^`s that are attached to them.
  36. At its creation, a new scenario is attached to a single cycle, the one that
  37. matches its optional _frequency_ and its _creation_date_.
  38. The possible frequencies are:
  39. - `Frequency.DAILY`
  40. - `Frequency.WEEKLY`
  41. - `Frequency.MONTHLY`
  42. - `Frequency.QUARTERLY`
  43. - `Frequency.YEARLY`
  44. !!! example "Example for January cycle"
  45. ![cycles](../../../../img/cycles_january_colored.svg){ align=left width="250" }
  46. Let's assume an end-user publishes production orders (i.e., a production plan) every
  47. month. During each month (the cycle), he/she will be interested in experimenting with
  48. different scenarios until only one of those scenarios is selected as the official
  49. production plan to be published. Each month is modeled as a cycle, and each cycle
  50. can contain one or more scenarios.
  51. The picture on the left shows the tree of entities: Cycles, Scenarios, and their
  52. associated Sequence(s). There is an existing past cycle for December and a current
  53. cycle for January containing a single scenario.
  54. When comes the end of a _cycle_ (start date + duration), only one of the scenarios is
  55. applied in production. This "official" scenario is called the _**primary scenario**_.
  56. Only one _**primary scenario**_ per cycle is allowed.
  57. !!! example "Example for February cycle"
  58. ![cycles](../../../../img/cycles_colored.svg){ align=left width="250" }
  59. Now the user starts working on the February work cycle. He or she creates two
  60. scenarios for the February cycle (one with a low capacity assumption and one with
  61. a high capacity assumption). The user can then decide to elect the low capacity
  62. scenario as the "official" scenario for February. To accomplish that, he just
  63. needs to promote the low capacity scenario as _**primary**_ for the February cycle.
  64. The tree of entities resulting from the various scenarios created is represented
  65. in the picture on the left. The underlined scenarios are _**primary**_.
  66. !!! note
  67. For a scenario, cycles are optional. If a scenario has no Frequency, it will not be
  68. attached to any cycle.
  69. """
  70. _ID_PREFIX = "CYCLE"
  71. __SEPARATOR = "_"
  72. _MANAGER_NAME = "cycle"
  73. id:CycleId
  74. """The unique identifier of the cycle."""
  75. def __init__(
  76. self,
  77. frequency: Frequency,
  78. properties: Dict[str, Any],
  79. creation_date: datetime,
  80. start_date: datetime,
  81. end_date: datetime,
  82. name: Optional[str] = None,
  83. id: Optional[CycleId] = None,
  84. ) -> None:
  85. self._frequency = frequency
  86. self._creation_date = creation_date
  87. self._start_date = start_date
  88. self._end_date = end_date
  89. self._name = self._new_name(name)
  90. self.id = id or self._new_id(self._name)
  91. self._properties = _Properties(self, **properties)
  92. def _new_name(self, name: Optional[str] = None) -> str:
  93. if name:
  94. return name
  95. if self._frequency == Frequency.DAILY:
  96. # Example "Monday, 2. January 2023"
  97. return self._start_date.strftime("%A, %d. %B %Y")
  98. if self._frequency == Frequency.WEEKLY:
  99. # Example "Week 01 2023, from 2. January"
  100. return self._start_date.strftime("Week %W %Y, from %d. %B")
  101. if self._frequency == Frequency.MONTHLY:
  102. # Example "January 2023"
  103. return self._start_date.strftime("%B %Y")
  104. if self._frequency == Frequency.QUARTERLY:
  105. # Example "2023 Q1"
  106. return f"{self._start_date.strftime('%Y')} Q{(self._start_date.month-1)//3+1}"
  107. if self._frequency == Frequency.YEARLY:
  108. # Example "2023"
  109. return self._start_date.strftime("%Y")
  110. return Cycle.__SEPARATOR.join([str(self._frequency.value), self._start_date.ctime()])
  111. @property # type: ignore
  112. @_self_reload(_MANAGER_NAME)
  113. def frequency(self) -> Frequency:
  114. """The frequency of this cycle."""
  115. return self._frequency
  116. @frequency.setter # type: ignore
  117. @_self_setter(_MANAGER_NAME)
  118. def frequency(self, val):
  119. self._frequency = val
  120. @property # type: ignore
  121. @_self_reload(_MANAGER_NAME)
  122. def creation_date(self) -> datetime:
  123. """The date and time of the creation of this cycle."""
  124. return self._creation_date
  125. @creation_date.setter # type: ignore
  126. @_self_setter(_MANAGER_NAME)
  127. def creation_date(self, val):
  128. self._creation_date = val
  129. @property # type: ignore
  130. @_self_reload(_MANAGER_NAME)
  131. def start_date(self) -> datetime:
  132. """The date and time of the start of this cycle."""
  133. return self._start_date
  134. @start_date.setter # type: ignore
  135. @_self_setter(_MANAGER_NAME)
  136. def start_date(self, val):
  137. self._start_date = val
  138. @property # type: ignore
  139. @_self_reload(_MANAGER_NAME)
  140. def end_date(self) -> datetime:
  141. """The date and time of the end of this cycle."""
  142. return self._end_date
  143. @end_date.setter # type: ignore
  144. @_self_setter(_MANAGER_NAME)
  145. def end_date(self, val):
  146. self._end_date = val
  147. @property # type: ignore
  148. @_self_reload(_MANAGER_NAME)
  149. def name(self) -> str:
  150. """The name of this cycle."""
  151. return self._name
  152. @name.setter # type: ignore
  153. @_self_setter(_MANAGER_NAME)
  154. def name(self, val):
  155. self._name = val
  156. @property
  157. def properties(self) -> _Properties:
  158. """A dictionary of additional properties."""
  159. self._properties = _Reloader()._reload(self._MANAGER_NAME, self)._properties
  160. return self._properties
  161. @staticmethod
  162. def _new_id(name: str) -> CycleId:
  163. def _get_valid_filename(name: str) -> str:
  164. """
  165. Source: https://github.com/django/django/blob/main/django/utils/text.py
  166. """
  167. s = name.strip().replace(" ", "_")
  168. s = re.sub(r"(?u)[^-\w.]", "", s)
  169. if s in {"", ".", ".."}:
  170. raise _SuspiciousFileOperation(f"Could not derive file name from '{name}'")
  171. s = s.strip().replace(" ", "_")
  172. return re.sub(r"(?u)[^-\w.]", "", s)
  173. return CycleId(_get_valid_filename(Cycle.__SEPARATOR.join([Cycle._ID_PREFIX, name, str(uuid.uuid4())])))
  174. def __eq__(self, other):
  175. return isinstance(other, Cycle) and self.id == other.id
  176. def __hash__(self) -> int:
  177. return hash(self.id)
  178. def get_label(self) -> str:
  179. """Returns the cycle label.
  180. Returns:
  181. The label of the cycle as a string.
  182. """
  183. return self._get_label()
  184. def get_simple_label(self) -> str:
  185. """Returns the cycle simple label.
  186. Returns:
  187. The simple label of the cycle as a string.
  188. """
  189. return self._get_simple_label()
  190. @_make_event.register(Cycle)
  191. def _make_event_for_cycle(
  192. cycle: Cycle,
  193. operation: EventOperation,
  194. /,
  195. attribute_name: Optional[str] = None,
  196. attribute_value: Optional[Any] = None,
  197. **kwargs,
  198. ) -> Event:
  199. metadata = {**kwargs}
  200. return Event(
  201. entity_type=EventEntityType.CYCLE,
  202. entity_id=cycle.id,
  203. operation=operation,
  204. attribute_name=attribute_name,
  205. attribute_value=attribute_value,
  206. metadata=metadata,
  207. )