perfact-api-pd 0.2__tar.gz → 0.3__tar.gz

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.
Files changed (29) hide show
  1. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/PKG-INFO +6 -2
  2. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/pyproject.toml +8 -1
  3. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/pdordlch.py +6 -2
  4. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/pdordprd.py +130 -7
  5. perfact_api_pd-0.3/src/perfact/api/pd/services/__init__.py +13 -0
  6. perfact_api_pd-0.3/src/perfact/api/pd/services/pdunitperformance_orm.py +425 -0
  7. perfact_api_pd-0.3/src/perfact/api/pd/services/pdunitperformance_plainsql.py +191 -0
  8. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact_api_pd.egg-info/PKG-INFO +6 -2
  9. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact_api_pd.egg-info/SOURCES.txt +7 -1
  10. perfact_api_pd-0.3/src/perfact_api_pd.egg-info/requires.txt +10 -0
  11. perfact_api_pd-0.3/tests/test_models.py +397 -0
  12. perfact_api_pd-0.3/tests/test_policy.py +34 -0
  13. perfact_api_pd-0.3/tests/test_s_pdunitperformance.py +484 -0
  14. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/tox.ini +2 -1
  15. perfact_api_pd-0.2/src/perfact_api_pd.egg-info/requires.txt +0 -5
  16. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/README.md +0 -0
  17. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/setup.cfg +0 -0
  18. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/__init__.py +0 -0
  19. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/__init__.py +0 -0
  20. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/pdord.py +0 -0
  21. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/pdordlc.py +0 -0
  22. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/pdordlct.py +0 -0
  23. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/pdunit.py +0 -0
  24. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/policy.py +0 -0
  25. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/model/py.typed +0 -0
  26. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact/api/pd/py.typed +0 -0
  27. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact_api_pd.egg-info/dependency_links.txt +0 -0
  28. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact_api_pd.egg-info/entry_points.txt +0 -0
  29. {perfact_api_pd-0.2 → perfact_api_pd-0.3}/src/perfact_api_pd.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perfact-api-pd
3
- Version: 0.2
3
+ Version: 0.3
4
4
  Summary: PerFact API - pd domain models and services
5
5
  Author-email: Viktor Dick <viktor.dick@perfact.de>
6
6
  License-Expression: GPL-2.0-or-later
@@ -11,9 +11,13 @@ Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: psycopg[binary]
13
13
  Requires-Dist: sqlalchemy
14
- Requires-Dist: perfact-api-base-model
14
+ Requires-Dist: perfact-api-base
15
15
  Requires-Dist: perfact-api-app
16
16
  Requires-Dist: perfact-api-pp
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest; extra == "test"
19
+ Requires-Dist: pytest-cov; extra == "test"
20
+ Requires-Dist: pytest-postgresql; extra == "test"
17
21
 
18
22
  # PerFact API - pd - model
19
23
 
@@ -18,13 +18,20 @@ classifiers = [
18
18
  dependencies = [
19
19
  "psycopg[binary]",
20
20
  "sqlalchemy",
21
- "perfact-api-base-model",
21
+ "perfact-api-base",
22
22
  "perfact-api-app",
23
23
  "perfact-api-pp",
24
24
  ]
25
25
  dynamic = ["version"]
26
26
  requires-python = ">=3.10"
27
27
 
28
+ [project.optional-dependencies]
29
+ test = [
30
+ "pytest",
31
+ "pytest-cov",
32
+ "pytest-postgresql",
33
+ ]
34
+
28
35
  [project.scripts]
29
36
 
30
37
  [tool.distutils.bdist_wheel]
@@ -31,7 +31,9 @@ class PdOrdLch(Base):
31
31
  pdord: Mapped["PdOrd"] = relationship("PdOrd")
32
32
 
33
33
  @hybrid_method
34
- def availability_nominator_eligible_def(self, pdunit_id, /) -> bool:
34
+ def availability_nominator_eligible_def(
35
+ self, pdunit_id, /
36
+ ) -> bool: # pragma: no cover
35
37
  """Python level - boils down to check if this operation history
36
38
  is linked to the workcenter and represents a status change
37
39
  from a status with pdordlc_isexecution"""
@@ -47,7 +49,9 @@ class PdOrdLch(Base):
47
49
  )
