perfact-api-pd-fastapi 0.2__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.
- perfact_api_pd_fastapi-0.2/PKG-INFO +33 -0
- perfact_api_pd_fastapi-0.2/README.md +17 -0
- perfact_api_pd_fastapi-0.2/pyproject.toml +46 -0
- perfact_api_pd_fastapi-0.2/setup.cfg +4 -0
- perfact_api_pd_fastapi-0.2/src/perfact/api/pd_fastapi/__init__.py +17 -0
- perfact_api_pd_fastapi-0.2/src/perfact/api/pd_fastapi/kpi.py +226 -0
- perfact_api_pd_fastapi-0.2/src/perfact/api/pd_fastapi/locustfile.py +72 -0
- perfact_api_pd_fastapi-0.2/src/perfact/api/pd_fastapi/pdunit.py +42 -0
- perfact_api_pd_fastapi-0.2/src/perfact/api/pd_fastapi/performancedata.py +701 -0
- perfact_api_pd_fastapi-0.2/src/perfact/api/pd_fastapi/py.typed +0 -0
- perfact_api_pd_fastapi-0.2/src/perfact_api_pd_fastapi.egg-info/PKG-INFO +33 -0
- perfact_api_pd_fastapi-0.2/src/perfact_api_pd_fastapi.egg-info/SOURCES.txt +15 -0
- perfact_api_pd_fastapi-0.2/src/perfact_api_pd_fastapi.egg-info/dependency_links.txt +1 -0
- perfact_api_pd_fastapi-0.2/src/perfact_api_pd_fastapi.egg-info/entry_points.txt +2 -0
- perfact_api_pd_fastapi-0.2/src/perfact_api_pd_fastapi.egg-info/requires.txt +4 -0
- perfact_api_pd_fastapi-0.2/src/perfact_api_pd_fastapi.egg-info/top_level.txt +1 -0
- perfact_api_pd_fastapi-0.2/tox.ini +34 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perfact-api-pd-fastapi
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: PerFact API - SQLAlchemy+FastAPI pd apis package
|
|
5
|
+
Author-email: Viktor Dick <viktor.dick@perfact.de>
|
|
6
|
+
License-Expression: GPL-2.0-or-later
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: SQL
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]
|
|
13
|
+
Requires-Dist: perfact-api-pp
|
|
14
|
+
Requires-Dist: perfact-api-pd
|
|
15
|
+
Requires-Dist: perfact-api-main
|
|
16
|
+
|
|
17
|
+
# PerFact API - pd - APIs
|
|
18
|
+
|
|
19
|
+
This package contains all APIs related to the Pd-module ('Production Execution') of the PerFact software.
|
|
20
|
+
All available APIs are added to the main OpenAPI specification automatically.
|
|
21
|
+
|
|
22
|
+
## getting started
|
|
23
|
+
This module is discovered and hosted by the `PythonPackages/perfact-api-base`.
|
|
24
|
+
As this package does not contain an own main entrypoint, you have to install it into the base and run that one.
|
|
25
|
+
Please refer to the documentation of the *API Base* to get more information about how the discovery works and how to include this module to your app bundle.
|
|
26
|
+
If everything is working, you should see this module be added to your bundle instance in the log:
|
|
27
|
+
```
|
|
28
|
+
2026-[...] - auth.app - INFO - start plugin discovery
|
|
29
|
+
[...]
|
|
30
|
+
2026-[...] - auth.app - INFO - try to include plugin: perfact.api.pd_fastapi:mount
|
|
31
|
+
[...]
|
|
32
|
+
2026-[...] - auth.app - INFO - finished discovery and include: X plugins
|
|
33
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# PerFact API - pd - APIs
|
|
2
|
+
|
|
3
|
+
This package contains all APIs related to the Pd-module ('Production Execution') of the PerFact software.
|
|
4
|
+
All available APIs are added to the main OpenAPI specification automatically.
|
|
5
|
+
|
|
6
|
+
## getting started
|
|
7
|
+
This module is discovered and hosted by the `PythonPackages/perfact-api-base`.
|
|
8
|
+
As this package does not contain an own main entrypoint, you have to install it into the base and run that one.
|
|
9
|
+
Please refer to the documentation of the *API Base* to get more information about how the discovery works and how to include this module to your app bundle.
|
|
10
|
+
If everything is working, you should see this module be added to your bundle instance in the log:
|
|
11
|
+
```
|
|
12
|
+
2026-[...] - auth.app - INFO - start plugin discovery
|
|
13
|
+
[...]
|
|
14
|
+
2026-[...] - auth.app - INFO - try to include plugin: perfact.api.pd_fastapi:mount
|
|
15
|
+
[...]
|
|
16
|
+
2026-[...] - auth.app - INFO - finished discovery and include: X plugins
|
|
17
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.2", "setuptools-scm>=8.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "perfact-api-pd-fastapi"
|
|
7
|
+
authors = [
|
|
8
|
+
{name="Viktor Dick", email="viktor.dick@perfact.de"},
|
|
9
|
+
]
|
|
10
|
+
description = "PerFact API - SQLAlchemy+FastAPI pd apis package"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "GPL-2.0-or-later"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: SQL",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"fastapi[standard-no-fastapi-cloud-cli]",
|
|
20
|
+
"perfact-api-pp",
|
|
21
|
+
"perfact-api-pd",
|
|
22
|
+
"perfact-api-main",
|
|
23
|
+
]
|
|
24
|
+
dynamic = ["version"]
|
|
25
|
+
requires-python = ">=3.10"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
|
|
29
|
+
[tool.distutils.bdist_wheel]
|
|
30
|
+
universal = 1
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
include-package-data = true
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools_scm]
|
|
39
|
+
root = ".."
|
|
40
|
+
fallback_version = "0.0.0"
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
plugins = ["sqlalchemy.ext.mypy.plugin"]
|
|
44
|
+
|
|
45
|
+
[project.entry-points.'perfact.api']
|
|
46
|
+
pd = 'perfact.api.pd_fastapi:mount'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from perfact.api.main.dbsession import APIRouter
|
|
3
|
+
|
|
4
|
+
from .kpi import mount as kpi_mount
|
|
5
|
+
from .pdunit import mount as pdunitmount
|
|
6
|
+
from .performancedata import mount as performancemount
|
|
7
|
+
|
|
8
|
+
routes = APIRouter()
|
|
9
|
+
routes_pdord = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mount(app: FastAPI):
|
|
13
|
+
kpi_mount(routes)
|
|
14
|
+
pdunitmount(routes)
|
|
15
|
+
performancemount(routes_pdord)
|
|
16
|
+
app.include_router(routes, prefix="/pd")
|
|
17
|
+
app.include_router(routes_pdord, prefix="/pd/pdord")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from perfact.api.main.auth import require_roles
|
|
5
|
+
from perfact.api.main.dbsession import APIRouter, DBSession
|
|
6
|
+
from perfact.api.pd.model import PdOrdLch, PdOrdPrd
|
|
7
|
+
from perfact.api.pp.model import PpRsrc, PpShft
|
|
8
|
+
from pydantic import BeforeValidator
|
|
9
|
+
from sqlalchemy import func, select
|
|
10
|
+
|
|
11
|
+
routes = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_datetime_with_UTC_offset(value: str) -> datetime:
|
|
15
|
+
try:
|
|
16
|
+
return datetime.fromisoformat(value)
|
|
17
|
+
except ValueError:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"Invalid datetime format. \
|
|
20
|
+
Expected: yyyy-mm-ddThh:mm:ss.sssss+HH:00, got: {value}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
UTCOffsetDatetime = Annotated[datetime, BeforeValidator(parse_datetime_with_UTC_offset)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def availability_computation(
|
|
28
|
+
session: DBSession, pdunit_id: int, starttime: datetime, stoptime: datetime, /
|
|
29
|
+
) -> float:
|
|
30
|
+
"""OEE function for computation of availability"""
|
|
31
|
+
search_range = func.tstzrange(starttime, stoptime)
|
|
32
|
+
n_intersection = PdOrdLch.lchtimerange.op("*")(search_range)
|
|
33
|
+
in_seconds = func.extract(
|
|
34
|
+
"epoch", func.upper(n_intersection) - func.lower(n_intersection)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
current_available_time_nq = session.scalar(
|
|
38
|
+
select(
|
|
39
|
+
PdOrdLch.availability_missing_active_operations_nominator(
|
|
40
|
+
pdunit_id, starttime, stoptime
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
current_available_time_n = float(current_available_time_nq or 0.0)
|
|
45
|
+
|
|
46
|
+
current_available_time_dq = session.scalar(
|
|
47
|
+
select(
|
|
48
|
+
PdOrdLch.availability_missing_active_operations_denominator(
|
|
49
|
+
pdunit_id, starttime, stoptime
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
current_available_time_d = float(current_available_time_dq or 0.0)
|
|
54
|
+
|
|
55
|
+
# nominator calculation
|
|
56
|
+
nominator_q = session.execute(
|
|
57
|
+
select(func.coalesce(func.sum(in_seconds), 0.0))
|
|
58
|
+
.where(PdOrdLch.availability_nominator_eligible(pdunit_id).is_(True))
|
|
59
|
+
.where(PdOrdLch.lchtimerange.op("&&")(search_range))
|
|
60
|
+
).scalar()
|
|
61
|
+
nominator = float(nominator_q or 0.0) + current_available_time_n
|
|
62
|
+
|
|
63
|
+
# denominator calculation
|
|
64
|
+
d_intersection = n_intersection.op("*")(PpShft.shiftrange)
|
|
65
|
+
in_seconds = func.extract(
|
|
66
|
+
"epoch", func.upper(d_intersection) - func.lower(d_intersection)
|
|
67
|
+
)
|
|
68
|
+
denominator_q = session.execute(
|
|
69
|
+
select(func.coalesce(func.sum(in_seconds), 1.0))
|
|
70
|
+
.select_from(PdOrdLch)
|
|
71
|
+
.join(PpRsrc, PpRsrc.pdunit_id == pdunit_id)
|
|
72
|
+
.join(PpShft, PpShft.pdunit_id == PpRsrc.id)
|
|
73
|
+
.where(PdOrdLch.availability_denominator_eligible(pdunit_id).is_(True))
|
|
74
|
+
.where(PdOrdLch.lchtimerange.op("&&")(search_range))
|
|
75
|
+
.where(n_intersection.op("&&")(PpShft.shiftrange))
|
|
76
|
+
).scalar()
|
|
77
|
+
denominator = nominator + float(denominator_q or 0.0) + current_available_time_d
|
|
78
|
+
|
|
79
|
+
availability = nominator / denominator
|
|
80
|
+
|
|
81
|
+
return float(availability)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@routes.get("/kpi/oee/availability", tags=["pd"], summary="Route for OEE availability")
|
|
85
|
+
@require_roles("PdKpi")
|
|
86
|
+
def oee_availability(
|
|
87
|
+
session: DBSession,
|
|
88
|
+
pdunit_id: int,
|
|
89
|
+
# ex w timezone "2025-05-20T13:54:15.555103+03:00"
|
|
90
|
+
# ex wo timezone "2025-05-20T13:54:15.555103"
|
|
91
|
+
starttime: datetime,
|
|
92
|
+
# ex w timezone "2025-05-22T11:53:13.561411+03:00"
|
|
93
|
+
# ex wo timezone "2025-05-22T11:53:13.561411"
|
|
94
|
+
stoptime: datetime,
|
|
95
|
+
) -> float:
|
|
96
|
+
"""
|
|
97
|
+
Route for OEE availability
|
|
98
|
+
Uses the availability_computation function
|
|
99
|
+
Returns single floating point value
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
result = availability_computation(session, pdunit_id, starttime, stoptime)
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def performance_computation(
|
|
108
|
+
session: DBSession, pdunit_id: int, starttime: datetime, stoptime: datetime, /
|
|
109
|
+
) -> float:
|
|
110
|
+
"""OEE function for computation of performance"""
|
|
111
|
+
performance = session.execute(
|
|
112
|
+
select(
|
|
113
|
+
func.coalesce(
|
|
114
|
+
func.sum(PdOrdPrd.ideal_time_over_time_range(starttime, stoptime)), 0.0
|
|
115
|
+
)
|
|
116
|
+
/ func.nullif(
|
|
117
|
+
func.coalesce(
|
|
118
|
+
func.sum(PdOrdPrd.actual_time_over_time_range(starttime, stoptime)),
|
|
119
|
+
0.0,
|
|
120
|
+
),
|
|
121
|
+
0.0,
|
|
122
|
+
)
|
|
123
|
+
).where(PdOrdPrd.performance_eligible(pdunit_id).is_(True))
|
|
124
|
+
).scalar()
|
|
125
|
+
|
|
126
|
+
if not performance:
|
|
127
|
+
performance = 0
|
|
128
|
+
|
|
129
|
+
return float(performance)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@routes.get("/kpi/oee/performance", tags=["pd"], summary="Route for OEE performance")
|
|
133
|
+
@require_roles("PdKpi")
|
|
134
|
+
def oee_performance(
|
|
135
|
+
session: DBSession,
|
|
136
|
+
pdunit_id: int,
|
|
137
|
+
# ex w timezone "2025-05-20T13:54:15.555103+03:00"
|
|
138
|
+
# ex wo timezone "2025-05-20T13:54:15.555103"
|
|
139
|
+
starttime: datetime,
|
|
140
|
+
# ex w timezone "2025-05-22T11:53:13.561411+03:00"
|
|
141
|
+
# ex wo timezone "2025-05-22T11:53:13.561411"
|
|
142
|
+
stoptime: datetime,
|
|
143
|
+
) -> float:
|
|
144
|
+
"""
|
|
145
|
+
Route for OEE availability
|
|
146
|
+
Uses the performance_computation function
|
|
147
|
+
Returns single floating point value
|
|
148
|
+
"""
|
|
149
|
+
result = performance_computation(session, pdunit_id, starttime, stoptime)
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def quality_computation(
|
|
155
|
+
session: DBSession, pdunit_id: int, starttime: datetime, stoptime: datetime, /
|
|
156
|
+
) -> float:
|
|
157
|
+
"""OEE function computation for quality"""
|
|
158
|
+
quality = session.execute(
|
|
159
|
+
select(
|
|
160
|
+
func.sum(PdOrdPrd.good_quantity_over_range(starttime, stoptime))
|
|
161
|
+
/ func.sum(PdOrdPrd.total_quantity_over_range(starttime, stoptime))
|
|
162
|
+
).where(PdOrdPrd.quality_eligible(pdunit_id).is_(True))
|
|
163
|
+
).scalar()
|
|
164
|
+
|
|
165
|
+
if not quality:
|
|
166
|
+
quality = 0
|
|
167
|
+
|
|
168
|
+
return float(quality)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@routes.get("/kpi/oee/quality", tags=["pd"], summary="Route for OEE quality")
|
|
172
|
+
# response_model=Sequence[PdordlchGet])
|
|
173
|
+
@require_roles("PdKpi")
|
|
174
|
+
def oee_quality(
|
|
175
|
+
session: DBSession,
|
|
176
|
+
pdunit_id: int,
|
|
177
|
+
# ex w timezone "2025-05-20T13:54:15.555103+03:00"
|
|
178
|
+
# ex wo timezone "2025-05-20T13:54:15.555103"
|
|
179
|
+
starttime: datetime,
|
|
180
|
+
# ex w timezone "2025-05-22T11:53:13.561411+03:00"
|
|
181
|
+
# ex wo timezone "2025-05-22T11:53:13.561411"
|
|
182
|
+
stoptime: datetime,
|
|
183
|
+
) -> float:
|
|
184
|
+
"""
|
|
185
|
+
Route for OEE quality
|
|
186
|
+
Uses the quality_computation function
|
|
187
|
+
Returns single floating point value
|
|
188
|
+
"""
|
|
189
|
+
result = quality_computation(session, pdunit_id, starttime, stoptime)
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@routes.get("/kpi/oee", tags=["pd"], summary="OEE calculation")
|
|
195
|
+
# response_model=Sequence[PdordlchGet])
|
|
196
|
+
@require_roles("PdKpi")
|
|
197
|
+
def oee_calculation(
|
|
198
|
+
session: DBSession,
|
|
199
|
+
pdunit_id: int,
|
|
200
|
+
# ex w timezone "2025-05-20T13:54:15.555103+03:00"
|
|
201
|
+
# ex wo timezone "2025-05-20T13:54:15.555103"
|
|
202
|
+
starttime: datetime,
|
|
203
|
+
# ex w timezone "2025-05-22T11:53:13.561411+03:00"
|
|
204
|
+
# ex wo timezone "2025-05-22T11:53:13.561411"
|
|
205
|
+
stoptime: datetime,
|
|
206
|
+
) -> dict:
|
|
207
|
+
"""
|
|
208
|
+
OEE route for complete calculations: availability, performance, quality
|
|
209
|
+
Returns dict/json type response containing
|
|
210
|
+
all results under their specific category
|
|
211
|
+
"""
|
|
212
|
+
availability = availability_computation(session, pdunit_id, starttime, stoptime)
|
|
213
|
+
performance = performance_computation(session, pdunit_id, starttime, stoptime)
|
|
214
|
+
quality = quality_computation(session, pdunit_id, starttime, stoptime)
|
|
215
|
+
oee = availability * performance * quality
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"availability": availability,
|
|
219
|
+
"performance": performance,
|
|
220
|
+
"quality": quality,
|
|
221
|
+
"oee": oee,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def mount(app: APIRouter):
|
|
226
|
+
app.include_router(routes)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Locust load test for all three /get_time_performance_data endpoints.
|
|
3
|
+
- Each task POSTs a JSON array of pdunit_ids to one endpoint.
|
|
4
|
+
- Payloads are generated once per user, reused for all endpoints.
|
|
5
|
+
- Tracks comparative performance and error rates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from itertools import cycle
|
|
11
|
+
|
|
12
|
+
from locust import HttpUser, between, task
|
|
13
|
+
|
|
14
|
+
API_BASE = os.getenv("API_BASE_PATH", "/api/pd/pdord")
|
|
15
|
+
ALL_ENDPOINTS = [
|
|
16
|
+
"/get_time_performance_data_v1", # Pure SQL
|
|
17
|
+
"/get_time_performance_data_v2", # ORM
|
|
18
|
+
"/get_time_performance_data_v3", # Pure Python SQL
|
|
19
|
+
"/get_time_performance_data_v4", # Pure Python ORM
|
|
20
|
+
]
|
|
21
|
+
PDUNIT_ID_POOL = list(range(2000, 3001))
|
|
22
|
+
PDUNITS_PER_REQUEST = 10
|
|
23
|
+
|
|
24
|
+
API_KEY = os.getenv("API_KEY")
|
|
25
|
+
API_KEY_HEADER = os.getenv("API_KEY_HEADER", "Authorization")
|
|
26
|
+
API_KEY_PREFIX = os.getenv("API_KEY_PREFIX", "Apikey")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PdUnitPerformanceUser(HttpUser):
|
|
30
|
+
wait_time = between(0.5, 2.0)
|
|
31
|
+
|
|
32
|
+
def on_start(self):
|
|
33
|
+
self.client.trust_env = False
|
|
34
|
+
pool_len = len(PDUNIT_ID_POOL)
|
|
35
|
+
start = abs(hash((os.getpid(), id(self)))) % pool_len
|
|
36
|
+
self.pdunit_ids = [
|
|
37
|
+
PDUNIT_ID_POOL[(start + i) % pool_len] for i in range(PDUNITS_PER_REQUEST)
|
|
38
|
+
]
|
|
39
|
+
self.headers = {"Content-Type": "application/json"}
|
|
40
|
+
if API_KEY:
|
|
41
|
+
self.headers[API_KEY_HEADER] = f"{API_KEY_PREFIX} {API_KEY}"
|
|
42
|
+
self.endpoint_cycle = cycle(ALL_ENDPOINTS)
|
|
43
|
+
|
|
44
|
+
@task
|
|
45
|
+
def post_next_endpoint(self):
|
|
46
|
+
self._post_to_endpoint(next(self.endpoint_cycle))
|
|
47
|
+
|
|
48
|
+
def _post_to_endpoint(self, endpoint):
|
|
49
|
+
url = API_BASE + endpoint
|
|
50
|
+
payload = json.dumps(self.pdunit_ids)
|
|
51
|
+
with self.client.post(
|
|
52
|
+
url,
|
|
53
|
+
data=payload,
|
|
54
|
+
headers=self.headers,
|
|
55
|
+
catch_response=True,
|
|
56
|
+
name=endpoint,
|
|
57
|
+
) as resp:
|
|
58
|
+
if resp.status_code != 200:
|
|
59
|
+
resp.failure(f"{endpoint} failed: {resp.status_code}")
|
|
60
|
+
else:
|
|
61
|
+
try:
|
|
62
|
+
data = resp.json()
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
resp.failure(f"{endpoint} returned non-dict response")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
resp.failure(f"{endpoint} invalid JSON: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# How to use currently:
|
|
70
|
+
# locust -f locustfile.py --host http://127.0.0.1:8000
|
|
71
|
+
# --web-host 127.0.0.1 --web-port 8010
|
|
72
|
+
# add your key export API_KEY='your_key'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Optional, Sequence
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
from perfact.api.base.model import PydanticBase
|
|
5
|
+
from perfact.api.main import Auth, DBSession, VisibilityRegistryDep, require_roles
|
|
6
|
+
from perfact.api.main.dbsession import APIRouter
|
|
7
|
+
from perfact.api.pd.model import PdUnit
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
routes = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PdUnitGet(PydanticBase):
|
|
14
|
+
name: Optional[str]
|
|
15
|
+
num: str
|
|
16
|
+
overprodmay: bool
|
|
17
|
+
overprodfactor: float
|
|
18
|
+
seqnum: Optional[int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@routes.get(
|
|
22
|
+
"/pdunit",
|
|
23
|
+
tags=["pd"],
|
|
24
|
+
summary="Get all visible PdUnit",
|
|
25
|
+
response_model=Sequence[PdUnitGet],
|
|
26
|
+
)
|
|
27
|
+
@require_roles("PdRead")
|
|
28
|
+
def pdunit_get_all(
|
|
29
|
+
session: DBSession, auth: Auth, visibility: VisibilityRegistryDep
|
|
30
|
+
) -> Sequence[PdUnit]:
|
|
31
|
+
"""
|
|
32
|
+
returns all PdUnit which the current user is allowed to see.
|
|
33
|
+
"""
|
|
34
|
+
policy = visibility.get(PdUnit)
|
|
35
|
+
if auth is None:
|
|
36
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
37
|
+
|
|
38
|
+
return session.execute(select(PdUnit).filter(policy.filter(auth))).scalars().all()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def mount(app: APIRouter):
|
|
42
|
+
app.include_router(routes)
|
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
from typing import Any, Dict, Mapping, Optional, Sequence
|
|
3
|
+
|
|
4
|
+
from perfact.api.main.auth import Auth, require_roles
|
|
5
|
+
from perfact.api.main.dbsession import APIRouter, DBSession
|
|
6
|
+
from perfact.api.pd.model.pdord import PdOrd
|
|
7
|
+
from perfact.api.pd.model.pdord import PdOrd_TimeStat as PdOrdTimeStat
|
|
8
|
+
from perfact.api.pd.model.pdordlc import PdOrdLc
|
|
9
|
+
from perfact.api.pd.model.pdordlch import PdOrdLch
|
|
10
|
+
from perfact.api.pd.model.pdordlct import PdOrdLct
|
|
11
|
+
from perfact.api.pd.model.pdordprd import PdOrdPrd
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from sqlalchemy import bindparam, func, select, text
|
|
14
|
+
from sqlalchemy.engine import RowMapping
|
|
15
|
+
from sqlalchemy.orm import aliased
|
|
16
|
+
|
|
17
|
+
routes = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PdUnitPerformanceGetTime(BaseModel):
|
|
21
|
+
estimated_start: Optional[datetime]
|
|
22
|
+
estimated_end: Optional[datetime]
|
|
23
|
+
delta_start_seconds: Optional[float]
|
|
24
|
+
delta_end_seconds: Optional[float]
|
|
25
|
+
delta_planned_duration_seconds: Optional[float]
|
|
26
|
+
estimated_duration_seconds: Optional[float]
|
|
27
|
+
delta_duration_seconds: Optional[float]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PdUnitPerformanceGetStatus(BaseModel):
|
|
31
|
+
running_time_seconds: Optional[float]
|
|
32
|
+
remaining_time_seconds: Optional[float]
|
|
33
|
+
downtime_seconds: Optional[float]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PdUnitPerformanceGetPerformanceTime(BaseModel):
|
|
37
|
+
planned_seconds: Optional[float]
|
|
38
|
+
current_seconds: Optional[float]
|
|
39
|
+
delta_seconds: Optional[float]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PdUnitPerformanceGetPerformanceTimeSetup(BaseModel):
|
|
43
|
+
current_seconds: Optional[float]
|
|
44
|
+
delta_seconds: Optional[float]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PdUnitPerformanceGetPerformance(BaseModel):
|
|
48
|
+
cycle_time: Optional[PdUnitPerformanceGetPerformanceTime]
|
|
49
|
+
setup_time: Optional[PdUnitPerformanceGetPerformanceTimeSetup]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PdUnitPerformanceGet(BaseModel):
|
|
53
|
+
time_management: Optional[PdUnitPerformanceGetTime]
|
|
54
|
+
operation_status: Optional[PdUnitPerformanceGetStatus]
|
|
55
|
+
performance: Optional[PdUnitPerformanceGetPerformance]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_performance_row(data_row):
|
|
59
|
+
return {
|
|
60
|
+
"time_management": {
|
|
61
|
+
"estimated_start": data_row.estimated_actual_start_raw,
|
|
62
|
+
"estimated_end": data_row.estimated_end_raw,
|
|
63
|
+
"delta_start_seconds": data_row.delta_start_seconds,
|
|
64
|
+
"delta_end_seconds": data_row.delta_end_seconds,
|
|
65
|
+
"delta_planned_duration_seconds": data_row.delta_planned_duration_seconds,
|
|
66
|
+
"estimated_duration_seconds": data_row.estimated_duration_seconds,
|
|
67
|
+
"delta_duration_seconds": data_row.delta_duration_seconds,
|
|
68
|
+
},
|
|
69
|
+
"operation_status": {
|
|
70
|
+
"running_time_seconds": data_row.running_time_seconds,
|
|
71
|
+
"remaining_time_seconds": data_row.remaining_time_seconds,
|
|
72
|
+
"downtime_seconds": data_row.downtime_seconds,
|
|
73
|
+
},
|
|
74
|
+
# Performance Metrics
|
|
75
|
+
"performance": {
|
|
76
|
+
"cycle_time": {
|
|
77
|
+
"planned_seconds": data_row.planned_cycle_time_seconds,
|
|
78
|
+
"current_seconds": data_row.current_cycle_time_seconds,
|
|
79
|
+
"delta_seconds": data_row.delta_cycle_time_seconds,
|
|
80
|
+
},
|
|
81
|
+
"setup_time": {
|
|
82
|
+
"current_seconds": data_row.current_setup_time_seconds,
|
|
83
|
+
"delta_seconds": data_row.delta_setup_time_seconds,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _safe_delta(end: Any, start: Any):
|
|
90
|
+
if end and start:
|
|
91
|
+
return (end - start).total_seconds()
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _td_to_sec(td: Any):
|
|
96
|
+
return td.total_seconds() if td is not None else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@routes.post(
|
|
100
|
+
"/get_time_performance_data_v1",
|
|
101
|
+
tags=["pd"],
|
|
102
|
+
summary="get_time_performance_data - variant 1 (Pure SQL)",
|
|
103
|
+
response_model=Dict[str, PdUnitPerformanceGet],
|
|
104
|
+
)
|
|
105
|
+
@require_roles("PdRead")
|
|
106
|
+
def get_time_performance_data_1(
|
|
107
|
+
session: DBSession, auth: Auth, pdord_ids: Sequence[int]
|
|
108
|
+
):
|
|
109
|
+
if not pdord_ids:
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
stm = text(
|
|
113
|
+
"""
|
|
114
|
+
-- Get time management and performance overview data for a specific production order
|
|
115
|
+
with base_data as (
|
|
116
|
+
select
|
|
117
|
+
pdord.*,
|
|
118
|
+
pdordlc.*,
|
|
119
|
+
pdordtimestat_activetime,
|
|
120
|
+
pdordtimestat_exectime,
|
|
121
|
+
pdordtimestat_setuptime,
|
|
122
|
+
pdordtimestat_spentindowntime,
|
|
123
|
+
pdordtimestat_cycle_time,
|
|
124
|
+
-- Pre-calculate commonly used values
|
|
125
|
+
coalesce(pdord_planstarttime, pdord_starttime) as planned_start,
|
|
126
|
+
coalesce(pdord_planstoptime, pdord_stoptime) as planned_end,
|
|
127
|
+
(select sum(pdordprd_quantity) from pdordprd where pdordprd_pdord_id = pdord_id) as produced_quantity
|
|
128
|
+
|
|
129
|
+
from pdord
|
|
130
|
+
|
|
131
|
+
join pdordlc
|
|
132
|
+
on pdordlc_id = pdord_pdordlc_id
|
|
133
|
+
|
|
134
|
+
left
|
|
135
|
+
join pdord_timestat
|
|
136
|
+
on pdordtimestat_pdord_id = pdord_id
|
|
137
|
+
|
|
138
|
+
where pdord_id = any(:pdord_id_array)
|
|
139
|
+
),
|
|
140
|
+
actual_start_calc as (
|
|
141
|
+
select
|
|
142
|
+
*,
|
|
143
|
+
-- Calculate actual start time - when operation transitioned from non-active to active state
|
|
144
|
+
(select min(pdordlch_createtime)
|
|
145
|
+
from pdordlch
|
|
146
|
+
join pdordlct
|
|
147
|
+
on pdordlch_pdordlct_id = pdordlct_id
|
|
148
|
+
join pdordlc from_lc
|
|
149
|
+
on from_lc.pdordlc_id = pdordlct_from_pdordlc_id
|
|
150
|
+
join pdordlc to_lc
|
|
151
|
+
on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
|
|
152
|
+
where pdordlch_pdord_id = pdord_id
|
|
153
|
+
and from_lc.pdordlc_isactive = false
|
|
154
|
+
and to_lc.pdordlc_isactive = true) as actual_start_time,
|
|
155
|
+
|
|
156
|
+
-- Calculate actual end time - when operation transitioned to final state
|
|
157
|
+
(select max(pdordlch_createtime)
|
|
158
|
+
from pdordlch
|
|
159
|
+
join pdordlct
|
|
160
|
+
on pdordlch_pdordlct_id = pdordlct_id
|
|
161
|
+
join pdordlc to_lc
|
|
162
|
+
on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
|
|
163
|
+
where pdordlch_pdord_id = pdord_id
|
|
164
|
+
and to_lc.pdordlc_isfinal = true) as actual_end_time
|
|
165
|
+
from base_data
|
|
166
|
+
),
|
|
167
|
+
time_calculations as (
|
|
168
|
+
select
|
|
169
|
+
*,
|
|
170
|
+
-- Estimated start calculation based on execution state
|
|
171
|
+
case
|
|
172
|
+
-- If operation has transitioned to active state, use actual start
|
|
173
|
+
when actual_start_time is not null then
|
|
174
|
+
actual_start_time
|
|
175
|
+
-- If operation has not started but is overdue, use current time
|
|
176
|
+
when planned_start is not null and planned_start < now() then
|
|
177
|
+
now()
|
|
178
|
+
-- If operation has not started and not overdue, use planned start
|
|
179
|
+
else planned_start
|
|
180
|
+
end as estimated_start
|
|
181
|
+
from actual_start_calc
|
|
182
|
+
),
|
|
183
|
+
final_calculations as (
|
|
184
|
+
select
|
|
185
|
+
*,
|
|
186
|
+
-- Estimated end calculation based on execution state
|
|
187
|
+
case
|
|
188
|
+
-- If operation is completed (final state), use actual end time
|
|
189
|
+
when actual_end_time is not null then
|
|
190
|
+
actual_end_time
|
|
191
|
+
-- If operation is in a state that is considered active and has corresponding time data
|
|
192
|
+
when actual_start_time is not null and pdordtimestat_activetime is not null and pdord_qty > 0 and produced_quantity > 0 then
|
|
193
|
+
-- Actual start + estimated total running time
|
|
194
|
+
actual_start_time + (pdordtimestat_activetime * (pdord_qty::float / produced_quantity::float))
|
|
195
|
+
-- If operation has been in execution but no execution time available, use time difference from planned
|
|
196
|
+
when actual_start_time is not null and planned_end is not null and planned_start is not null then
|
|
197
|
+
actual_start_time + (planned_end - planned_start)
|
|
198
|
+
-- If operation has not started but is overdue, use current time + planned duration
|
|
199
|
+
when actual_start_time is null and planned_start is not null and planned_end is not null and planned_start < now() then
|
|
200
|
+
now() + (planned_end - planned_start)
|
|
201
|
+
-- Default: use planned end
|
|
202
|
+
else planned_end
|
|
203
|
+
end as estimated_end,
|
|
204
|
+
-- Cycle time calculations
|
|
205
|
+
extract(epoch from pdord_plancycletime)::float as planned_cycle_time_seconds,
|
|
206
|
+
|
|
207
|
+
-- Calculate current cycle time if operation has been in an active state and has produced parts
|
|
208
|
+
extract(epoch from pdordtimestat_cycle_time)::float
|
|
209
|
+
as current_cycle_time_seconds
|
|
210
|
+
from time_calculations
|
|
211
|
+
)
|
|
212
|
+
select
|
|
213
|
+
pdord_id,
|
|
214
|
+
-- Return calculated timestamps
|
|
215
|
+
estimated_start as estimated_actual_start_raw,
|
|
216
|
+
estimated_end as estimated_end_raw,
|
|
217
|
+
|
|
218
|
+
-- Delta calculations
|
|
219
|
+
extract(epoch from (estimated_start - planned_start))::float as delta_start_seconds,
|
|
220
|
+
|
|
221
|
+
extract(epoch from (estimated_end - planned_end))::float as delta_end_seconds,
|
|
222
|
+
|
|
223
|
+
extract(epoch from (planned_end - planned_start))::float as delta_planned_duration_seconds,
|
|
224
|
+
|
|
225
|
+
-- Duration calculations
|
|
226
|
+
extract(epoch from (estimated_end - estimated_start))::float as estimated_duration_seconds,
|
|
227
|
+
|
|
228
|
+
extract(epoch from ((estimated_end - estimated_start) - (planned_end - planned_start)))::float as delta_duration_seconds,
|
|
229
|
+
|
|
230
|
+
-- Time statistics based on active operation state
|
|
231
|
+
extract(epoch from pdordtimestat_activetime)::float as running_time_seconds,
|
|
232
|
+
|
|
233
|
+
case
|
|
234
|
+
-- Calculate remaining time only if operation has been in an active state and has produced parts
|
|
235
|
+
when actual_start_time is not null and pdordtimestat_activetime is not null and pdord_qty > 0 and produced_quantity > 0 then
|
|
236
|
+
greatest(0, extract(epoch from (
|
|
237
|
+
pdordtimestat_activetime * (pdord_qty::float / produced_quantity::float) - pdordtimestat_activetime
|
|
238
|
+
))::float)
|
|
239
|
+
else null
|
|
240
|
+
end as remaining_time_seconds,
|
|
241
|
+
|
|
242
|
+
planned_cycle_time_seconds,
|
|
243
|
+
current_cycle_time_seconds,
|
|
244
|
+
current_cycle_time_seconds - planned_cycle_time_seconds as delta_cycle_time_seconds,
|
|
245
|
+
|
|
246
|
+
-- Setup time calculations - total accumulated setup time
|
|
247
|
+
extract(epoch from pdordtimestat_setuptime)::float as current_setup_time_seconds,
|
|
248
|
+
|
|
249
|
+
extract(epoch from (pdordtimestat_setuptime - pdord_plansetuptime))::float as delta_setup_time_seconds,
|
|
250
|
+
|
|
251
|
+
-- Downtime - total accumulated downtime
|
|
252
|
+
extract(epoch from pdordtimestat_spentindowntime)::float as downtime_seconds
|
|
253
|
+
|
|
254
|
+
from final_calculations
|
|
255
|
+
""" # noqa: E501
|
|
256
|
+
)
|
|
257
|
+
params = {"pdord_id_array": pdord_ids}
|
|
258
|
+
resultset = session.execute(stm, params).fetchall()
|
|
259
|
+
return {str(res.pdord_id): parse_performance_row(res) for res in resultset}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@routes.post(
|
|
263
|
+
"/get_time_performance_data_v2",
|
|
264
|
+
tags=["pd"],
|
|
265
|
+
summary="get_time_performance_data_ variant 2 (ORM)",
|
|
266
|
+
response_model=Dict[str, PdUnitPerformanceGet],
|
|
267
|
+
)
|
|
268
|
+
@require_roles("PdRead")
|
|
269
|
+
def get_time_performance_data_2(
|
|
270
|
+
session: DBSession, auth: Auth, pdord_ids: Sequence[int]
|
|
271
|
+
):
|
|
272
|
+
if not pdord_ids:
|
|
273
|
+
return {}
|
|
274
|
+
|
|
275
|
+
from_lc = aliased(PdOrdLc)
|
|
276
|
+
to_lc = aliased(PdOrdLc)
|
|
277
|
+
|
|
278
|
+
prd_sq = (
|
|
279
|
+
select(func.sum(PdOrdPrd.quantity))
|
|
280
|
+
.where(PdOrdPrd.pdord_id == PdOrd.id)
|
|
281
|
+
.scalar_subquery()
|
|
282
|
+
.label("produced_quantity")
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
start_sq = (
|
|
286
|
+
select(func.min(PdOrdLch.createtime))
|
|
287
|
+
.select_from(PdOrdLch)
|
|
288
|
+
.join(PdOrdLct, PdOrdLch.pdordlct_id == PdOrdLct.id)
|
|
289
|
+
.join(from_lc, from_lc.id == PdOrdLct.from_pdordlc_id)
|
|
290
|
+
.join(to_lc, to_lc.id == PdOrdLct.to_pdordlc_id)
|
|
291
|
+
.where(
|
|
292
|
+
PdOrdLch.pdord_id == PdOrd.id,
|
|
293
|
+
from_lc.isactive.is_(False),
|
|
294
|
+
to_lc.isactive,
|
|
295
|
+
)
|
|
296
|
+
.scalar_subquery()
|
|
297
|
+
.label("actual_start_time")
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
end_sq = (
|
|
301
|
+
select(func.max(PdOrdLch.createtime))
|
|
302
|
+
.select_from(PdOrdLch)
|
|
303
|
+
.join(PdOrdLct, PdOrdLch.pdordlct_id == PdOrdLct.id)
|
|
304
|
+
.join(to_lc, to_lc.id == PdOrdLct.to_pdordlc_id)
|
|
305
|
+
.where(PdOrdLch.pdord_id == PdOrd.id, to_lc.isfinal)
|
|
306
|
+
.scalar_subquery()
|
|
307
|
+
.label("actual_end_time")
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
stm = (
|
|
311
|
+
select(
|
|
312
|
+
PdOrd.id.label("pdord_id"),
|
|
313
|
+
PdOrd.planstarttime.label("pdord_planstarttime"),
|
|
314
|
+
PdOrd.starttime.label("pdord_starttime"),
|
|
315
|
+
PdOrd.planstoptime.label("pdord_planstoptime"),
|
|
316
|
+
PdOrd.stoptime.label("pdord_stoptime"),
|
|
317
|
+
PdOrd.qty.label("pdord_qty"),
|
|
318
|
+
PdOrd.plancycletime.label("pdord_plancycletime"),
|
|
319
|
+
PdOrd.plansetuptime.label("pdord_plansetuptime"),
|
|
320
|
+
PdOrdTimeStat.activetime.label("pdordtimestat_activetime"),
|
|
321
|
+
PdOrdTimeStat.cycle_time.label("pdordtimestat_cycle_time"),
|
|
322
|
+
PdOrdTimeStat.setuptime.label("pdordtimestat_setuptime"),
|
|
323
|
+
PdOrdTimeStat.spentindowntime.label("pdordtimestat_spentindowntime"),
|
|
324
|
+
PdOrdLc.isactive.label("is_active_state"),
|
|
325
|
+
PdOrdLc.isfinal.label("is_final_state"),
|
|
326
|
+
prd_sq,
|
|
327
|
+
start_sq,
|
|
328
|
+
end_sq,
|
|
329
|
+
)
|
|
330
|
+
.join(PdOrdLc, PdOrdLc.id == PdOrd.pdordlc_id)
|
|
331
|
+
.outerjoin(PdOrdTimeStat, PdOrdTimeStat.id == PdOrd.id)
|
|
332
|
+
.where(PdOrd.id.in_(pdord_ids))
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
resultset = session.execute(stm).mappings().fetchall()
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
str(row["pdord_id"]): process_performance_in_pure_python(row)
|
|
339
|
+
for row in resultset
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@routes.post(
|
|
344
|
+
"/get_time_performance_data_v3",
|
|
345
|
+
tags=["pd"],
|
|
346
|
+
summary="get_time_performance_data_ variant 3 (Pure Python SQL)",
|
|
347
|
+
response_model=Dict[str, PdUnitPerformanceGet],
|
|
348
|
+
)
|
|
349
|
+
@require_roles("PdRead")
|
|
350
|
+
def get_time_performance_data_3(
|
|
351
|
+
session: DBSession, auth: Auth, pdord_ids: Sequence[int]
|
|
352
|
+
):
|
|
353
|
+
if not pdord_ids:
|
|
354
|
+
return {}
|
|
355
|
+
|
|
356
|
+
raw_fetch_sql = text(
|
|
357
|
+
"""
|
|
358
|
+
select
|
|
359
|
+
pdord_id,
|
|
360
|
+
pdord_planstarttime,
|
|
361
|
+
pdord_starttime,
|
|
362
|
+
pdord_planstoptime,
|
|
363
|
+
pdord_stoptime,
|
|
364
|
+
pdord_qty,
|
|
365
|
+
pdord_plancycletime,
|
|
366
|
+
pdord_plansetuptime,
|
|
367
|
+
|
|
368
|
+
pdordtimestat_activetime,
|
|
369
|
+
pdordtimestat_cycle_time,
|
|
370
|
+
pdordtimestat_setuptime,
|
|
371
|
+
pdordtimestat_spentindowntime,
|
|
372
|
+
|
|
373
|
+
-- Grab current state flags directly from the lifecycle table
|
|
374
|
+
pdordlc_isactive as is_active_state,
|
|
375
|
+
pdordlc_isfinal as is_final_state,
|
|
376
|
+
|
|
377
|
+
(select sum(pdordprd_quantity)
|
|
378
|
+
from pdordprd where pdordprd_pdord_id = pdord_id) as produced_quantity,
|
|
379
|
+
|
|
380
|
+
(select min(pdordlch_createtime)
|
|
381
|
+
from pdordlch
|
|
382
|
+
join pdordlct on pdordlch_pdordlct_id = pdordlct_id
|
|
383
|
+
join pdordlc from_lc on from_lc.pdordlc_id = pdordlct_from_pdordlc_id
|
|
384
|
+
join pdordlc to_lc on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
|
|
385
|
+
where pdordlch_pdord_id = pdord_id
|
|
386
|
+
and from_lc.pdordlc_isactive = false
|
|
387
|
+
and to_lc.pdordlc_isactive = true) as actual_start_time,
|
|
388
|
+
(select max(pdordlch_createtime)
|
|
389
|
+
from pdordlch
|
|
390
|
+
join pdordlct on pdordlch_pdordlct_id = pdordlct_id
|
|
391
|
+
join pdordlc to_lc on to_lc.pdordlc_id = pdordlct_to_pdordlc_id
|
|
392
|
+
where pdordlch_pdord_id = pdord_id
|
|
393
|
+
and to_lc.pdordlc_isfinal = true) as actual_end_time
|
|
394
|
+
from pdord
|
|
395
|
+
join pdordlc on pdordlc_id = pdord_pdordlc_id
|
|
396
|
+
left join pdord_timestat on pdordtimestat_pdord_id = pdord_id
|
|
397
|
+
where pdord_id in :pdord_id_array
|
|
398
|
+
"""
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
params = {"pdord_id_array": pdord_ids}
|
|
402
|
+
stm = raw_fetch_sql.bindparams(bindparam("pdord_id_array", expanding=True))
|
|
403
|
+
resultset = session.execute(stm, params).mappings().fetchall()
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
str(row["pdord_id"]): process_performance_in_pure_python(row)
|
|
407
|
+
for row in resultset
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@routes.post(
|
|
412
|
+
"/get_time_performance_data_v4",
|
|
413
|
+
tags=["pd"],
|
|
414
|
+
summary="get_time_performance_data_ variant 4 (Pure Python ORM)",
|
|
415
|
+
response_model=Dict[str, PdUnitPerformanceGet],
|
|
416
|
+
)
|
|
417
|
+
@require_roles("PdRead")
|
|
418
|
+
def get_time_performance_data_4(
|
|
419
|
+
session: DBSession, auth: Auth, pdord_ids: Sequence[int]
|
|
420
|
+
):
|
|
421
|
+
if not pdord_ids:
|
|
422
|
+
return {}
|
|
423
|
+
|
|
424
|
+
pdords = (
|
|
425
|
+
session.execute(
|
|
426
|
+
select(
|
|
427
|
+
PdOrd.id,
|
|
428
|
+
PdOrd.pdordlc_id,
|
|
429
|
+
PdOrd.planstarttime,
|
|
430
|
+
PdOrd.starttime,
|
|
431
|
+
PdOrd.planstoptime,
|
|
432
|
+
PdOrd.stoptime,
|
|
433
|
+
PdOrd.qty,
|
|
434
|
+
PdOrd.plancycletime,
|
|
435
|
+
PdOrd.plansetuptime,
|
|
436
|
+
).where(PdOrd.id.in_(pdord_ids))
|
|
437
|
+
)
|
|
438
|
+
.mappings()
|
|
439
|
+
.all()
|
|
440
|
+
)
|
|
441
|
+
if not pdords:
|
|
442
|
+
return {}
|
|
443
|
+
|
|
444
|
+
pdord_ids = [pdord["id"] for pdord in pdords]
|
|
445
|
+
pdord_lc_ids = {pdord["pdordlc_id"] for pdord in pdords}
|
|
446
|
+
|
|
447
|
+
timestats = (
|
|
448
|
+
session.execute(
|
|
449
|
+
select(
|
|
450
|
+
PdOrdTimeStat.id,
|
|
451
|
+
PdOrdTimeStat.activetime,
|
|
452
|
+
PdOrdTimeStat.cycle_time,
|
|
453
|
+
PdOrdTimeStat.setuptime,
|
|
454
|
+
PdOrdTimeStat.spentindowntime,
|
|
455
|
+
).where(PdOrdTimeStat.id.in_(pdord_ids))
|
|
456
|
+
)
|
|
457
|
+
.mappings()
|
|
458
|
+
.all()
|
|
459
|
+
)
|
|
460
|
+
timestat_by_pdord_id = {row["id"]: row for row in timestats}
|
|
461
|
+
|
|
462
|
+
prd_rows = session.execute(
|
|
463
|
+
select(PdOrdPrd.pdord_id, PdOrdPrd.quantity).where(
|
|
464
|
+
PdOrdPrd.pdord_id.in_(pdord_ids)
|
|
465
|
+
)
|
|
466
|
+
).all()
|
|
467
|
+
produced_by_pdord_id: dict[int, float] = {}
|
|
468
|
+
for prd_pdord_id, prd_quantity in prd_rows:
|
|
469
|
+
produced_by_pdord_id[prd_pdord_id] = produced_by_pdord_id.get(
|
|
470
|
+
prd_pdord_id, 0.0
|
|
471
|
+
) + float(prd_quantity or 0.0)
|
|
472
|
+
|
|
473
|
+
lch_rows = (
|
|
474
|
+
session.execute(
|
|
475
|
+
select(PdOrdLch.pdord_id, PdOrdLch.pdordlct_id, PdOrdLch.createtime).where(
|
|
476
|
+
PdOrdLch.pdord_id.in_(pdord_ids)
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
.mappings()
|
|
480
|
+
.all()
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
lct_ids = {row["pdordlct_id"] for row in lch_rows}
|
|
484
|
+
lct_rows = (
|
|
485
|
+
session.execute(
|
|
486
|
+
select(PdOrdLct.id, PdOrdLct.from_pdordlc_id, PdOrdLct.to_pdordlc_id).where(
|
|
487
|
+
PdOrdLct.id.in_(lct_ids)
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
.mappings()
|
|
491
|
+
.all()
|
|
492
|
+
if lct_ids
|
|
493
|
+
else []
|
|
494
|
+
)
|
|
495
|
+
lct_by_id = {row["id"]: row for row in lct_rows}
|
|
496
|
+
|
|
497
|
+
transition_lc_ids = {
|
|
498
|
+
lc_id
|
|
499
|
+
for row in lct_rows
|
|
500
|
+
for lc_id in (row["from_pdordlc_id"], row["to_pdordlc_id"])
|
|
501
|
+
}
|
|
502
|
+
all_lc_ids = pdord_lc_ids | transition_lc_ids
|
|
503
|
+
lc_rows = (
|
|
504
|
+
session.execute(
|
|
505
|
+
select(PdOrdLc.id, PdOrdLc.isactive, PdOrdLc.isfinal).where(
|
|
506
|
+
PdOrdLc.id.in_(all_lc_ids)
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
.mappings()
|
|
510
|
+
.all()
|
|
511
|
+
if all_lc_ids
|
|
512
|
+
else []
|
|
513
|
+
)
|
|
514
|
+
lc_by_id = {row["id"]: row for row in lc_rows}
|
|
515
|
+
|
|
516
|
+
history_by_pdord_id: dict[int, list[RowMapping]] = {}
|
|
517
|
+
for row in lch_rows:
|
|
518
|
+
history_by_pdord_id.setdefault(row["pdord_id"], []).append(row)
|
|
519
|
+
|
|
520
|
+
result: Dict[str, PdUnitPerformanceGet] = {}
|
|
521
|
+
for pdord in pdords:
|
|
522
|
+
actual_start_time = None
|
|
523
|
+
actual_end_time = None
|
|
524
|
+
|
|
525
|
+
for history in history_by_pdord_id.get(pdord["id"], []):
|
|
526
|
+
transition = lct_by_id.get(history["pdordlct_id"])
|
|
527
|
+
if transition is None:
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
from_lc = lc_by_id.get(transition["from_pdordlc_id"])
|
|
531
|
+
to_lc = lc_by_id.get(transition["to_pdordlc_id"])
|
|
532
|
+
|
|
533
|
+
if (
|
|
534
|
+
from_lc is not None
|
|
535
|
+
and to_lc is not None
|
|
536
|
+
and (from_lc["isactive"] is False)
|
|
537
|
+
and bool(to_lc["isactive"])
|
|
538
|
+
):
|
|
539
|
+
if (
|
|
540
|
+
actual_start_time is None
|
|
541
|
+
or history["createtime"] < actual_start_time
|
|
542
|
+
):
|
|
543
|
+
actual_start_time = history["createtime"]
|
|
544
|
+
|
|
545
|
+
if to_lc is not None and bool(to_lc["isfinal"]):
|
|
546
|
+
if actual_end_time is None or history["createtime"] > actual_end_time:
|
|
547
|
+
actual_end_time = history["createtime"]
|
|
548
|
+
|
|
549
|
+
timestat = timestat_by_pdord_id.get(pdord["id"])
|
|
550
|
+
order_lc = lc_by_id.get(pdord["pdordlc_id"])
|
|
551
|
+
raw_data = {
|
|
552
|
+
"pdord_id": pdord["id"],
|
|
553
|
+
"pdord_planstarttime": pdord["planstarttime"],
|
|
554
|
+
"pdord_starttime": pdord["starttime"],
|
|
555
|
+
"pdord_planstoptime": pdord["planstoptime"],
|
|
556
|
+
"pdord_stoptime": pdord["stoptime"],
|
|
557
|
+
"pdord_qty": pdord["qty"],
|
|
558
|
+
"pdord_plancycletime": pdord["plancycletime"],
|
|
559
|
+
"pdord_plansetuptime": pdord["plansetuptime"],
|
|
560
|
+
"pdordtimestat_activetime": (
|
|
561
|
+
None if timestat is None else timestat["activetime"]
|
|
562
|
+
),
|
|
563
|
+
"pdordtimestat_cycle_time": (
|
|
564
|
+
None if timestat is None else timestat["cycle_time"]
|
|
565
|
+
),
|
|
566
|
+
"pdordtimestat_setuptime": (
|
|
567
|
+
None if timestat is None else timestat["setuptime"]
|
|
568
|
+
),
|
|
569
|
+
"pdordtimestat_spentindowntime": (
|
|
570
|
+
None if timestat is None else timestat["spentindowntime"]
|
|
571
|
+
),
|
|
572
|
+
"is_active_state": None if order_lc is None else order_lc["isactive"],
|
|
573
|
+
"is_final_state": None if order_lc is None else order_lc["isfinal"],
|
|
574
|
+
"produced_quantity": produced_by_pdord_id.get(pdord["id"], 0.0),
|
|
575
|
+
"actual_start_time": actual_start_time,
|
|
576
|
+
"actual_end_time": actual_end_time,
|
|
577
|
+
}
|
|
578
|
+
result[str(pdord["id"])] = process_performance_in_pure_python(raw_data)
|
|
579
|
+
|
|
580
|
+
return result
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def process_performance_in_pure_python(raw_data: Mapping[Any, Any]):
|
|
584
|
+
"""
|
|
585
|
+
Takes a dictionary of raw database columns and calculates
|
|
586
|
+
all time management and performance metrics in pure python
|
|
587
|
+
"""
|
|
588
|
+
now = datetime.now(timezone.utc)
|
|
589
|
+
planned_start = raw_data.get("pdord_planstarttime") or raw_data.get(
|
|
590
|
+
"pdord_starttime"
|
|
591
|
+
)
|
|
592
|
+
planned_end = raw_data.get("pdord_planstoptime") or raw_data.get("pdord_stoptime")
|
|
593
|
+
actual_start_time = raw_data.get("actual_start_time")
|
|
594
|
+
actual_end_time = raw_data.get("actual_end_time")
|
|
595
|
+
|
|
596
|
+
if actual_start_time is not None:
|
|
597
|
+
estimated_start = actual_start_time
|
|
598
|
+
elif planned_start is not None and planned_start < now:
|
|
599
|
+
estimated_start = now
|
|
600
|
+
else:
|
|
601
|
+
estimated_start = planned_start
|
|
602
|
+
|
|
603
|
+
pdord_qty = float(raw_data.get("pdord_qty") or 0)
|
|
604
|
+
produced_qty = float(raw_data.get("produced_quantity") or 0)
|
|
605
|
+
active_time = raw_data.get("pdordtimestat_activetime")
|
|
606
|
+
|
|
607
|
+
if actual_end_time is not None:
|
|
608
|
+
estimated_end = actual_end_time
|
|
609
|
+
elif (
|
|
610
|
+
actual_start_time is not None
|
|
611
|
+
and active_time is not None
|
|
612
|
+
and pdord_qty > 0
|
|
613
|
+
and produced_qty > 0
|
|
614
|
+
):
|
|
615
|
+
extrapolated_seconds = active_time.total_seconds() * (pdord_qty / produced_qty)
|
|
616
|
+
estimated_end = actual_start_time + timedelta(seconds=extrapolated_seconds)
|
|
617
|
+
elif (
|
|
618
|
+
actual_start_time is not None
|
|
619
|
+
and planned_end is not None
|
|
620
|
+
and planned_start is not None
|
|
621
|
+
):
|
|
622
|
+
planned_duration = planned_end - planned_start
|
|
623
|
+
estimated_end = actual_start_time + planned_duration
|
|
624
|
+
elif (
|
|
625
|
+
actual_start_time is None
|
|
626
|
+
and planned_start is not None
|
|
627
|
+
and planned_end is not None
|
|
628
|
+
and planned_start < now
|
|
629
|
+
):
|
|
630
|
+
planned_duration = planned_end - planned_start
|
|
631
|
+
estimated_end = now + planned_duration
|
|
632
|
+
else:
|
|
633
|
+
estimated_end = planned_end
|
|
634
|
+
|
|
635
|
+
remaining_time_seconds = None
|
|
636
|
+
if (
|
|
637
|
+
actual_start_time is not None
|
|
638
|
+
and active_time is not None
|
|
639
|
+
and pdord_qty > 0
|
|
640
|
+
and produced_qty > 0
|
|
641
|
+
):
|
|
642
|
+
calc_remaining = (
|
|
643
|
+
active_time.total_seconds() * (pdord_qty / produced_qty)
|
|
644
|
+
- active_time.total_seconds()
|
|
645
|
+
)
|
|
646
|
+
remaining_time_seconds = max(0.0, calc_remaining)
|
|
647
|
+
|
|
648
|
+
delta_start_seconds = _safe_delta(estimated_start, planned_start)
|
|
649
|
+
delta_end_seconds = _safe_delta(estimated_end, planned_end)
|
|
650
|
+
delta_planned_duration = _safe_delta(planned_end, planned_start)
|
|
651
|
+
|
|
652
|
+
dur_est = _safe_delta(estimated_end, estimated_start)
|
|
653
|
+
dur_plan = _safe_delta(planned_end, planned_start)
|
|
654
|
+
estimated_duration = _safe_delta(estimated_end, estimated_start)
|
|
655
|
+
delta_duration_seconds = (
|
|
656
|
+
(dur_est - dur_plan) if (dur_est is not None and dur_plan is not None) else None
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
planned_cycle = _td_to_sec(raw_data.get("pdord_plancycletime"))
|
|
660
|
+
current_cycle = _td_to_sec(raw_data.get("pdordtimestat_cycle_time"))
|
|
661
|
+
|
|
662
|
+
planned_setup = _td_to_sec(raw_data.get("pdord_plansetuptime"))
|
|
663
|
+
current_setup = _td_to_sec(raw_data.get("pdordtimestat_setuptime"))
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"time_management": {
|
|
667
|
+
"estimated_start": estimated_start,
|
|
668
|
+
"estimated_end": estimated_end,
|
|
669
|
+
"delta_start_seconds": delta_start_seconds,
|
|
670
|
+
"delta_end_seconds": delta_end_seconds,
|
|
671
|
+
"delta_planned_duration_seconds": delta_planned_duration,
|
|
672
|
+
"estimated_duration_seconds": estimated_duration,
|
|
673
|
+
"delta_duration_seconds": delta_duration_seconds,
|
|
674
|
+
},
|
|
675
|
+
"operation_status": {
|
|
676
|
+
"running_time_seconds": _td_to_sec(active_time),
|
|
677
|
+
"remaining_time_seconds": remaining_time_seconds,
|
|
678
|
+
"downtime_seconds": _td_to_sec(
|
|
679
|
+
raw_data.get("pdordtimestat_spentindowntime")
|
|
680
|
+
),
|
|
681
|
+
},
|
|
682
|
+
"performance": {
|
|
683
|
+
"cycle_time": {
|
|
684
|
+
"planned_seconds": planned_cycle,
|
|
685
|
+
"current_seconds": current_cycle,
|
|
686
|
+
"delta_seconds": (current_cycle - planned_cycle)
|
|
687
|
+
if (current_cycle is not None and planned_cycle is not None)
|
|
688
|
+
else None,
|
|
689
|
+
},
|
|
690
|
+
"setup_time": {
|
|
691
|
+
"current_seconds": current_setup,
|
|
692
|
+
"delta_seconds": (current_setup - planned_setup)
|
|
693
|
+
if (current_setup is not None and planned_setup is not None)
|
|
694
|
+
else None,
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def mount(app: APIRouter):
|
|
701
|
+
app.include_router(routes)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perfact-api-pd-fastapi
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: PerFact API - SQLAlchemy+FastAPI pd apis package
|
|
5
|
+
Author-email: Viktor Dick <viktor.dick@perfact.de>
|
|
6
|
+
License-Expression: GPL-2.0-or-later
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: SQL
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]
|
|
13
|
+
Requires-Dist: perfact-api-pp
|
|
14
|
+
Requires-Dist: perfact-api-pd
|
|
15
|
+
Requires-Dist: perfact-api-main
|
|
16
|
+
|
|
17
|
+
# PerFact API - pd - APIs
|
|
18
|
+
|
|
19
|
+
This package contains all APIs related to the Pd-module ('Production Execution') of the PerFact software.
|
|
20
|
+
All available APIs are added to the main OpenAPI specification automatically.
|
|
21
|
+
|
|
22
|
+
## getting started
|
|
23
|
+
This module is discovered and hosted by the `PythonPackages/perfact-api-base`.
|
|
24
|
+
As this package does not contain an own main entrypoint, you have to install it into the base and run that one.
|
|
25
|
+
Please refer to the documentation of the *API Base* to get more information about how the discovery works and how to include this module to your app bundle.
|
|
26
|
+
If everything is working, you should see this module be added to your bundle instance in the log:
|
|
27
|
+
```
|
|
28
|
+
2026-[...] - auth.app - INFO - start plugin discovery
|
|
29
|
+
[...]
|
|
30
|
+
2026-[...] - auth.app - INFO - try to include plugin: perfact.api.pd_fastapi:mount
|
|
31
|
+
[...]
|
|
32
|
+
2026-[...] - auth.app - INFO - finished discovery and include: X plugins
|
|
33
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tox.ini
|
|
4
|
+
src/perfact/api/pd_fastapi/__init__.py
|
|
5
|
+
src/perfact/api/pd_fastapi/kpi.py
|
|
6
|
+
src/perfact/api/pd_fastapi/locustfile.py
|
|
7
|
+
src/perfact/api/pd_fastapi/pdunit.py
|
|
8
|
+
src/perfact/api/pd_fastapi/performancedata.py
|
|
9
|
+
src/perfact/api/pd_fastapi/py.typed
|
|
10
|
+
src/perfact_api_pd_fastapi.egg-info/PKG-INFO
|
|
11
|
+
src/perfact_api_pd_fastapi.egg-info/SOURCES.txt
|
|
12
|
+
src/perfact_api_pd_fastapi.egg-info/dependency_links.txt
|
|
13
|
+
src/perfact_api_pd_fastapi.egg-info/entry_points.txt
|
|
14
|
+
src/perfact_api_pd_fastapi.egg-info/requires.txt
|
|
15
|
+
src/perfact_api_pd_fastapi.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
perfact
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[tox]
|
|
2
|
+
envlist = py3
|
|
3
|
+
isolated_build = true
|
|
4
|
+
|
|
5
|
+
[pytest]
|
|
6
|
+
|
|
7
|
+
[testenv]
|
|
8
|
+
passenv = SSH_AUTH_SOCK, PYTHONPATH, HTTP_PROXY, HTTPS_PROXY
|
|
9
|
+
setenv =
|
|
10
|
+
GIT_SSH_VARIANT=ssh
|
|
11
|
+
GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no
|
|
12
|
+
|
|
13
|
+
deps =
|
|
14
|
+
ruff
|
|
15
|
+
pytest
|
|
16
|
+
coverage
|
|
17
|
+
locust
|
|
18
|
+
psycopg[binary]
|
|
19
|
+
pytest-postgresql
|
|
20
|
+
pytest-cov
|
|
21
|
+
pytest-typing
|
|
22
|
+
bandit
|
|
23
|
+
mypy
|
|
24
|
+
types-pyyaml
|
|
25
|
+
perfact-api-main
|
|
26
|
+
perfact-api-pd
|
|
27
|
+
perfact-api-pp
|
|
28
|
+
|
|
29
|
+
commands =
|
|
30
|
+
ruff format --check
|
|
31
|
+
ruff check
|
|
32
|
+
bandit --configfile {toxinidir}/../bandit.yml -r src
|
|
33
|
+
mypy src
|
|
34
|
+
# pytest --doctest-modules --cov-branch --cov=src --cov-report=term-missing {posargs:src}
|