brynq-sdk-sage-germany 1.0.0__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.
- brynq_sdk_sage_germany-1.0.0/PKG-INFO +21 -0
- brynq_sdk_sage_germany-1.0.0/README.md +31 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/__init__.py +278 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/absences.py +175 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/allowances.py +100 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/contracts.py +145 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/cost_centers.py +89 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/employees.py +140 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/helpers.py +391 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/organization.py +90 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/payroll.py +167 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/payslips.py +106 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/salaries.py +95 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/__init__.py +44 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/absences.py +311 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/allowances.py +147 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/cost_centers.py +46 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/employees.py +487 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/organization.py +172 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/organization_assignment.py +61 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/payroll.py +287 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/payslips.py +34 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/salaries.py +101 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/start_end_dates.py +194 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/vacation_account.py +117 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/schemas/work_hours.py +94 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/start_end_dates.py +123 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/vacation_account.py +70 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany/work_hours.py +97 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany.egg-info/PKG-INFO +21 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany.egg-info/SOURCES.txt +35 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany.egg-info/dependency_links.txt +1 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany.egg-info/not-zip-safe +1 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany.egg-info/requires.txt +6 -0
- brynq_sdk_sage_germany-1.0.0/brynq_sdk_sage_germany.egg-info/top_level.txt +1 -0
- brynq_sdk_sage_germany-1.0.0/setup.cfg +4 -0
- brynq_sdk_sage_germany-1.0.0/setup.py +22 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brynq_sdk_sage_germany
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Sage Germany wrapper from BrynQ
|
|
5
|
+
Author: BrynQ
|
|
6
|
+
Author-email: support@brynq.com
|
|
7
|
+
License: BrynQ License
|
|
8
|
+
Requires-Dist: brynq-sdk-brynq<5,>=4
|
|
9
|
+
Requires-Dist: brynq-sdk-functions<3,>=2.1.3
|
|
10
|
+
Requires-Dist: pandas<3,>=2
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
Requires-Dist: requests>=2
|
|
13
|
+
Requires-Dist: pandera>=0.17
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: author-email
|
|
16
|
+
Dynamic: description
|
|
17
|
+
Dynamic: license
|
|
18
|
+
Dynamic: requires-dist
|
|
19
|
+
Dynamic: summary
|
|
20
|
+
|
|
21
|
+
Sage Germany wrapper from BrynQ
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# BrynQ SDK Sage Germany
|
|
2
|
+
|
|
3
|
+
This package provides the BrynQ-compliant integration scaffold for Sage Germany.
|
|
4
|
+
It follows the standard BrynQ SDK structure, so future endpoint implementations can focus on
|
|
5
|
+
business logic instead of project boilerplate.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## Prerequisites:
|
|
10
|
+
- A BrynQ Agent must be installed and reachable from the target Sage Germany environment. The SDK proxies all Sage API calls through this agent; without it, authentication and data access will fail.
|
|
11
|
+
- The Sage HR Portal API must be running and reachable on the Sage HR Suite server; the SDK routes requests through the agent to this API, so it must be up for all operations.
|
|
12
|
+
|
|
13
|
+
## Authentication:
|
|
14
|
+
- The customer must supply credentials (username and password) for an API user provisioned with the necessary read/write permissions; without valid credentials, authentication cannot be completed.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Project Structure
|
|
18
|
+
|
|
19
|
+
- `brynq_sdk_sage_germany/`: Root namespace exporting `SageGermany`.
|
|
20
|
+
- `brynq_sdk_sage_germany/brynq_sdk_sage_germany/`: Implementation package containing the client class and future endpoints.
|
|
21
|
+
- `setup.py`: Packaging configuration that matches the BrynQ standards.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from brynq_sdk_sage_germany import SageGermany
|
|
27
|
+
|
|
28
|
+
client = SageGermany(system_type="source")
|
|
29
|
+
payload = client.get("health")
|
|
30
|
+
print(payload)
|
|
31
|
+
```
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public package interface for the BrynQ Sage Germany SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from brynq_sdk_brynq import BrynQ
|
|
11
|
+
from .employees import Employees
|
|
12
|
+
from .payroll import Payroll
|
|
13
|
+
from .payslips import Payslips
|
|
14
|
+
from .cost_centers import CostCenters
|
|
15
|
+
from .organization import Organization
|
|
16
|
+
|
|
17
|
+
class SageGermany(BrynQ):
|
|
18
|
+
"""
|
|
19
|
+
Base client for interacting with Sage Germany through the BrynQ platform.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
TIMEOUT_SECONDS = 60
|
|
23
|
+
SESSION_COOKIE_NAME = "SageHRWebApi_SessionKey"
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
system_type: Optional[Literal["source", "target"]] = None,
|
|
28
|
+
debug: bool = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Initialize the Sage Germany SDK client.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
system_type: Credential type to pull from BrynQ vault.
|
|
35
|
+
debug: Enables verbose logging.
|
|
36
|
+
base_url: Optional override for the API base URL.
|
|
37
|
+
timeout: Optional request timeout in seconds.
|
|
38
|
+
"""
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.debug = debug
|
|
41
|
+
self.timeout = self.TIMEOUT_SECONDS
|
|
42
|
+
self.session_key = None
|
|
43
|
+
credentials = self.interfaces.credentials.get(
|
|
44
|
+
system="sage-germany",
|
|
45
|
+
system_type=system_type,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
credential_data = credentials["data"]
|
|
49
|
+
self._user_name = credential_data["user_name"]
|
|
50
|
+
self._password = credential_data["password"]
|
|
51
|
+
|
|
52
|
+
# Agent and target endpoints
|
|
53
|
+
self.agent_base_url = credential_data.get("brynq_agent_url")
|
|
54
|
+
if not self.agent_base_url:
|
|
55
|
+
raise ValueError("Sage Germany credentials must include brynq_agent_url.")
|
|
56
|
+
self.agent_rest_url = f"{self.agent_base_url}/brynq-agent/basic-request"
|
|
57
|
+
self.target_base_url = credential_data.get("target_base_url") or "http://127.0.0.1/hrportalapi"
|
|
58
|
+
# Preserve legacy attribute expected by existing modules
|
|
59
|
+
self.base_url = self.target_base_url
|
|
60
|
+
|
|
61
|
+
# Agent auth headers (BrynQ domain/token)
|
|
62
|
+
self.agent_domain = credential_data.get("domain") or os.getenv("BRYNQ_SUBDOMAIN")
|
|
63
|
+
agent_token = credential_data.get("token") or os.getenv("BRYNQ_API_TOKEN")
|
|
64
|
+
|
|
65
|
+
self.session = requests.Session()
|
|
66
|
+
self.session.timeout = self.timeout
|
|
67
|
+
base_headers: Dict[str, str] = {
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
}
|
|
71
|
+
if self.agent_domain:
|
|
72
|
+
base_headers["domain"] = self.agent_domain
|
|
73
|
+
if agent_token:
|
|
74
|
+
base_headers["Authorization"] = f"Bearer {agent_token}"
|
|
75
|
+
self.session.headers.update(base_headers)
|
|
76
|
+
|
|
77
|
+
# Initial authorization against Sage via agent
|
|
78
|
+
self.session_key = self.authorize()
|
|
79
|
+
|
|
80
|
+
self._verify_authorization()
|
|
81
|
+
|
|
82
|
+
self.employees = Employees(self)
|
|
83
|
+
self.payroll = Payroll(self)
|
|
84
|
+
self.payslips = Payslips(self)
|
|
85
|
+
self.cost_centers = CostCenters(self)
|
|
86
|
+
self.organization = Organization(self)
|
|
87
|
+
|
|
88
|
+
# Cache for employee search results
|
|
89
|
+
self._employee_search_cache: Optional[List[Dict[str, int]]] = None
|
|
90
|
+
|
|
91
|
+
def _employee_search(self, force_refresh: bool = False) -> List[Dict[str, int]]:
|
|
92
|
+
"""
|
|
93
|
+
Search employees and return their MdNr/AnNr pairs.
|
|
94
|
+
Results are cached to avoid repeated API calls.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
force_refresh: If True, bypass cache and fetch fresh data.
|
|
98
|
+
"""
|
|
99
|
+
if not force_refresh and self._employee_search_cache is not None:
|
|
100
|
+
return self._employee_search_cache
|
|
101
|
+
|
|
102
|
+
response = self._agent_request(
|
|
103
|
+
path="/EmployeeNew/Employees/Search",
|
|
104
|
+
method="POST",
|
|
105
|
+
body={}, # Empty payload is required to retrieve all employees.
|
|
106
|
+
)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
payload = response.json()
|
|
109
|
+
results = payload.get("Results", []) if isinstance(payload, dict) else []
|
|
110
|
+
self._employee_search_cache = [
|
|
111
|
+
{"MdNr": record_mdnr, "AnNr": record_annr}
|
|
112
|
+
for record in results
|
|
113
|
+
if isinstance(record, dict)
|
|
114
|
+
if (record_mdnr := record.get("MdNr")) is not None
|
|
115
|
+
if (record_annr := record.get("AnNr")) is not None
|
|
116
|
+
]
|
|
117
|
+
return self._employee_search_cache
|
|
118
|
+
|
|
119
|
+
def get_databases(self) -> Any:
|
|
120
|
+
"""
|
|
121
|
+
Return the list of databases available in the Sage Germany environment.
|
|
122
|
+
"""
|
|
123
|
+
response = self._agent_request(
|
|
124
|
+
path="/configuration/databases",
|
|
125
|
+
method="GET",
|
|
126
|
+
)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
return response.json()
|
|
129
|
+
|
|
130
|
+
def authorize(self) -> Optional[str]:
|
|
131
|
+
"""
|
|
132
|
+
Authenticate and capture the Sage session cookie according to API specs.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Optional[str]: Session key (SageHRWebApi_SessionKey) if provided.
|
|
136
|
+
"""
|
|
137
|
+
username = self._user_name
|
|
138
|
+
password = self._password
|
|
139
|
+
|
|
140
|
+
if not username or not password:
|
|
141
|
+
raise ValueError("Sage Germany credentials must include username and password.")
|
|
142
|
+
|
|
143
|
+
payload: Dict[str, Any] = {
|
|
144
|
+
"username": username,
|
|
145
|
+
"password": password,
|
|
146
|
+
"ntauthorization": False,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
response = self._agent_request(
|
|
150
|
+
path="/authorization",
|
|
151
|
+
method="POST",
|
|
152
|
+
body=payload,
|
|
153
|
+
)
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
|
|
156
|
+
session_key = self.session.cookies.get(self.SESSION_COOKIE_NAME)
|
|
157
|
+
|
|
158
|
+
return session_key
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _verify_authorization(self) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Validate that the authorization cookie works by calling the security probe endpoint.
|
|
164
|
+
"""
|
|
165
|
+
response = self._agent_request(
|
|
166
|
+
path="/security/calculate",
|
|
167
|
+
method="POST",
|
|
168
|
+
)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
|
|
171
|
+
def _agent_request(
|
|
172
|
+
self,
|
|
173
|
+
path: str,
|
|
174
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
175
|
+
params: Optional[Dict[str, Any]] = None,
|
|
176
|
+
body: Optional[Any] = None,
|
|
177
|
+
headers: Optional[Dict[str, str]] = None,
|
|
178
|
+
data: Optional[Any] = None,
|
|
179
|
+
) -> Any:
|
|
180
|
+
"""
|
|
181
|
+
Proxy a request to Sage via the local BrynQ agent.
|
|
182
|
+
"""
|
|
183
|
+
request_body: Dict[str, Any] = {
|
|
184
|
+
"url": f"{self.target_base_url}/{path.lstrip('/')}",
|
|
185
|
+
"method": method,
|
|
186
|
+
}
|
|
187
|
+
if params:
|
|
188
|
+
request_body["params"] = params
|
|
189
|
+
if body is not None:
|
|
190
|
+
request_body["body"] = body
|
|
191
|
+
if data is not None:
|
|
192
|
+
request_body["data"] = data
|
|
193
|
+
if headers:
|
|
194
|
+
request_body["headers"] = headers
|
|
195
|
+
if self.session_key:
|
|
196
|
+
if "headers" not in request_body:
|
|
197
|
+
request_body["headers"] = {}
|
|
198
|
+
request_body["headers"]["Cookie"] = f"{self.SESSION_COOKIE_NAME}={self.session_key}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if self.debug:
|
|
203
|
+
print(f"[agent-request] {method} {request_body['url']} params={params} body={bool(body)}")
|
|
204
|
+
|
|
205
|
+
response = self.session.post(
|
|
206
|
+
self.agent_rest_url,
|
|
207
|
+
json=request_body,
|
|
208
|
+
timeout=self.timeout,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return response
|
|
212
|
+
|
|
213
|
+
def get(
|
|
214
|
+
self,
|
|
215
|
+
path: str,
|
|
216
|
+
params: Optional[Dict[str, Any]] = None,
|
|
217
|
+
headers: Optional[Dict[str, str]] = None,
|
|
218
|
+
) -> Any:
|
|
219
|
+
"""
|
|
220
|
+
Perform a GET request via the BrynQ agent.
|
|
221
|
+
"""
|
|
222
|
+
return self._agent_request(path=path, method="GET", params=params, headers=headers)
|
|
223
|
+
|
|
224
|
+
def post(
|
|
225
|
+
self,
|
|
226
|
+
path: str,
|
|
227
|
+
params: Optional[Dict[str, Any]] = None,
|
|
228
|
+
body: Optional[Any] = None,
|
|
229
|
+
data: Optional[Any] = None,
|
|
230
|
+
headers: Optional[Dict[str, str]] = None,
|
|
231
|
+
) -> Any:
|
|
232
|
+
"""
|
|
233
|
+
Perform a POST request via the BrynQ agent.
|
|
234
|
+
"""
|
|
235
|
+
return self._agent_request(
|
|
236
|
+
path=path,
|
|
237
|
+
method="POST",
|
|
238
|
+
params=params,
|
|
239
|
+
body=body,
|
|
240
|
+
data=data,
|
|
241
|
+
headers=headers
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def put(
|
|
245
|
+
self,
|
|
246
|
+
path: str,
|
|
247
|
+
params: Optional[Dict[str, Any]] = None,
|
|
248
|
+
body: Optional[Any] = None,
|
|
249
|
+
headers: Optional[Dict[str, str]] = None,
|
|
250
|
+
) -> Any:
|
|
251
|
+
"""
|
|
252
|
+
Perform a PUT request via the BrynQ agent.
|
|
253
|
+
"""
|
|
254
|
+
return self._agent_request(
|
|
255
|
+
path=path,
|
|
256
|
+
method="PUT",
|
|
257
|
+
params=params,
|
|
258
|
+
body=body,
|
|
259
|
+
headers=headers,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def delete(
|
|
263
|
+
self,
|
|
264
|
+
path: str,
|
|
265
|
+
params: Optional[Dict[str, Any]] = None,
|
|
266
|
+
body: Optional[Any] = None,
|
|
267
|
+
headers: Optional[Dict[str, str]] = None,
|
|
268
|
+
) -> Any:
|
|
269
|
+
"""
|
|
270
|
+
Perform a DELETE request via the BrynQ agent.
|
|
271
|
+
"""
|
|
272
|
+
return self._agent_request(
|
|
273
|
+
path=path,
|
|
274
|
+
method="DELETE",
|
|
275
|
+
params=params,
|
|
276
|
+
body=body,
|
|
277
|
+
headers=headers,
|
|
278
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absence endpoint implementations for Sage Germany.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from brynq_sdk_functions import Functions
|
|
11
|
+
|
|
12
|
+
from .schemas.absences import AbsenceTypesGet, AbsencesGet, AbsenceCreate, AbsenceDelete
|
|
13
|
+
from .helpers import sage_flat_to_nested_with_prefix
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .employees import Employees
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Absences:
|
|
20
|
+
"""
|
|
21
|
+
Handles absence-related operations scoped to employees.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, sage) -> None:
|
|
25
|
+
self.sage = sage
|
|
26
|
+
self.base_url = "/time/absencetimes"
|
|
27
|
+
self.types_url = "/time/absencetimetypes"
|
|
28
|
+
|
|
29
|
+
def _collect_company_ids(self) -> List[int]:
|
|
30
|
+
"""
|
|
31
|
+
Retrieve distinct company ids using the employee search endpoint.
|
|
32
|
+
"""
|
|
33
|
+
records = self.sage._employee_search()
|
|
34
|
+
company_ids = {record["MdNr"] for record in records if record.get("MdNr") is not None}
|
|
35
|
+
return list(company_ids)
|
|
36
|
+
|
|
37
|
+
def get(self, company_ids: Optional[List[int]] = None) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
38
|
+
"""
|
|
39
|
+
Retrieve absence entries for the provided companies.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
companies = company_ids or self._collect_company_ids()
|
|
43
|
+
if not companies:
|
|
44
|
+
return pd.DataFrame(), pd.DataFrame()
|
|
45
|
+
|
|
46
|
+
absence_records: List[Dict[str, Any]] = []
|
|
47
|
+
for company_id in companies:
|
|
48
|
+
response = self.sage.get(
|
|
49
|
+
path=self.base_url,
|
|
50
|
+
params={"MdNr": company_id},
|
|
51
|
+
)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
payload = response.json()
|
|
54
|
+
if isinstance(payload, list):
|
|
55
|
+
absence_records.extend(payload)
|
|
56
|
+
|
|
57
|
+
if not absence_records:
|
|
58
|
+
return pd.DataFrame(), pd.DataFrame()
|
|
59
|
+
|
|
60
|
+
df = pd.json_normalize(absence_records, sep="__")
|
|
61
|
+
|
|
62
|
+
valid_data, invalid_data = Functions.validate_data(
|
|
63
|
+
df=df,
|
|
64
|
+
schema=AbsencesGet,
|
|
65
|
+
)
|
|
66
|
+
return valid_data, invalid_data
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
raise RuntimeError("Failed to retrieve absence data.") from exc
|
|
69
|
+
|
|
70
|
+
def get_types(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
71
|
+
"""
|
|
72
|
+
Retrieve absence type definitions (global for the instance).
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
type_records: List[Dict[str, Any]] = []
|
|
76
|
+
response = self.sage.get(
|
|
77
|
+
path=self.types_url,
|
|
78
|
+
)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
payload = response.json()
|
|
81
|
+
if isinstance(payload, list):
|
|
82
|
+
type_records.extend(payload)
|
|
83
|
+
|
|
84
|
+
if not type_records:
|
|
85
|
+
return pd.DataFrame(), pd.DataFrame()
|
|
86
|
+
|
|
87
|
+
df = pd.json_normalize(type_records, sep="__")
|
|
88
|
+
|
|
89
|
+
valid_data, invalid_data = Functions.validate_data(
|
|
90
|
+
df=df,
|
|
91
|
+
schema=AbsenceTypesGet,
|
|
92
|
+
)
|
|
93
|
+
return valid_data, invalid_data
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
raise RuntimeError("Failed to retrieve absence type data.") from exc
|
|
96
|
+
|
|
97
|
+
def create(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
98
|
+
"""
|
|
99
|
+
Create an absence entry (AbsenceTime).
|
|
100
|
+
|
|
101
|
+
Accepts flat snake_case keys; converts to nested Sage payload using schema prefixes.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
nested_payload = sage_flat_to_nested_with_prefix(data, AbsenceCreate)
|
|
105
|
+
validated = AbsenceCreate(**nested_payload)
|
|
106
|
+
payload = validated.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
107
|
+
|
|
108
|
+
response = self.sage.post(
|
|
109
|
+
path=self.base_url,
|
|
110
|
+
body=payload,
|
|
111
|
+
)
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
return response
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
raise RuntimeError("Failed to create Sage Germany absence.") from exc
|
|
116
|
+
|
|
117
|
+
def update(self, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Dict[str, Any]:
|
|
118
|
+
"""
|
|
119
|
+
Update absence entries (AbsenceTime) via bulk PUT.
|
|
120
|
+
|
|
121
|
+
Accepts a single dict or list of dicts in flat snake_case form; each item is
|
|
122
|
+
validated against the AbsenceCreateRequest schema and sent as a list payload.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(data, dict):
|
|
125
|
+
items = [data]
|
|
126
|
+
elif isinstance(data, list):
|
|
127
|
+
if not data:
|
|
128
|
+
raise ValueError("Absence update payload list must not be empty.")
|
|
129
|
+
if not all(isinstance(item, dict) for item in data):
|
|
130
|
+
raise ValueError("Absence update payload list must contain only dictionaries.")
|
|
131
|
+
items = data
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError("Absence update payload must be a dictionary or list of dictionaries.")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
payload = [
|
|
137
|
+
AbsenceCreate(
|
|
138
|
+
**sage_flat_to_nested_with_prefix(item, AbsenceCreate)
|
|
139
|
+
).model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
140
|
+
for item in items
|
|
141
|
+
]
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
raise ValueError(f"Invalid absence update payload: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
response = self.sage.put(
|
|
147
|
+
path=self.base_url,
|
|
148
|
+
body=payload,
|
|
149
|
+
)
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
return response
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
raise RuntimeError("Failed to update Sage Germany absences.") from exc
|
|
154
|
+
|
|
155
|
+
def delete(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
156
|
+
"""
|
|
157
|
+
Delete an absence entry.
|
|
158
|
+
|
|
159
|
+
Requires at minimum Id, MdNr, AnNr. Accepts flat snake_case keys.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
validated = AbsenceDelete(**data)
|
|
163
|
+
payload = validated.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise ValueError(f"Invalid absence delete payload: {exc}") from exc
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
response = self.sage.delete(
|
|
169
|
+
path=self.base_url,
|
|
170
|
+
body=payload,
|
|
171
|
+
)
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
return response
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
raise RuntimeError("Failed to delete Sage Germany absence.") from exc
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Allowances endpoint implementations for Sage Germany.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from brynq_sdk_functions import Functions
|
|
12
|
+
|
|
13
|
+
from .schemas.allowances import AllowancesGet, AllowanceCreateRequest
|
|
14
|
+
from .helpers import sage_flat_to_nested_with_prefix
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .employees import Employees
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Allowances:
|
|
21
|
+
"""
|
|
22
|
+
Handles allowance-related operations scoped to employees.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, sage) -> None:
|
|
26
|
+
self.sage = sage
|
|
27
|
+
self.base_url = "/employeenew/entgelte/festebeundabzuege"
|
|
28
|
+
|
|
29
|
+
def get(
|
|
30
|
+
self,
|
|
31
|
+
date: Optional[str] = None,
|
|
32
|
+
company_id: Optional[int] = None,
|
|
33
|
+
employee_number: Optional[int] = None,
|
|
34
|
+
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
35
|
+
"""
|
|
36
|
+
Retrieve allowances for a specific employee or all employees (optionally filtered by date).
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
if company_id is not None and employee_number is not None:
|
|
40
|
+
employee_keys = [{"MdNr": company_id, "AnNr": employee_number}]
|
|
41
|
+
else:
|
|
42
|
+
employee_keys = self.sage._employee_search()
|
|
43
|
+
|
|
44
|
+
if not employee_keys:
|
|
45
|
+
return pd.DataFrame(), pd.DataFrame()
|
|
46
|
+
|
|
47
|
+
allowances_records = []
|
|
48
|
+
for key in employee_keys:
|
|
49
|
+
params = {
|
|
50
|
+
"MdNr": key["MdNr"],
|
|
51
|
+
"AnNr": key["AnNr"],
|
|
52
|
+
"date": date
|
|
53
|
+
or datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
response = self.sage.get(
|
|
57
|
+
path=self.base_url,
|
|
58
|
+
params=params,
|
|
59
|
+
)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
payload = response.json()
|
|
62
|
+
if isinstance(payload, list):
|
|
63
|
+
allowances_records.extend(payload)
|
|
64
|
+
|
|
65
|
+
if not allowances_records:
|
|
66
|
+
return pd.DataFrame(), pd.DataFrame()
|
|
67
|
+
|
|
68
|
+
df = pd.json_normalize(allowances_records, sep="__")
|
|
69
|
+
|
|
70
|
+
valid_data, invalid_data = Functions.validate_data(
|
|
71
|
+
df=df,
|
|
72
|
+
schema=AllowancesGet,
|
|
73
|
+
)
|
|
74
|
+
return valid_data, invalid_data
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise RuntimeError("Failed to retrieve allowance data.") from exc
|
|
77
|
+
|
|
78
|
+
def create(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Create or update a fixed earning/deduction (festebeundabzuege) entry.
|
|
81
|
+
|
|
82
|
+
Accepts flat, snake_case keys; converts to nested payload per schema
|
|
83
|
+
prefixes, then serializes using Sage aliases.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
nested_payload = sage_flat_to_nested_with_prefix(data, AllowanceCreateRequest)
|
|
87
|
+
validated = AllowanceCreateRequest(**nested_payload)
|
|
88
|
+
payload = validated.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise ValueError(f"Invalid allowance payload: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
response = self.sage.post(
|
|
94
|
+
path=self.base_url,
|
|
95
|
+
body=payload,
|
|
96
|
+
)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
return response
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
raise RuntimeError("Failed to create Sage Germany allowance.") from exc
|