48
50
 
49
51
  @hybrid_method
50
- def availability_denominator_eligible_def(self, pdunit_id, /) -> bool:
52
+ def availability_denominator_eligible_def(
53
+ self, pdunit_id, /
54
+ ) -> bool: # pragma: no cover
51
55
  """Python level - boils down to check if this operation history
52
56
  is linked to the workcenter and represents a status change
53
57
  from a status with pdordlc_isactive(True) and pdordlc_isexecution(False)"""
@@ -2,16 +2,18 @@ from datetime import datetime
2
2
  from typing import Any, Literal, cast, overload
3
3
 
4
4
  from perfact.api.base.model import Base, ForeignKey, Mapped, relationship
5
+ from perfact.api.pp.model import PpRsrc, PpShft
5
6
  from psycopg.types.range import Range
6
7
  from sqlalchemy import and_, case, func, select
7
8
  from sqlalchemy.dialects.postgresql import TSTZRANGE
8
9
  from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property
9
- from sqlalchemy.orm import mapped_column
10
+ from sqlalchemy.orm import Session, mapped_column
10
11
  from sqlalchemy.sql import ColumnElement
11
12
  from sqlalchemy.sql.elements import SQLCoreOperations
12
13
  from sqlalchemy.sql.expression import Extract
13
14
 
14
15
  from .pdord import PdOrd
16
+ from .pdordlch import PdOrdLch
15
17
 
16
18
 
17
19
  class PdOrdPrd(Base):
@@ -60,7 +62,7 @@ class PdOrdPrd(Base):
60
62
  search_range = func.tstzrange(starttime, stoptime, "[)")
61
63
  condition = and_(
62
64
  cast(Any, cls.refrange).is_not(None),
63
- cls.refrange.op("&&")(search_range),
65
+ cast(Any, cls.refrange).op("&&")(search_range),
64
66
  )
65
67
  intersection = cls.refrange.op("*")(search_range)
66
68
 
@@ -176,31 +178,152 @@ class PdOrdPrd(Base):
176
178
  return cls.refrange_intersection(starttime, stoptime, True)
177
179
 
178
180
  @hybrid_method
179
- def performance_eligible_def(self, pdunit_id: int, /) -> bool:
181
+ def performance_eligible_def(
182
+ self, pdunit_id: int, starttime: datetime, stoptime: datetime, /
183
+ ) -> bool:
180
184
  """Python-level: check if entry is eligible
181
185
  to be taken in account for performance"""
182
186
  raise NotImplementedError("Sql expression level only")
183
187
 
184
188
  @performance_eligible_def.expression
185
- def performance_eligible(cls, pdunit_id: int, /) -> ColumnElement[bool]:
189
+ def performance_eligible(
190
+ cls, pdunit_id: int, starttime: datetime, stoptime: datetime, /
191
+ ) -> ColumnElement[bool]:
186
192
  """SQL-level: check if entry is eligible
187
193
  to be taken in account for performance"""
194
+ search_range = func.tstzrange(starttime, stoptime, "[)")
188
195
  return and_(
189
196
  cast(Any, cls.deleted).is_(False),
190
197
  cast(Any, cls.pdunit_id) == pdunit_id,
191
198
  cast(Any, cls.pdord).has(PdOrd.pdunit_id == pdunit_id),
199
+ cast(Any, cls.refrange).is_not(None),
200
+ cast(Any, cls.refrange).op("&&")(search_range),
192
201
  )
193
202
 
194
203
  @hybrid_method
195
- def quality_eligible_def(self, pdunit_id: int, /) -> bool:
204
+ def quality_eligible_def(
205
+ self, pdunit_id: int, starttime: datetime, stoptime: datetime, /
206
+ ) -> bool:
196
207
  """Python-level: check if entry is eligible
197
208
  to be taken in account for quality"""
198
209
  raise NotImplementedError("Sql expression level only")
199
210
 
200
211
  @quality_eligible_def.expression
