brynq-sdk-sage-germany 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. brynq_sdk_sage_germany/__init__.py +278 -0
  2. brynq_sdk_sage_germany/absences.py +175 -0
  3. brynq_sdk_sage_germany/allowances.py +100 -0
  4. brynq_sdk_sage_germany/contracts.py +145 -0
  5. brynq_sdk_sage_germany/cost_centers.py +89 -0
  6. brynq_sdk_sage_germany/employees.py +140 -0
  7. brynq_sdk_sage_germany/helpers.py +391 -0
  8. brynq_sdk_sage_germany/organization.py +90 -0
  9. brynq_sdk_sage_germany/payroll.py +167 -0
  10. brynq_sdk_sage_germany/payslips.py +106 -0
  11. brynq_sdk_sage_germany/salaries.py +95 -0
  12. brynq_sdk_sage_germany/schemas/__init__.py +44 -0
  13. brynq_sdk_sage_germany/schemas/absences.py +311 -0
  14. brynq_sdk_sage_germany/schemas/allowances.py +147 -0
  15. brynq_sdk_sage_germany/schemas/cost_centers.py +46 -0
  16. brynq_sdk_sage_germany/schemas/employees.py +487 -0
  17. brynq_sdk_sage_germany/schemas/organization.py +172 -0
  18. brynq_sdk_sage_germany/schemas/organization_assignment.py +61 -0
  19. brynq_sdk_sage_germany/schemas/payroll.py +287 -0
  20. brynq_sdk_sage_germany/schemas/payslips.py +34 -0
  21. brynq_sdk_sage_germany/schemas/salaries.py +101 -0
  22. brynq_sdk_sage_germany/schemas/start_end_dates.py +194 -0
  23. brynq_sdk_sage_germany/schemas/vacation_account.py +117 -0
  24. brynq_sdk_sage_germany/schemas/work_hours.py +94 -0
  25. brynq_sdk_sage_germany/start_end_dates.py +123 -0
  26. brynq_sdk_sage_germany/vacation_account.py +70 -0
  27. brynq_sdk_sage_germany/work_hours.py +97 -0
  28. brynq_sdk_sage_germany-1.0.0.dist-info/METADATA +21 -0
  29. brynq_sdk_sage_germany-1.0.0.dist-info/RECORD +31 -0
  30. brynq_sdk_sage_germany-1.0.0.dist-info/WHEEL +5 -0
  31. brynq_sdk_sage_germany-1.0.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -0,0 +1,145 @@
1
+ """
2
+ Contracts aggregation for Sage Germany.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import List, Optional, Tuple
8
+
9
+ import pandas as pd
10
+
11
+ from .start_end_dates import StartEndDates
12
+ from .payroll import Payroll
13
+ from .work_hours import WorkHours
14
+ from .vacation_account import VacationAccount
15
+ from .organization import Organization
16
+ from .salaries import Salary
17
+
18
+
19
+ class Contracts:
20
+ """
21
+ Aggregates contract-related data points from existing services.
22
+ """
23
+
24
+ START_END_FIELDS = [
25
+ "combined_key",
26
+ "probation_end",
27
+ "service_time",
28
+ "service_time_duration",
29
+ "employment_time",
30
+ "employment_time_duration",
31
+ ]
32
+
33
+ PAYROLL_FIELDS = [
34
+ "combined_key",
35
+ "employment_type_text",
36
+ "contract_form_text",
37
+ "employee_group_text",
38
+ "multi_employed",
39
+ "social_insurance_key",
40
+ ]
41
+
42
+ WORK_HOUR_FIELDS = [
43
+ "combined_key",
44
+ "work_schedule_label",
45
+ "weekly_hours_average",
46
+ "days_per_week",
47
+ "calendar_text",
48
+ ]
49
+
50
+ VACATION_FIELDS = [
51
+ "combined_key",
52
+ "vacation_table_text",
53
+ "entitlement_current_year",
54
+ "rest_total",
55
+ "rest_current_year",
56
+ "reference_year",
57
+ ]
58
+
59
+ ORGANIZATION_FIELDS = [
60
+ "combined_key",
61
+ "payroll_group_text",
62
+ "payroll_run_text",
63
+ "site_text",
64
+ "position_text",
65
+ "search_term_1",
66
+ ]
67
+
68
+ SALARY_FIELDS = [
69
+ "combined_key",
70
+ "agreed_salary_amount",
71
+ "allowance_one_amount",
72
+ "allowance_two_amount",
73
+ "automatic_advance_payment",
74
+ "advance_payment_amount",
75
+ "hourly_rate_1",
76
+ "base_hourly_wage",
77
+ ]
78
+
79
+ def __init__(self, sage) -> None:
80
+ self.sage = sage
81
+ self.start_end_dates = StartEndDates(sage)
82
+ self.payroll = Payroll(sage)
83
+ self.work_hours = WorkHours(sage)
84
+ self.vacation_account = VacationAccount(sage)
85
+ self.organization = Organization(sage)
86
+ self.salary = Salary(sage)
87
+
88
+ def get(
89
+ self,
90
+ date: Optional[str] = None,
91
+ company_id: Optional[int] = None,
92
+ employee_number: Optional[int] = None,
93
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
94
+ """
95
+ Retrieve a merged contract view by combining core datasets.
96
+ """
97
+
98
+ datasets= []
99
+
100
+ self._add_dataset(
101
+ self.start_end_dates.get(date=date, company_id=company_id, employee_number=employee_number)[0],
102
+ self.START_END_FIELDS,
103
+ datasets,
104
+ )
105
+ self._add_dataset(
106
+ self.payroll.get_master_data(date=date, company_id=company_id, employee_number=employee_number)[0],
107
+ self.PAYROLL_FIELDS,
108
+ datasets,
109
+ )
110
+ self._add_dataset(
111
+ self.work_hours.get(date=date, company_id=company_id, employee_number=employee_number)[0],
112
+ self.WORK_HOUR_FIELDS,
113
+ datasets,
114
+ )
115
+ self._add_dataset(
116
+ self.vacation_account.get(date=date, company_id=company_id, employee_number=employee_number)[0],
117
+ self.VACATION_FIELDS,
118
+ datasets,
119
+ )
120
+ self._add_dataset(
121
+ self.organization.get(date=date, company_id=company_id, employee_number=employee_number)[0],
122
+ self.ORGANIZATION_FIELDS,
123
+ datasets,
124
+ )
125
+ salary_data, _ = self.salary.get(
126
+ company_id=company_id,
127
+ employee_number=employee_number,
128
+ )
129
+ self._add_dataset(salary_data, self.SALARY_FIELDS, datasets)
130
+
131
+ if not datasets:
132
+ return pd.DataFrame(), pd.DataFrame()
133
+
134
+ contracts_df = datasets[0]
135
+ for frame in datasets[1:]:
136
+ contracts_df = contracts_df.merge(frame, on="combined_key", how="left")
137
+
138
+ return contracts_df, pd.DataFrame()
139
+
140
+ def _add_dataset(self, frame: pd.DataFrame, fields: List[str], datasets: List[pd.DataFrame]) -> None:
141
+ if frame.empty:
142
+ return
143
+ existing = [field for field in fields if field in frame.columns]
144
+ if existing:
145
+ datasets.append(frame[existing].drop_duplicates(subset=["combined_key"]))