201
- def quality_eligible(cls, pdunit_id: int, /) -> ColumnElement[bool]:
212
+ def quality_eligible(
213
+ cls, pdunit_id: int, starttime: datetime, stoptime: datetime, /
214
+ ) -> ColumnElement[bool]:
202
215
  """SQL-level: check if entry is eligible
203
216
  to be taken in account for quality"""
217
+ search_range = func.tstzrange(starttime, stoptime, "[)")
204
218
  return and_(
205
- cast(Any, cls.deleted).is_(False), cast(Any, cls.pdunit_id) == pdunit_id
219
+ cast(Any, cls.deleted).is_(False),
220
+ cast(Any, cls.pdunit_id) == pdunit_id,
221
+ cast(Any, cls.refrange).is_not(None),
222
+ cast(Any, cls.refrange).op("&&")(search_range),
206
223
  )
224
+
225
+ @staticmethod
226
+ def oee_performance_computation(
227
+ session: Session, pdunit_id: int, starttime: datetime, stoptime: datetime, /
228
+ ) -> float:
229
+ """OEE function for computation of performance."""
230
+ result = session.execute(
231
+ select(
232
+ func.coalesce(
233
+ func.sum(PdOrdPrd.ideal_time_over_time_range(starttime, stoptime)),
234
+ 0.0,
235
+ )
236
+ / func.nullif(
237
+ func.coalesce(
238
+ func.sum(
239
+ PdOrdPrd.actual_time_over_time_range(starttime, stoptime)
240
+ ),
241
+ 0.0,
242
+ ),
243
+ 0.0,
244
+ )
245
+ ).where(
246
+ PdOrdPrd.performance_eligible(pdunit_id, starttime, stoptime).is_(True)
247
+ )
248
+ ).scalar()
249
+ return float(result or 0.0)
250
+
251
+ @staticmethod
252
+ def oee_quality_computation(
253
+ session: Session, pdunit_id: int, starttime: datetime, stoptime: datetime, /
254
+ ) -> float | None:
255
+ """OEE function computation for quality. None when no production data."""
256
+ return session.execute(
257
+ select(
258
+ func.coalesce(
259
+ func.sum(PdOrdPrd.good_quantity_over_range(starttime, stoptime)),
260
+ 0.0,
261
+ )
262
+ / func.nullif(
263
+ func.coalesce(
264
+ func.sum(
265
+ PdOrdPrd.total_quantity_over_range(starttime, stoptime)
266
+ ),
267
+ 0.0,
268
+ ),
269
+ 0.0,
270
+ )
271
+ ).where(PdOrdPrd.quality_eligible(pdunit_id, starttime, stoptime).is_(True))
272
+ ).scalar()
273
+
274
+ @staticmethod
275
+ def oee_availability_computation(
276
+ session: Session, pdunit_id: int, starttime: datetime, stoptime: datetime, /
277
+ ) -> float:
278
+ """OEE function for computation of availability"""
279
+ search_range = func.tstzrange(starttime, stoptime)
280
+ n_intersection = PdOrdLch.lchtimerange.op("*")(search_range)
281
+ in_seconds = func.extract(
282
+ "epoch", func.upper(n_intersection) - func.lower(n_intersection)
283
+ )
284
+
285
+ current_available_time_nq = session.scalar(
286
+ select(
287
+ PdOrdLch.availability_missing_active_operations_nominator(
288
+ pdunit_id, starttime, stoptime
289
+ )
290
+ )
291
+ )
292
+ current_available_time_n = float(current_available_time_nq or 0.0)
293
+
294
+ current_available_time_dq = session.scalar(
295
+ select(
296
+ PdOrdLch.availability_missing_active_operations_denominator(
297
+ pdunit_id, starttime, stoptime
298
+ )
299
+ )
300
+ )
301
+ current_available_time_d = float(current_available_time_dq or 0.0)
302
+
303
+ # nominator calculation
304
+ nominator_q = session.execute(
305
+ select(func.coalesce(func.sum(in_seconds), 0.0))
306
+ .where(PdOrdLch.availability_nominator_eligible(pdunit_id).is_(True))
307
+ .where(PdOrdLch.lchtimerange.op("&&")(search_range))
308
+ ).scalar()
309
+ nominator = float(nominator_q or 0.0) + current_available_time_n
310
+
311
+ # denominator calculation
312
+ d_intersection = n_intersection.op("*")(PpShft.shiftrange)
313
+ in_seconds = func.extract(
314
+ "epoch", func.upper(d_intersection) - func.lower(d_intersection)
315
+ )
316
+ denominator_q = session.execute(
317
+ select(func.coalesce(func.sum(in_seconds), 1.0))
318
+ .select_from(PdOrdLch)
319
+ .join(PpRsrc, PpRsrc.pdunit_id == pdunit_id)
320
+ .join(PpShft, PpShft.pdunit_id == PpRsrc.id)
321
+ .where(PdOrdLch.availability_denominator_eligible(pdunit_id).is_(True))
322
+ .where(PdOrdLch.lchtimerange.op("&&")(search_range))
323
+ .where(n_intersection.op("&&")(PpShft.shiftrange))
324
+ ).scalar()
325
+ denominator = nominator + float(denominator_q or 0.0) + current_available_time_d
326
+
327
+ availability = nominator / denominator
328
+
329
+ return float(availability)
@@ -0,0 +1,13 @@
1
+ from .pdunitperformance_orm import (
2
+ get_time_performance_data_v2,
3
+ get_time_performance_data_v3,
4
+ get_time_performance_data_v4,
5
+ )
6
+ from .pdunitperformance_plainsql import get_time_performance_data
7
+
8
+ __all__ = [
9
+ "get_time_performance_data",
10
+ "get_time_performance_data_v2",
11
+ "get_time_performance_data_v3",
12
+ "get_time_performance_data_v4",
13
+ ]
@@ -0,0 +1,425 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Any, Dict, Mapping, Sequence
3
+
4
+ from perfact.api.pd.model.pdord import PdOrd
5
+ from perfact.api.pd.model.pdord import PdOrd_TimeStat as PdOrdTimeStat
6
+ from perfact.api.pd.model.pdordlc import PdOrdLc
7
+ from perfact.api.pd.model.pdordlch import PdOrdLch
8
+ from perfact.api.pd.model.pdordlct import PdOrdLct
9
+ from perfact.api.pd.model.pdordprd import PdOrdPrd
10
+ from sqlalchemy import bindparam, func, select, text
11
+ from sqlalchemy.engine import RowMapping
12
+ from sqlalchemy.orm import Session, aliased
13
+
14
+
15
+ def _safe_delta(end: Any, start: Any):
16
+ if end and start:
17
+ return (end - start).total_seconds()
18
+ return None
19
+
20
+
21
+ def _td_to_sec(td: Any):
22
+ return td.total_seconds() if td is not None else None
23
+
24
+
25
+ def _process_performance_in_pure_python(raw_data: Mapping[Any, Any]):
26
+ """
27
+ Takes a dictionary of raw database columns and calculates
28
+ all time management and performance metrics in pure python
29
+ """
30
+ now = datetime.now(timezone.utc)
31
+ planned_start = raw_data.get("pdord_planstarttime") or raw_data.get(
32
+ "pdord_starttime"
33
+ )
34
+ planned_end = raw_data.get("pdord_planstoptime") or raw_data.get("pdord_stoptime")
35
+ actual_start_time = raw_data.get("actual_start_time")
36
+ actual_end_time = raw_data.get("actual_end_time")
37
+
38
+ if actual_start_time is not None:
39
+ estimated_start = actual_start_time
40
+ elif planned_start is not None and planned_start < now:
41
+ estimated_start = now
42
+ else:
43
+ estimated_start = planned_start
44
+
45
+ pdord_qty = float(raw_data.get("pdord_qty") or 0)
46
+ produced_qty = float(raw_data.get("produced_quantity") or 0)
47
+ active_time = raw_data.get("pdordtimestat_activetime")
48
+
49
+ if actual_end_time is not None:
50
+ estimated_end = actual_end_time
51
+ elif (
52
+ actual_start_time is not None
53
+ and active_time is not None
54
+ and pdord_qty > 0
55
+ and produced_qty > 0
56
+ ):
57
+ extrapolated_seconds = active_time.total_seconds() * (pdord_qty / produced_qty)
58
+ estimated_end = actual_start_time + timedelta(seconds=extrapolated_seconds)
59
+ elif (
60
+ actual_start_time is not None
61
+ and planned_end is not None
62
+ and planned_start is not None
63
+ ):
64
+ planned_duration = planned_end - planned_start
65
+ estimated_end = actual_start_time + planned_duration
66
+ elif (
67
+ actual_start_time is None
68
+ and planned_start is not None
69
+ and planned_end is not None
70
+ and planned_start < now
71
+ ):
72
+ planned_duration = planned_end - planned_start
73
+ estimated_end = now + planned_duration
74
+ else:
75
+ estimated_end = planned_end
76
+
77
+ remaining_time_seconds = None
78
+ if (
79
+ actual_start_time is not None
80
+ and active_time is not None
81
+ and pdord_qty > 0
82
+ and produced_qty > 0
83
+ ):
84
+ calc_remaining = (
85
+ active_time.total_seconds() * (pdord_qty / produced_qty)
86
+ - active_time.total_seconds()
87
+ )
88
+ remaining_time_seconds = max(0.0, calc_remaining)
89
+
90
+ delta_start_seconds = _safe_delta(estimated_start, planned_start)
91
+ delta_end_seconds = _safe_delta(estimated_end, planned_end)
92
+ delta_planned_duration = _safe_delta(planned_end, planned_start)
93
+
94
+ dur_est = _safe_delta(estimated_end, estimated_start)
95
+ dur_plan = _safe_delta(planned_end, planned_start)
96
+ estimated_duration = _safe_delta(estimated_end, estimated_start)
97
+ delta_duration_seconds = (
98
+ (dur_est - dur_plan) if (dur_est is not None and dur_plan is not None) else None
99
+ )
100
+
101
+ planned_cycle = _td_to_sec(raw_data.get("pdord_plancycletime"))
102
+ current_cycle = _td_to_sec(raw_data.get("pdordtimestat_cycle_time"))
103
+
104
+ planned_setup = _td_to_sec(raw_data.get("pdord_plansetuptime"))
105
+ current_setup = _td_to_sec(raw_data.get("pdordtimestat_setuptime"))
106
+
107
+ return {
108
+ "time_management": {
109
+ "estimated_start": estimated_start,
110
+ "estimated_end": estimated_end,
111
+ "delta_start_seconds": delta_start_seconds,
112
+ "delta_end_seconds": delta_end_seconds,
113
+ "delta_planned_duration_seconds": delta_planned_duration,
114
+ "estimated_duration_seconds": estimated_duration,
115
+ "delta_duration_seconds": delta_duration_seconds,
116
+ },
117
+ "operation_status": {
118
+ "running_time_seconds": _td_to_sec(active_time),
119
+ "remaining_time_seconds": remaining_time_seconds,
120
+ "downtime_seconds": _td_to_sec(
121
+ raw_data.get("pdordtimestat_spentindowntime")
122
+ ),
123
+ },
124
+ "performance": {
125
+ "cycle_time": {
126
+ "planned_seconds": planned_cycle,
127
+ "current_seconds": current_cycle,
128
+ "delta_seconds": (current_cycle - planned_cycle)
129
+ if (current_cycle is not None and planned_cycle is not None)
130
+ else None,
131
+ },
132
+ "setup_time": {
133
+ "current_seconds": current_setup,
134
+ "delta_seconds": (current_setup - planned_setup)
135
+ if (current_setup is not None and planned_setup is not None)
136
+ else None,
137
+ },
138
+ },
139
+ }
140
+
141
+
142
+ def get_time_performance_data_v3(session: Session, pdord_ids: Sequence[int]):
143
+ raw_fetch_sql = text(
144
+ """
145
+ select
146
+ pdord_id,
147
+ pdord_planstarttime,
148
+ pdord_starttime,
149
+ pdord_planstoptime,
150
+ pdord_stoptime,
151
+ pdord_qty,
152
+ pdord_plancycletime,
153
+ pdord_plansetuptime,
154
+
155
+ pdordtimestat_activetime,
156
+ pdordtimestat_cycle_time,
157
+ pdordtimestat_setuptime,
158
+ pdordtimestat_spentindowntime,
159
+
160
+ -- Grab current state flags directly from the lifecycle table
161
+ pdordlc_isactive as is_active_state,
162
+ pdordlc_isfinal as is_final_state,
163
+
164
+ (select sum(pdordprd_quantity)
165
+ from pdordprd where pdordprd_pdord_id = pdord_id) as produced_quantity,
166
+
167
+ (select min(pdordlch_createtime)
168
+ from pdordlch
169
+ join pdordlct on pdordlch_pdordlct_id = pdordlct_id
170
+ join pdordlc from_lc on from_lc.pdordlc_id = pdordlct_from_pdordlc_id
171
+ join pdordlc to_lc on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
172
+ where pdordlch_pdord_id = pdord_id
173
+ and from_lc.pdordlc_isactive = false
174
+ and to_lc.pdordlc_isactive = true) as actual_start_time,
175
+ (select max(pdordlch_createtime)
176
+ from pdordlch
177
+ join pdordlct on pdordlch_pdordlct_id = pdordlct_id
178
+ join pdordlc to_lc on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
179
+ where pdordlch_pdord_id = pdord_id
180
+ and to_lc.pdordlc_isfinal = true) as actual_end_time
181
+ from pdord
182
+ join pdordlc on pdordlc_id = pdord_pdordlc_id
183
+ left join pdord_timestat on pdordtimestat_pdord_id = pdord_id
184
+ where pdord_id in :pdord_id_array
185
+ """
186
+ )
187
+
188
+ params = {"pdord_id_array": pdord_ids}
189
+ stm = raw_fetch_sql.bindparams(bindparam("pdord_id_array", expanding=True))
190
+ resultset = session.execute(stm, params).mappings().fetchall()
191
+
192
+ return {
193
+ str(row["pdord_id"]): _process_performance_in_pure_python(row)
194
+ for row in resultset
195
+ }
196
+
197
+
198
+ def get_time_performance_data_v2(session: Session, pdord_ids: Sequence[int]):
199
+
200
+ from_lc = aliased(PdOrdLc)
201
+ to_lc = aliased(PdOrdLc)
202
+
203
+ prd_sq = (
204
+ select(func.sum(PdOrdPrd.quantity))
205
+ .where(PdOrdPrd.pdord_id == PdOrd.id)
206
+ .scalar_subquery()
207
+ .label("produced_quantity")
208
+ )
209
+
210
+ start_sq = (
211
+ select(func.min(PdOrdLch.createtime))
212
+ .select_from(PdOrdLch)
213
+ .join(PdOrdLct, PdOrdLch.pdordlct_id == PdOrdLct.id)
214
+ .join(from_lc, from_lc.id == PdOrdLct.from_pdordlc_id)
215
+ .join(to_lc, to_lc.id == PdOrdLct.to_pdordlc_id)
216
+ .where(
217
+ PdOrdLch.pdord_id == PdOrd.id,
218
+ from_lc.isactive.is_(False),
219
+ to_lc.isactive,
220
+ )
221
+ .scalar_subquery()
222
+ .label("actual_start_time")
223
+ )
224
+
225
+ end_sq = (
226
+ select(func.max(PdOrdLch.createtime))
227
+ .select_from(PdOrdLch)
228
+ .join(PdOrdLct, PdOrdLch.pdordlct_id == PdOrdLct.id)
229
+ .join(to_lc, to_lc.id == PdOrdLct.to_pdordlc_id)
230
+ .where(PdOrdLch.pdord_id == PdOrd.id, to_lc.isfinal)
231
+ .scalar_subquery()
232
+ .label("actual_end_time")
233
+ )
234
+
235
+ stm = (
236
+ select(
237
+ PdOrd.id.label("pdord_id"),
238
+ PdOrd.planstarttime.label("pdord_planstarttime"),
239
+ PdOrd.starttime.label("pdord_starttime"),
240
+ PdOrd.planstoptime.label("pdord_planstoptime"),
241
+ PdOrd.stoptime.label("pdord_stoptime"),
242
+ PdOrd.qty.label("pdord_qty"),
243
+ PdOrd.plancycletime.label("pdord_plancycletime"),
244
+ PdOrd.plansetuptime.label("pdord_plansetuptime"),
245
+ PdOrdTimeStat.activetime.label("pdordtimestat_activetime"),
246
+ PdOrdTimeStat.cycle_time.label("pdordtimestat_cycle_time"),
247
+ PdOrdTimeStat.setuptime.label("pdordtimestat_setuptime"),
248
+ PdOrdTimeStat.spentindowntime.label("pdordtimestat_spentindowntime"),
249
+ PdOrdLc.isactive.label("is_active_state"),
250
+ PdOrdLc.isfinal.label("is_final_state"),
251
+ prd_sq,
252
+ start_sq,
253
+ end_sq,
254
+ )
255
+ .join(PdOrdLc, PdOrdLc.id == PdOrd.pdordlc_id)
256
+ .outerjoin(PdOrdTimeStat, PdOrdTimeStat.id == PdOrd.id)
257
+ .where(PdOrd.id.in_(pdord_ids))
258
+ )
259
+
260
+ resultset = session.execute(stm).mappings().fetchall()
261
+
262
+ return {
263
+ str(row["pdord_id"]): _process_performance_in_pure_python(row)
264
+ for row in resultset
265
+ }
266
+
267
+
268
+ def get_time_performance_data_v4(session: Session, pdord_ids: Sequence[int]):
269
+ pdords = (
270
+ session.execute(
271
+ select(
272
+ PdOrd.id,
273
+ PdOrd.pdordlc_id,
274
+ PdOrd.planstarttime,
275
+ PdOrd.starttime,
276
+ PdOrd.planstoptime,
277
+ PdOrd.stoptime,
278
+ PdOrd.qty,
279
+ PdOrd.plancycletime,
280
+ PdOrd.plansetuptime,
281
+ ).where(PdOrd.id.in_(pdord_ids))
282
+ )
283
+ .mappings()
284
+ .all()
285
+ )
286
+ if not pdords:
287
+ return {}
288
+
289
+ pdord_ids = [pdord["id"] for pdord in pdords]
290
+ pdord_lc_ids = {pdord["pdordlc_id"] for pdord in pdords}
291
+
292
+ timestats = (
293
+ session.execute(
294
+ select(
295
+ PdOrdTimeStat.id,
296
+ PdOrdTimeStat.activetime,
297
+ PdOrdTimeStat.cycle_time,
298
+ PdOrdTimeStat.setuptime,
299
+ PdOrdTimeStat.spentindowntime,
300
+ ).where(PdOrdTimeStat.id.in_(pdord_ids))
301
+ )
302
+ .mappings()
303
+ .all()
304
+ )
305
+ timestat_by_pdord_id = {row["id"]: row for row in timestats}
306
+
307
+ prd_rows = session.execute(
308
+ select(PdOrdPrd.pdord_id, PdOrdPrd.quantity).where(
309
+ PdOrdPrd.pdord_id.in_(pdord_ids)
310
+ )
311
+ ).all()
312
+ produced_by_pdord_id: dict[int, float] = {}
313
+ for prd_pdord_id, prd_quantity in prd_rows:
314
+ produced_by_pdord_id[prd_pdord_id] = produced_by_pdord_id.get(
315
+ prd_pdord_id, 0.0
316
+ ) + float(prd_quantity or 0.0)
317
+
318
+ lch_rows = (
319
+ session.execute(
320
+ select(PdOrdLch.pdord_id, PdOrdLch.pdordlct_id, PdOrdLch.createtime).where(
321
+ PdOrdLch.pdord_id.in_(pdord_ids)
322
+ )
323
+ )
324
+ .mappings()
325
+ .all()
326
+ )
327
+
328
+ lct_ids = {row["pdordlct_id"] for row in lch_rows}
329
+ lct_rows = (
330
+ session.execute(
331
+ select(PdOrdLct.id, PdOrdLct.from_pdordlc_id, PdOrdLct.to_pdordlc_id).where(
332
+ PdOrdLct.id.in_(lct_ids)
333
+ )
334
+ )
335
+ .mappings()
336
+ .all()
337
+ if lct_ids
338
+ else []
339
+ )
340
+ lct_by_id = {row["id"]: row for row in lct_rows}
341
+
342
+ transition_lc_ids = {
343
+ lc_id
344
+ for row in lct_rows
345
+ for lc_id in (row["from_pdordlc_id"], row["to_pdordlc_id"])
346
+ }
347
+ all_lc_ids = pdord_lc_ids | transition_lc_ids
348
+ lc_rows = (
349
+ session.execute(
350
+ select(PdOrdLc.id, PdOrdLc.isactive, PdOrdLc.isfinal).where(
351
+ PdOrdLc.id.in_(all_lc_ids)
352
+ )
353
+ )
354
+ .mappings()
355
+ .all()
356
+ if all_lc_ids
357
+ else []
358
+ )
359
+ lc_by_id = {row["id"]: row for row in lc_rows}
360
+
361
+ history_by_pdord_id: dict[int, list[RowMapping]] = {}
362
+ for row in lch_rows:
363
+ history_by_pdord_id.setdefault(row["pdord_id"], []).append(row)
364
+
365
+ result: Dict[str, Any] = {}
366
+ for pdord in pdords:
367
+ actual_start_time = None
368
+ actual_end_time = None
369
+
370
+ for history in history_by_pdord_id.get(pdord["id"], []):
371
+ transition = lct_by_id.get(history["pdordlct_id"])
372
+ if transition is None:
373
+ continue
374
+
375
+ from_lc = lc_by_id.get(transition["from_pdordlc_id"])
376
+ to_lc = lc_by_id.get(transition["to_pdordlc_id"])
377
+
378
+ if (
379
+ from_lc is not None
380
+ and to_lc is not None
381
+ and (from_lc["isactive"] is False)
382
+ and bool(to_lc["isactive"])
383
+ ):
384
+ if (
385
+ actual_start_time is None
386
+ or history["createtime"] < actual_start_time
387
+ ):
388
+ actual_start_time = history["createtime"]
389
+
390
+ if to_lc is not None and bool(to_lc["isfinal"]):
391
+ if actual_end_time is None or history["createtime"] > actual_end_time:
392
+ actual_end_time = history["createtime"]
393
+
394
+ timestat = timestat_by_pdord_id.get(pdord["id"])
395
+ order_lc = lc_by_id.get(pdord["pdordlc_id"])
396
+ raw_data = {
397
+ "pdord_id": pdord["id"],
398
+ "pdord_planstarttime": pdord["planstarttime"],
399
+ "pdord_starttime": pdord["starttime"],
400
+ "pdord_planstoptime": pdord["planstoptime"],
401
+ "pdord_stoptime": pdord["stoptime"],
402
+ "pdord_qty": pdord["qty"],
403
+ "pdord_plancycletime": pdord["plancycletime"],
404
+ "pdord_plansetuptime": pdord["plansetuptime"],
405
+ "pdordtimestat_activetime": (
406
+ None if timestat is None else timestat["activetime"]
407
+ ),
408
+ "pdordtimestat_cycle_time": (
409
+ None if timestat is None else timestat["cycle_time"]
410
+ ),
411
+ "pdordtimestat_setuptime": (
412
+ None if timestat is None else timestat["setuptime"]
413
+ ),
414
+ "pdordtimestat_spentindowntime": (
415
+ None if timestat is None else timestat["spentindowntime"]
416
+ ),
417
+ "is_active_state": None if order_lc is None else order_lc["isactive"],
418
+ "is_final_state": None if order_lc is None else order_lc["isfinal"],
419
+ "produced_quantity": produced_by_pdord_id.get(pdord["id"], 0.0),
420
+ "actual_start_time": actual_start_time,
421
+ "actual_end_time": actual_end_time,
422
+ }
423
+ result[str(pdord["id"])] = _process_performance_in_pure_python(raw_data)
424
+
425
+ return result