brynq-sdk-alight 1.0.0.dev0__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.
- brynq_sdk_alight/__init__.py +1058 -0
- brynq_sdk_alight/address.py +72 -0
- brynq_sdk_alight/archive/flat_wrapper.py +139 -0
- brynq_sdk_alight/archive/hrxml_generator.py +280 -0
- brynq_sdk_alight/archive/managers.py +132 -0
- brynq_sdk_alight/archive/managers_generic.py +114 -0
- brynq_sdk_alight/archive/managers_old_complex.py +294 -0
- brynq_sdk_alight/archive/managers_simple.py +229 -0
- brynq_sdk_alight/employee.py +81 -0
- brynq_sdk_alight/job.py +89 -0
- brynq_sdk_alight/leave.py +97 -0
- brynq_sdk_alight/pay_elements.py +97 -0
- brynq_sdk_alight/salary.py +89 -0
- brynq_sdk_alight/schemas/__init__.py +26 -0
- brynq_sdk_alight/schemas/absence.py +83 -0
- brynq_sdk_alight/schemas/address.py +113 -0
- brynq_sdk_alight/schemas/employee.py +656 -0
- brynq_sdk_alight/schemas/generated_envelope_xsd_schema/__init__.py +38683 -0
- brynq_sdk_alight/schemas/generated_envelope_xsd_schema/process_pay_serv_emp.py +622264 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/__init__.py +10965 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/csec_person.py +39808 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/hrxml_indicative_data.py +90318 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_bod.py +33869 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_currency_code_iso_7_04.py +365 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_language_code_iso_7_04.py +16 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_mimemedia_type_code_iana_7_04.py +16 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_unit_code_unece_7_04.py +14 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_lists.py +535 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_qualified_data_types.py +84 -0
- brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_unqualified_data_types.py +1449 -0
- brynq_sdk_alight/schemas/job.py +145 -0
- brynq_sdk_alight/schemas/leave.py +58 -0
- brynq_sdk_alight/schemas/payments.py +207 -0
- brynq_sdk_alight/schemas/salary.py +67 -0
- brynq_sdk_alight/schemas/termination.py +48 -0
- brynq_sdk_alight/schemas/timequota.py +66 -0
- brynq_sdk_alight/schemas/utils.py +452 -0
- brynq_sdk_alight/termination.py +103 -0
- brynq_sdk_alight/time_elements.py +121 -0
- brynq_sdk_alight/time_quotas.py +114 -0
- brynq_sdk_alight-1.0.0.dev0.dist-info/METADATA +20 -0
- brynq_sdk_alight-1.0.0.dev0.dist-info/RECORD +44 -0
- brynq_sdk_alight-1.0.0.dev0.dist-info/WHEEL +5 -0
- brynq_sdk_alight-1.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import re
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Union, List, Optional, Literal, Dict, Any
|
|
6
|
+
from xml.dom import minidom
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import requests
|
|
9
|
+
import os
|
|
10
|
+
from brynq_sdk_brynq import BrynQ
|
|
11
|
+
from .schemas.employee import EmployeeCreate
|
|
12
|
+
from .schemas.payments import PayServEmpExtensionCreate
|
|
13
|
+
from xsdata.models.datatype import XmlDate, XmlDateTime
|
|
14
|
+
from xsdata_pydantic.bindings import XmlSerializer as PydanticXmlSerializer
|
|
15
|
+
from .schemas.utils import construct_model
|
|
16
|
+
|
|
17
|
+
from .schemas.generated_xsd_schemas.hrxml_indicative_data import IndicativeDataType, IndicativeData
|
|
18
|
+
from .schemas.generated_envelope_xsd_schema.process_pay_serv_emp import (
|
|
19
|
+
ProcessPayServEmp,
|
|
20
|
+
DataArea,
|
|
21
|
+
PayServEmp,
|
|
22
|
+
PayServEmpExtension,
|
|
23
|
+
PayServEmpPayElements,
|
|
24
|
+
PayServEmpTimeElements,
|
|
25
|
+
PayServEmpTimeQuotas,
|
|
26
|
+
)
|
|
27
|
+
from .schemas.generated_xsd_schemas.openapplications_bod import ApplicationArea, Sender
|
|
28
|
+
from .employee import Employee
|
|
29
|
+
from .salary import Salary
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Alight(BrynQ):
|
|
33
|
+
def __init__(self, system_type: Optional[Literal['source', 'target']] = None, sandbox: bool = False, debug: bool = False):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.timeout = 3600
|
|
36
|
+
self.sandbox = sandbox
|
|
37
|
+
self.data_interface_id = os.getenv("DATA_INTERFACE_ID")
|
|
38
|
+
self.credentials = self.interfaces.credentials.get(system='alight', system_type=system_type)
|
|
39
|
+
self.auth_url = "https://identity-qas.eu.hrx.alight.com/connect/token" if sandbox else "https://identity.eu.hrx.alight.com/connect/token"
|
|
40
|
+
self.base_url = "https://apigateway.stradaglobal.com/extwebmethods"
|
|
41
|
+
self.session = requests.Session()
|
|
42
|
+
self.headers = self._get_request_headers()
|
|
43
|
+
self.session.headers.update(self.headers)
|
|
44
|
+
self.debug = debug
|
|
45
|
+
self.gcc = self.credentials.get('data').get('gcc').upper()
|
|
46
|
+
# Top-level API instances
|
|
47
|
+
self._employee: Optional[Employee] = None
|
|
48
|
+
self._salary: Optional[Salary] = None
|
|
49
|
+
self._address: Optional["Address"] = None
|
|
50
|
+
self._job: Optional["Job"] = None
|
|
51
|
+
self._leave: Optional["Leave"] = None
|
|
52
|
+
self._termination: Optional["Termination"] = None
|
|
53
|
+
self._pay_elements: Optional["PayElements"] = None
|
|
54
|
+
self._time_elements: Optional["TimeElements"] = None
|
|
55
|
+
self._time_quotas: Optional["TimeQuotas"] = None
|
|
56
|
+
|
|
57
|
+
def _get_request_headers(self):
|
|
58
|
+
"""
|
|
59
|
+
Exchange the configured client credentials for an OAuth bearer token and build session headers.
|
|
60
|
+
|
|
61
|
+
Called during initialization so the shared `requests.Session` carries the correct GCC, environment,
|
|
62
|
+
subscription key, and freshly minted token before any outbound requests fire.
|
|
63
|
+
"""
|
|
64
|
+
body = {
|
|
65
|
+
'grant_type': 'client_credentials',
|
|
66
|
+
'client_id': self.credentials.get('data').get('client_id'),
|
|
67
|
+
'client_secret': self.credentials.get('data').get('client_secret')
|
|
68
|
+
}
|
|
69
|
+
resp = requests.post(self.auth_url, data=body)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
return {
|
|
72
|
+
"gcc": self.credentials.get('data').get('gcc'),
|
|
73
|
+
"env": "qas" if self.sandbox else "prod",
|
|
74
|
+
'Authorization': f"Bearer {resp.json()['access_token']}",
|
|
75
|
+
"Ocp-Apim-Subscription-Key": self.credentials.get('data').get('subscription_key')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def employee(self) -> Employee:
|
|
80
|
+
"""Access employee operations (create/update)."""
|
|
81
|
+
if self._employee is None:
|
|
82
|
+
self._employee = Employee(self)
|
|
83
|
+
return self._employee
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def salary(self) -> Salary:
|
|
87
|
+
"""Access salary operations (update)."""
|
|
88
|
+
if self._salary is None:
|
|
89
|
+
self._salary = Salary(self)
|
|
90
|
+
return self._salary
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def address(self) -> "Address":
|
|
94
|
+
"""Access address operations (update)."""
|
|
95
|
+
if self._address is None:
|
|
96
|
+
from .address import Address
|
|
97
|
+
self._address = Address(self)
|
|
98
|
+
return self._address
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def job(self) -> "Job":
|
|
102
|
+
"""Access job operations (create/update)."""
|
|
103
|
+
if self._job is None:
|
|
104
|
+
from .job import Job
|
|
105
|
+
self._job = Job(self)
|
|
106
|
+
return self._job
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def leave(self) -> "Leave":
|
|
110
|
+
"""Access leave operations (create/update)."""
|
|
111
|
+
if self._leave is None:
|
|
112
|
+
from .leave import Leave
|
|
113
|
+
self._leave = Leave(self)
|
|
114
|
+
return self._leave
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def termination(self) -> "Termination":
|
|
118
|
+
"""Access termination operations (create/update)."""
|
|
119
|
+
if self._termination is None:
|
|
120
|
+
from .termination import Termination
|
|
121
|
+
self._termination = Termination(self)
|
|
122
|
+
return self._termination
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def pay_elements(self) -> "PayElements":
|
|
126
|
+
"""Access pay elements operations (create/update)."""
|
|
127
|
+
if self._pay_elements is None:
|
|
128
|
+
from .pay_elements import PayElements
|
|
129
|
+
self._pay_elements = PayElements(self)
|
|
130
|
+
return self._pay_elements
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def time_elements(self) -> "TimeElements":
|
|
134
|
+
"""Access time elements operations via extension (create/update)."""
|
|
135
|
+
if self._time_elements is None:
|
|
136
|
+
from .time_elements import TimeElements
|
|
137
|
+
self._time_elements = TimeElements(self)
|
|
138
|
+
return self._time_elements
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def time_quotas(self) -> "TimeQuotas":
|
|
142
|
+
"""Access time quotas operations via extension (create/update)."""
|
|
143
|
+
if self._time_quotas is None:
|
|
144
|
+
from .time_quotas import TimeQuotas
|
|
145
|
+
self._time_quotas = TimeQuotas(self)
|
|
146
|
+
return self._time_quotas
|
|
147
|
+
|
|
148
|
+
def create_hrxml_from_data(self, person_data: Dict[str, Any]) -> IndicativeDataType:
|
|
149
|
+
"""
|
|
150
|
+
Create HR-XML IndicativeDataType from person data dictionary.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
person_data: Dictionary containing person, employee, employment, deployment, and remuneration data
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
IndicativeDataType: Validated XSData model instance
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
>>> alight.create_hrxml_from_data(
|
|
160
|
+
... {
|
|
161
|
+
... "indicative_person_dossier": {
|
|
162
|
+
... "indicative_person": [
|
|
163
|
+
... {
|
|
164
|
+
... "person_id": [{"value": "35561"}],
|
|
165
|
+
... "person_name": [{"given_name": [{"value": "Alex"}]}],
|
|
166
|
+
... }
|
|
167
|
+
... ]
|
|
168
|
+
... }
|
|
169
|
+
... }
|
|
170
|
+
... )
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
return construct_model(IndicativeDataType, person_data)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
try:
|
|
176
|
+
indicative_data = IndicativeDataType.model_validate(person_data)
|
|
177
|
+
return indicative_data
|
|
178
|
+
except Exception as e2:
|
|
179
|
+
raise ValueError(f"Failed to create HR-XML from data: {e2}")
|
|
180
|
+
|
|
181
|
+
def create_extension_from_data(self, extension_data: Dict[str, Any]) -> PayServEmpExtension:
|
|
182
|
+
"""
|
|
183
|
+
Create PayServEmpExtension from extension data dictionary.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
extension_data: Dictionary containing payment instructions, cost assignments, etc.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
PayServEmpExtension: Validated XSData model instance
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
>>> alight.create_extension_from_data(
|
|
193
|
+
... {"bank_accounts": {"bank_account": [{"iban": {"value": "GB00BARC20201530093459"}}]}}
|
|
194
|
+
... )
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
# Always route through flat builder to ensure schema-driven wrappers
|
|
198
|
+
if isinstance(extension_data, PayServEmpExtensionCreate):
|
|
199
|
+
model = extension_data
|
|
200
|
+
else:
|
|
201
|
+
model = PayServEmpExtensionCreate(**dict(extension_data))
|
|
202
|
+
nested = model.to_nested_dict()
|
|
203
|
+
# Final generic normalization: coerce lists/wrappers/XML dates to XSD expectations
|
|
204
|
+
try:
|
|
205
|
+
from .schemas.utils import post_process_nested_data
|
|
206
|
+
from .schemas.generated_envelope_xsd_schema.process_pay_serv_emp import PayServEmpExtension as XsdPayServEmpExtension
|
|
207
|
+
post_process_nested_data(nested, XsdPayServEmpExtension)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
# Defensive fix: ensure approver date attributes are XmlDate/XmlDateTime instances
|
|
211
|
+
try:
|
|
212
|
+
from xsdata.models.datatype import XmlDate, XmlDateTime
|
|
213
|
+
import datetime as _dt
|
|
214
|
+
appr = nested.get("approvers")
|
|
215
|
+
if isinstance(appr, dict):
|
|
216
|
+
approver_items = appr.get("approver")
|
|
217
|
+
if isinstance(approver_items, list):
|
|
218
|
+
for item in approver_items:
|
|
219
|
+
if isinstance(item, dict):
|
|
220
|
+
v_from = item.get("valid_from")
|
|
221
|
+
if isinstance(v_from, _dt.datetime):
|
|
222
|
+
item["valid_from"] = XmlDateTime(
|
|
223
|
+
v_from.year, v_from.month, v_from.day, v_from.hour, v_from.minute, v_from.second, v_from.microsecond // 1000
|
|
224
|
+
)
|
|
225
|
+
elif isinstance(v_from, _dt.date):
|
|
226
|
+
item["valid_from"] = XmlDate(v_from.year, v_from.month, v_from.day)
|
|
227
|
+
v_to = item.get("valid_to")
|
|
228
|
+
if isinstance(v_to, _dt.datetime):
|
|
229
|
+
item["valid_to"] = XmlDateTime(
|
|
230
|
+
v_to.year, v_to.month, v_to.day, v_to.hour, v_to.minute, v_to.second, v_to.microsecond // 1000
|
|
231
|
+
)
|
|
232
|
+
elif isinstance(v_to, _dt.date):
|
|
233
|
+
item["valid_to"] = XmlDate(v_to.year, v_to.month, v_to.day)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
try:
|
|
237
|
+
return construct_model(PayServEmpExtension, nested)
|
|
238
|
+
except Exception:
|
|
239
|
+
return PayServEmpExtension.model_validate(nested)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
raise ValueError(f"Failed to create extension from data: {e}")
|
|
242
|
+
|
|
243
|
+
def create_pay_elements_from_data(self, pay_elements_data: Dict[str, Any]) -> PayServEmpPayElements:
|
|
244
|
+
"""
|
|
245
|
+
Create PayServEmpPayElements from pay elements data dictionary.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
pay_elements_data: Dictionary containing pay elements
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
PayServEmpPayElements: Validated XSData model instance
|
|
252
|
+
|
|
253
|
+
Example:
|
|
254
|
+
>>> alight.create_pay_elements_from_data(
|
|
255
|
+
... {
|
|
256
|
+
... "pay_element": [
|
|
257
|
+
... {"id": [{"value": "0010"}], "amount": {"value": "1200"}, "currency_code": {"value": "GBP"}}
|
|
258
|
+
... ]
|
|
259
|
+
... }
|
|
260
|
+
... )
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
# Coerce flat/simple values into XSD wrapper structures and XML dates
|
|
264
|
+
from xsdata.models.datatype import XmlDate
|
|
265
|
+
|
|
266
|
+
def coerce_wrapper(value: Any) -> Dict[str, Any]:
|
|
267
|
+
if isinstance(value, dict) and "value" in value:
|
|
268
|
+
return value
|
|
269
|
+
return {"value": value}
|
|
270
|
+
|
|
271
|
+
def coerce_date(value: Any) -> Any:
|
|
272
|
+
if isinstance(value, datetime):
|
|
273
|
+
return XmlDate.from_datetime(value) # type: ignore
|
|
274
|
+
if hasattr(value, "toordinal"):
|
|
275
|
+
return XmlDate.from_date(value) # type: ignore
|
|
276
|
+
return value
|
|
277
|
+
|
|
278
|
+
normalized: Dict[str, Any] = dict(pay_elements_data or {})
|
|
279
|
+
items = list((normalized.get("pay_element") or []))
|
|
280
|
+
coerced_items: list[Dict[str, Any]] = []
|
|
281
|
+
for item in items:
|
|
282
|
+
if not isinstance(item, dict):
|
|
283
|
+
coerced_items.append(item)
|
|
284
|
+
continue
|
|
285
|
+
tmp = dict(item)
|
|
286
|
+
# Dates
|
|
287
|
+
if "valid_from" in tmp:
|
|
288
|
+
tmp["valid_from"] = coerce_date(tmp["valid_from"])
|
|
289
|
+
if "end_date" in tmp:
|
|
290
|
+
# end_date is a wrapper in XSD
|
|
291
|
+
end_val = tmp["end_date"]
|
|
292
|
+
if isinstance(end_val, dict) and "value" in end_val:
|
|
293
|
+
end_val["value"] = coerce_date(end_val["value"])
|
|
294
|
+
tmp["end_date"] = end_val
|
|
295
|
+
else:
|
|
296
|
+
tmp["end_date"] = {"value": coerce_date(end_val)}
|
|
297
|
+
|
|
298
|
+
# id must be a list of wrapper dicts
|
|
299
|
+
if "id" in tmp:
|
|
300
|
+
id_val = tmp["id"]
|
|
301
|
+
if isinstance(id_val, list):
|
|
302
|
+
new_list = []
|
|
303
|
+
for iv in id_val:
|
|
304
|
+
if isinstance(iv, dict) and "value" in iv:
|
|
305
|
+
new_list.append(iv)
|
|
306
|
+
else:
|
|
307
|
+
new_list.append({"value": iv})
|
|
308
|
+
tmp["id"] = new_list
|
|
309
|
+
else:
|
|
310
|
+
if isinstance(id_val, dict) and "value" in id_val:
|
|
311
|
+
tmp["id"] = [id_val]
|
|
312
|
+
else:
|
|
313
|
+
tmp["id"] = [{"value": id_val}]
|
|
314
|
+
|
|
315
|
+
# Simple wrappers
|
|
316
|
+
for key in (
|
|
317
|
+
"pay_element_type",
|
|
318
|
+
"amount",
|
|
319
|
+
"currency_code",
|
|
320
|
+
"rate",
|
|
321
|
+
"units",
|
|
322
|
+
"unit_type",
|
|
323
|
+
"reference_number",
|
|
324
|
+
"cost_center_code",
|
|
325
|
+
"premium_id",
|
|
326
|
+
):
|
|
327
|
+
if key in tmp and not (isinstance(tmp[key], dict) and "value" in tmp[key]):
|
|
328
|
+
tmp[key] = coerce_wrapper(tmp[key])
|
|
329
|
+
|
|
330
|
+
coerced_items.append(tmp)
|
|
331
|
+
|
|
332
|
+
normalized["pay_element"] = coerced_items
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
return construct_model(PayServEmpPayElements, normalized)
|
|
336
|
+
except Exception:
|
|
337
|
+
return PayServEmpPayElements.model_validate(normalized)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
raise ValueError(f"Failed to create pay elements from data: {e}")
|
|
340
|
+
|
|
341
|
+
def create_complete_alight_envelope(
|
|
342
|
+
self,
|
|
343
|
+
indicative_data: IndicativeDataType,
|
|
344
|
+
extension: Optional[PayServEmpExtension] = None,
|
|
345
|
+
pay_elements: Optional[PayServEmpPayElements] = None,
|
|
346
|
+
time_elements: Optional[PayServEmpTimeElements] = None,
|
|
347
|
+
time_quotas: Optional[PayServEmpTimeQuotas] = None,
|
|
348
|
+
action_code: str = "ADD",
|
|
349
|
+
logical_id: str = "TST-GB003-1001",
|
|
350
|
+
language_code: str = "en-US",
|
|
351
|
+
system_environment_code: str = "TST NF",
|
|
352
|
+
release_id: str = "DEFAULT",
|
|
353
|
+
*,
|
|
354
|
+
creation_datetime: Optional[datetime] = None,
|
|
355
|
+
bod_id: Optional[str] = None,
|
|
356
|
+
application_sender_component_id: str = "PAYROLL",
|
|
357
|
+
application_sender_reference_id: str = "hrisxml",
|
|
358
|
+
application_sender_confirmation_code: str = "Always",
|
|
359
|
+
application_bodid: Optional[str] = None,
|
|
360
|
+
application_language_code: Optional[str] = None,
|
|
361
|
+
application_system_environment_code: Optional[str] = None,
|
|
362
|
+
application_release_id: Optional[str] = None,
|
|
363
|
+
data_area_language_code: Optional[str] = None,
|
|
364
|
+
data_area_system_environment_code: Optional[str] = None,
|
|
365
|
+
data_area_release_id: Optional[str] = None,
|
|
366
|
+
exclude_bod_fields: bool = False,
|
|
367
|
+
) -> ProcessPayServEmp:
|
|
368
|
+
"""
|
|
369
|
+
Create the complete Alight XML envelope using XSData models.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
indicative_data: The HR-XML indicative data
|
|
373
|
+
extension: Optional Alight-specific extensions
|
|
374
|
+
pay_elements: Optional pay elements
|
|
375
|
+
action_code: Action code for the process (default: "ADD")
|
|
376
|
+
logical_id: Logical ID for the sender (default: "TST-GB003-1001")
|
|
377
|
+
language_code: Language code (default: "en-US")
|
|
378
|
+
system_environment_code: System environment (default: "TST NF")
|
|
379
|
+
release_id: Release ID (default: "DEFAULT")
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
ProcessPayServEmp: Complete envelope ready for serialization
|
|
383
|
+
|
|
384
|
+
Example:
|
|
385
|
+
>>> envelope = alight.create_complete_alight_envelope(
|
|
386
|
+
... indicative_data=alight.create_hrxml_from_data({...}),
|
|
387
|
+
... pay_elements=alight.create_pay_elements_from_data({"pay_element": [{"id": [{"value": "0010"}]}]}),
|
|
388
|
+
... action_code="CHANGE",
|
|
389
|
+
... )
|
|
390
|
+
"""
|
|
391
|
+
# Create timestamp and BODID
|
|
392
|
+
timestamp = creation_datetime or datetime.now()
|
|
393
|
+
bod_id_value = (bod_id or str(uuid.uuid4())).upper()
|
|
394
|
+
app_bodid_value = (application_bodid or bod_id_value).upper()
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
# Create Sender
|
|
398
|
+
sender_data = {
|
|
399
|
+
"logical_id": {"value": logical_id},
|
|
400
|
+
"component_id": {"value": application_sender_component_id},
|
|
401
|
+
"reference_id": {"value": application_sender_reference_id},
|
|
402
|
+
"confirmation_code": {"value": application_sender_confirmation_code}
|
|
403
|
+
}
|
|
404
|
+
try:
|
|
405
|
+
sender = construct_model(Sender, sender_data)
|
|
406
|
+
except Exception:
|
|
407
|
+
sender = Sender.model_validate(sender_data)
|
|
408
|
+
|
|
409
|
+
# Create ApplicationArea
|
|
410
|
+
app_area_data = {
|
|
411
|
+
"sender": sender,
|
|
412
|
+
}
|
|
413
|
+
# BOD REMOVAL: Always include creation_date_time and bodid for model validation (required fields)
|
|
414
|
+
# We'll remove them from XML output later if exclude_bod_fields is True
|
|
415
|
+
app_area_data["creation_date_time"] = {"value": XmlDateTime.from_datetime(timestamp)}
|
|
416
|
+
# BOD REMOVAL: Only include bodid if not excluding BOD fields
|
|
417
|
+
if not exclude_bod_fields:
|
|
418
|
+
app_area_data["bodid"] = {"value": app_bodid_value}
|
|
419
|
+
if application_language_code:
|
|
420
|
+
app_area_data["language_code"] = application_language_code
|
|
421
|
+
if application_system_environment_code:
|
|
422
|
+
app_area_data["system_environment_code"] = application_system_environment_code
|
|
423
|
+
if application_release_id:
|
|
424
|
+
app_area_data["release_id"] = application_release_id
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
application_area = construct_model(ApplicationArea, app_area_data)
|
|
428
|
+
except Exception:
|
|
429
|
+
application_area = ApplicationArea.model_validate(app_area_data)
|
|
430
|
+
|
|
431
|
+
# Create PayServEmp (contains our IndicativeData + Extensions)
|
|
432
|
+
# Need to create IndicativeData wrapper (not IndicativeDataType)
|
|
433
|
+
# Copy all relevant fields from indicative_data to preserve employer_identifiers etc.
|
|
434
|
+
indicative_data_wrapper = IndicativeData(
|
|
435
|
+
document_id=indicative_data.document_id,
|
|
436
|
+
document_sequence=indicative_data.document_sequence,
|
|
437
|
+
alternate_document_id=indicative_data.alternate_document_id,
|
|
438
|
+
employer_identifiers=indicative_data.employer_identifiers,
|
|
439
|
+
indicative_person_dossier=indicative_data.indicative_person_dossier,
|
|
440
|
+
user_area=indicative_data.user_area,
|
|
441
|
+
language_code=indicative_data.language_code,
|
|
442
|
+
valid_from=indicative_data.valid_from,
|
|
443
|
+
valid_to=indicative_data.valid_to
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
pay_serv_emp_data = {
|
|
447
|
+
"indicative_data": indicative_data_wrapper
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Add optional components if provided
|
|
451
|
+
if extension:
|
|
452
|
+
pay_serv_emp_data["pay_serv_emp_extension"] = extension
|
|
453
|
+
if pay_elements:
|
|
454
|
+
pay_serv_emp_data["pay_serv_emp_pay_elements"] = pay_elements
|
|
455
|
+
if time_elements:
|
|
456
|
+
pay_serv_emp_data["pay_serv_emp_time_elements"] = time_elements
|
|
457
|
+
if time_quotas:
|
|
458
|
+
pay_serv_emp_data["pay_serv_emp_time_quotas"] = time_quotas
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
pay_serv_emp = construct_model(PayServEmp, pay_serv_emp_data)
|
|
462
|
+
except Exception:
|
|
463
|
+
pay_serv_emp = PayServEmp.model_validate(pay_serv_emp_data)
|
|
464
|
+
|
|
465
|
+
# Create Process with ActionExpression
|
|
466
|
+
process_data = {
|
|
467
|
+
"action_criteria": [{
|
|
468
|
+
"action_expression": [{
|
|
469
|
+
"action_code": action_code
|
|
470
|
+
}]
|
|
471
|
+
}]
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# Create DataArea
|
|
475
|
+
data_area_data = {
|
|
476
|
+
"process": process_data,
|
|
477
|
+
"pay_serv_emp": [pay_serv_emp]
|
|
478
|
+
}
|
|
479
|
+
try:
|
|
480
|
+
data_area = construct_model(DataArea, data_area_data)
|
|
481
|
+
except Exception:
|
|
482
|
+
data_area = DataArea.model_validate(data_area_data)
|
|
483
|
+
|
|
484
|
+
# Create the complete ProcessPayServEmp envelope
|
|
485
|
+
envelope_data = {
|
|
486
|
+
"application_area": application_area,
|
|
487
|
+
"data_area": data_area,
|
|
488
|
+
"language_code": language_code,
|
|
489
|
+
"system_environment_code": system_environment_code,
|
|
490
|
+
"release_id": release_id
|
|
491
|
+
}
|
|
492
|
+
if data_area_language_code:
|
|
493
|
+
envelope_data["language_code"] = data_area_language_code
|
|
494
|
+
if data_area_system_environment_code:
|
|
495
|
+
envelope_data["system_environment_code"] = data_area_system_environment_code
|
|
496
|
+
if data_area_release_id:
|
|
497
|
+
envelope_data["release_id"] = data_area_release_id
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
complete_envelope = construct_model(ProcessPayServEmp, envelope_data)
|
|
501
|
+
except Exception:
|
|
502
|
+
complete_envelope = ProcessPayServEmp.model_validate(envelope_data)
|
|
503
|
+
return complete_envelope
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
raise ValueError(f"Failed to create complete Alight envelope: {e}")
|
|
507
|
+
|
|
508
|
+
def serialize_with_namespaces(
|
|
509
|
+
self,
|
|
510
|
+
envelope: ProcessPayServEmp,
|
|
511
|
+
pretty_print: bool = True,
|
|
512
|
+
custom_namespaces: Optional[Dict[str, str]] = None
|
|
513
|
+
) -> str:
|
|
514
|
+
"""
|
|
515
|
+
Serialize ProcessPayServEmp envelope to XML with proper namespace handling.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
envelope: The ProcessPayServEmp envelope to serialize
|
|
519
|
+
pretty_print: Whether to format the XML with indentation (default: True)
|
|
520
|
+
custom_namespaces: Custom namespace mappings (prefix -> namespace URI)
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
str: Serialized XML string
|
|
524
|
+
|
|
525
|
+
Example:
|
|
526
|
+
>>> xml = alight.serialize_with_namespaces(envelope, custom_namespaces={"cust": "http://example.com"})
|
|
527
|
+
"""
|
|
528
|
+
# Default namespace mappings for Alight standard prefixes
|
|
529
|
+
namespace_map = custom_namespaces or {
|
|
530
|
+
"nga": "http://www.ngahr.com/ngapexxml/1",
|
|
531
|
+
"oa": "http://www.openapplications.org/oagis/9",
|
|
532
|
+
"hr": "http://www.hr-xml.org/3"
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
# Use ns_map parameter for clean namespace mapping
|
|
537
|
+
pydantic_serializer = PydanticXmlSerializer()
|
|
538
|
+
raw_xml = pydantic_serializer.render(envelope, ns_map=namespace_map)
|
|
539
|
+
|
|
540
|
+
# BOD REMOVAL: Remove BOD fields from XML if exclude_bod_fields was set (for new hires)
|
|
541
|
+
# Check if we should exclude by looking at the envelope's application_area
|
|
542
|
+
# When exclude_bod_fields is True, bodid is not set, so it will be None
|
|
543
|
+
if envelope.application_area and envelope.application_area.bodid is None:
|
|
544
|
+
# Remove CreationDateTime and BODID elements from XML for new hires
|
|
545
|
+
# Handle both single-line and multi-line XML formatting
|
|
546
|
+
raw_xml = re.sub(r'<oa:CreationDateTime>.*?</oa:CreationDateTime>\s*', '', raw_xml, flags=re.DOTALL)
|
|
547
|
+
raw_xml = re.sub(r'<oa:BODID>.*?</oa:BODID>\s*', '', raw_xml, flags=re.DOTALL)
|
|
548
|
+
# Also handle self-closing tags if they exist
|
|
549
|
+
raw_xml = re.sub(r'<oa:CreationDateTime\s*/>\s*', '', raw_xml)
|
|
550
|
+
raw_xml = re.sub(r'<oa:BODID\s*/>\s*', '', raw_xml)
|
|
551
|
+
|
|
552
|
+
if pretty_print:
|
|
553
|
+
# Pretty print the XML
|
|
554
|
+
dom = minidom.parseString(raw_xml)
|
|
555
|
+
pretty_xml = dom.toprettyxml(indent=" ", encoding=None)
|
|
556
|
+
|
|
557
|
+
# Clean up extra blank lines
|
|
558
|
+
lines = [line for line in pretty_xml.split('\n') if line.strip()]
|
|
559
|
+
return '\n'.join(lines)
|
|
560
|
+
else:
|
|
561
|
+
return raw_xml
|
|
562
|
+
|
|
563
|
+
except Exception as e:
|
|
564
|
+
# Fallback without ns_map if it fails
|
|
565
|
+
try:
|
|
566
|
+
pydantic_serializer = PydanticXmlSerializer()
|
|
567
|
+
raw_xml = pydantic_serializer.render(envelope)
|
|
568
|
+
|
|
569
|
+
if pretty_print:
|
|
570
|
+
dom = minidom.parseString(raw_xml)
|
|
571
|
+
pretty_xml = dom.toprettyxml(indent=" ", encoding=None)
|
|
572
|
+
lines = [line for line in pretty_xml.split('\n') if line.strip()]
|
|
573
|
+
return '\n'.join(lines)
|
|
574
|
+
else:
|
|
575
|
+
return raw_xml
|
|
576
|
+
|
|
577
|
+
except Exception as e2:
|
|
578
|
+
raise ValueError(f"Failed to serialize envelope: {e2}")
|
|
579
|
+
|
|
580
|
+
def generate_complete_hrxml(
|
|
581
|
+
self,
|
|
582
|
+
indicative_data: Dict[str, Any],
|
|
583
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
584
|
+
pay_elements_data: Optional[Dict[str, Any]] = None,
|
|
585
|
+
action_code: str = "ADD",
|
|
586
|
+
logical_id: str = "TST-GB003-1001",
|
|
587
|
+
pretty_print: bool = True
|
|
588
|
+
) -> str:
|
|
589
|
+
"""
|
|
590
|
+
High-level function to generate complete Alight HR-XML from data dictionaries.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
person_data: Dictionary containing person/employee/employment data
|
|
594
|
+
extension_data: Optional dictionary containing Alight extensions
|
|
595
|
+
pay_elements_data: Optional dictionary containing pay elements
|
|
596
|
+
action_code: Action code for the process (default: "ADD")
|
|
597
|
+
logical_id: Logical ID for the sender (default: "TST-GB003-1001")
|
|
598
|
+
pretty_print: Whether to format the XML with indentation (default: True)
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
str: Complete Alight HR-XML string
|
|
602
|
+
|
|
603
|
+
Example:
|
|
604
|
+
>>> xml = alight.generate_complete_hrxml(
|
|
605
|
+
... indicative_data=alight.create_hrxml_from_data({...}).model_dump(by_alias=True),
|
|
606
|
+
... extension_data={"bank_accounts": {"bank_account": [{"iban": {"value": "GB00..."}}]}},
|
|
607
|
+
... pay_elements_data={"pay_element": [{"id": [{"value": "0010"}], "amount": {"value": "1200"}}]},
|
|
608
|
+
... action_code="CHANGE",
|
|
609
|
+
... )
|
|
610
|
+
"""
|
|
611
|
+
try:
|
|
612
|
+
# Create optional components
|
|
613
|
+
extension = None
|
|
614
|
+
pay_elements = None
|
|
615
|
+
|
|
616
|
+
if extension_data:
|
|
617
|
+
extension = self.create_extension_from_data(extension_data)
|
|
618
|
+
|
|
619
|
+
if pay_elements_data:
|
|
620
|
+
pay_elements = self.create_pay_elements_from_data(pay_elements_data)
|
|
621
|
+
|
|
622
|
+
# Create complete envelope
|
|
623
|
+
envelope = self.create_complete_alight_envelope(
|
|
624
|
+
indicative_data=indicative_data,
|
|
625
|
+
extension=extension,
|
|
626
|
+
pay_elements=pay_elements,
|
|
627
|
+
action_code=action_code,
|
|
628
|
+
logical_id=logical_id
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# Serialize to XML
|
|
632
|
+
xml_string = self.serialize_with_namespaces(envelope, pretty_print=pretty_print)
|
|
633
|
+
|
|
634
|
+
return xml_string
|
|
635
|
+
|
|
636
|
+
except Exception as e:
|
|
637
|
+
raise ValueError(f"Failed to generate complete HR-XML: {e}")
|
|
638
|
+
|
|
639
|
+
def generate_newhire_xml(
|
|
640
|
+
self,
|
|
641
|
+
indicative_data: Dict[str, Any],
|
|
642
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
643
|
+
pay_elements_data: Optional[Dict[str, Any]] = None,
|
|
644
|
+
logical_id: Optional[str] = None,
|
|
645
|
+
pretty_print: bool = True
|
|
646
|
+
) -> str:
|
|
647
|
+
"""
|
|
648
|
+
Generate a complete NewHire HR-XML document for Alight integration.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
person_data: Dictionary containing person, employee, employment, deployment, and remuneration data
|
|
652
|
+
extension_data: Optional dictionary containing Alight-specific extensions (payment instructions, etc.)
|
|
653
|
+
pay_elements_data: Optional dictionary containing pay elements
|
|
654
|
+
logical_id: Optional logical ID for the sender (should be: GCC-LCC-generated ID where GCC is the client identifier and LCC is a legal entity identifier)
|
|
655
|
+
pretty_print: Whether to format the XML with indentation (default: True)
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
str: Complete Alight HR-XML string ready for transmission
|
|
659
|
+
|
|
660
|
+
Example:
|
|
661
|
+
>>> alight = Alight()
|
|
662
|
+
>>> person_data = {
|
|
663
|
+
... "indicative_person_dossier": {
|
|
664
|
+
... "indicative_person": [{
|
|
665
|
+
... "person_id": [{"value": "12345"}],
|
|
666
|
+
... "person_name": [{"given_name": [{"value": "John"}], "family_name": [{"value": "Doe"}]}]
|
|
667
|
+
... }]
|
|
668
|
+
... }
|
|
669
|
+
... }
|
|
670
|
+
>>> xml = alight.generate_newhire_xml(person_data)
|
|
671
|
+
"""
|
|
672
|
+
if self.debug:
|
|
673
|
+
print(f"🔄 Generating NewHire XML for logical_id: {logical_id}")
|
|
674
|
+
|
|
675
|
+
try:
|
|
676
|
+
xml_string = self.generate_complete_hrxml(
|
|
677
|
+
indicative_data=indicative_data,
|
|
678
|
+
extension_data=extension_data,
|
|
679
|
+
pay_elements_data=pay_elements_data,
|
|
680
|
+
action_code="ADD",
|
|
681
|
+
logical_id=logical_id,
|
|
682
|
+
pretty_print=pretty_print
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if self.debug:
|
|
686
|
+
print("✅ Successfully generated NewHire XML")
|
|
687
|
+
|
|
688
|
+
return xml_string
|
|
689
|
+
|
|
690
|
+
except Exception as e:
|
|
691
|
+
if self.debug:
|
|
692
|
+
print(f"❌ Failed to generate NewHire XML: {e}")
|
|
693
|
+
raise ValueError(f"Failed to generate NewHire XML: {e}")
|
|
694
|
+
|
|
695
|
+
def generate_employee_change_xml(
|
|
696
|
+
self,
|
|
697
|
+
indicative_data: Dict[str, Any],
|
|
698
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
699
|
+
pay_elements_data: Optional[Dict[str, Any]] = None,
|
|
700
|
+
logical_id: Optional[str] = None,
|
|
701
|
+
pretty_print: bool = True
|
|
702
|
+
) -> str:
|
|
703
|
+
"""
|
|
704
|
+
Generate a complete Employee Change HR-XML document for Alight integration.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
person_data: Dictionary containing person, employee, employment, deployment, and remuneration data
|
|
708
|
+
extension_data: Optional dictionary containing Alight-specific extensions
|
|
709
|
+
pay_elements_data: Optional dictionary containing pay elements
|
|
710
|
+
logical_id: Optional logical ID for the sender (defaults to TST-GB003-1001)
|
|
711
|
+
pretty_print: Whether to format the XML with indentation (default: True)
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
str: Complete Alight HR-XML string ready for transmission
|
|
715
|
+
|
|
716
|
+
Example:
|
|
717
|
+
>>> xml = alight.generate_employee_change_xml(
|
|
718
|
+
... indicative_data=alight.create_hrxml_from_data({...}).model_dump(by_alias=True),
|
|
719
|
+
... pay_elements_data={"pay_element": [{"id": [{"value": "0010"}], "amount": {"value": "45000"}}]},
|
|
720
|
+
... logical_id="GCC-LEGAL-0001",
|
|
721
|
+
... )
|
|
722
|
+
"""
|
|
723
|
+
if self.debug:
|
|
724
|
+
print(f"🔄 Generating Employee Change XML for logical_id: {logical_id or 'TST-GB003-1001'}")
|
|
725
|
+
|
|
726
|
+
try:
|
|
727
|
+
xml_string = self.generate_complete_hrxml(
|
|
728
|
+
indicative_data=indicative_data,
|
|
729
|
+
extension_data=extension_data,
|
|
730
|
+
pay_elements_data=pay_elements_data,
|
|
731
|
+
action_code="CHANGE",
|
|
732
|
+
logical_id=logical_id or "ADT-NL001-1001",
|
|
733
|
+
pretty_print=pretty_print
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
if self.debug:
|
|
737
|
+
print("✅ Successfully generated Employee Change XML")
|
|
738
|
+
|
|
739
|
+
return xml_string
|
|
740
|
+
|
|
741
|
+
except Exception as e:
|
|
742
|
+
if self.debug:
|
|
743
|
+
print(f"❌ Failed to generate Employee Change XML: {e}")
|
|
744
|
+
raise ValueError(f"Failed to generate Employee Change XML: {e}")
|
|
745
|
+
|
|
746
|
+
def save_xml_to_file(self, xml_content: str, filename: str) -> str:
|
|
747
|
+
"""
|
|
748
|
+
Save XML content to a file.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
xml_content: The XML string to save
|
|
752
|
+
filename: The filename to save to
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
str: The full path to the saved file
|
|
756
|
+
|
|
757
|
+
Example:
|
|
758
|
+
>>> path = alight.save_xml_to_file(xml_content, "exports/newhire.xml")
|
|
759
|
+
"""
|
|
760
|
+
try:
|
|
761
|
+
with open(filename, 'w', encoding='utf-8') as f:
|
|
762
|
+
f.write(xml_content)
|
|
763
|
+
|
|
764
|
+
if self.debug:
|
|
765
|
+
print(f"💾 XML saved to: {filename}")
|
|
766
|
+
|
|
767
|
+
return filename
|
|
768
|
+
|
|
769
|
+
except Exception as e:
|
|
770
|
+
if self.debug:
|
|
771
|
+
print(f"❌ Failed to save XML to file: {e}")
|
|
772
|
+
raise ValueError(f"Failed to save XML to file: {e}")
|
|
773
|
+
|
|
774
|
+
def validate_person_data(self, person_data: Dict[str, Any]) -> bool:
|
|
775
|
+
"""
|
|
776
|
+
Validate person data structure before generating XML.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
person_data: Dictionary containing person data
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
bool: True if valid, raises ValueError if not
|
|
783
|
+
|
|
784
|
+
Example:
|
|
785
|
+
>>> alight.validate_person_data({"indicative_person_dossier": {"indicative_person": []}})
|
|
786
|
+
"""
|
|
787
|
+
try:
|
|
788
|
+
# Try to create HR-XML from the data to validate structure
|
|
789
|
+
self.create_hrxml_from_data(person_data)
|
|
790
|
+
return True
|
|
791
|
+
except Exception as e:
|
|
792
|
+
raise ValueError(f"Invalid person data structure: {e}")
|
|
793
|
+
|
|
794
|
+
def upload_to_strada(self, xml_content: str):
|
|
795
|
+
"""
|
|
796
|
+
Submit a base64-encoded BOD payload to the Strada gateway.
|
|
797
|
+
|
|
798
|
+
The endpoint expects raw XML wrapped in JSON; returning UUID lets callers poll for status later on.
|
|
799
|
+
|
|
800
|
+
Example:
|
|
801
|
+
>>> bod_id = alight.upload_to_strada(xml_content)
|
|
802
|
+
"""
|
|
803
|
+
data = base64.b64encode(xml_content.encode('utf-8')).decode('utf-8')
|
|
804
|
+
|
|
805
|
+
# Print curl command equivalent
|
|
806
|
+
env = "qas" if self.sandbox else "prod"
|
|
807
|
+
auth_token = self.session.headers.get('Authorization', 'Bearer <TOKEN>')
|
|
808
|
+
subscription_key = self.session.headers.get('Ocp-Apim-Subscription-Key', '<SUBSCRIPTION_KEY>')
|
|
809
|
+
|
|
810
|
+
print(f'curl {self.base_url}/bods/submit -X POST -H "Content-Type: application/json" -H "gcc: {self.gcc}" -H "env: {env}" -H "Authorization: {auth_token}" -H "Ocp-Apim-Subscription-Key: {subscription_key}" -d \'{{"bod": "{data}"}}\'')
|
|
811
|
+
|
|
812
|
+
resp = self.session.post(url=f"{self.base_url}/bods/submit", json={"bod": data})
|
|
813
|
+
resp.raise_for_status()
|
|
814
|
+
return resp.json().get("UUID")
|
|
815
|
+
|
|
816
|
+
def get_bods_status(self, bods_id: str):
|
|
817
|
+
"""
|
|
818
|
+
Fetch the payroll processing status for a previously submitted BOD.
|
|
819
|
+
|
|
820
|
+
Mirrors the Strada UI status check so long-running jobs can be monitored programmatically.
|
|
821
|
+
|
|
822
|
+
Example:
|
|
823
|
+
>>> status = alight.get_bods_status("123E4567-E89B-12D3-A456-426614174000")
|
|
824
|
+
"""
|
|
825
|
+
resp = self.session.get(url=f"https://apigateway.stradaglobal.com/extwebmethods/bods/{bods_id}/gccs/{self.gcc}/payroll-status")
|
|
826
|
+
resp.raise_for_status()
|
|
827
|
+
return resp.json()
|
|
828
|
+
|
|
829
|
+
def generate_employee_xml(
|
|
830
|
+
self,
|
|
831
|
+
employee: Union[EmployeeCreate, Dict[str, Any]],
|
|
832
|
+
action_code: str = "ADD",
|
|
833
|
+
logical_id: Optional[str] = None,
|
|
834
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
835
|
+
pay_elements_data: Optional[Dict[str, Any]] = None,
|
|
836
|
+
time_elements_data: Optional[Dict[str, Any]] = None,
|
|
837
|
+
time_quotas_data: Optional[Dict[str, Any]] = None,
|
|
838
|
+
envelope_options: Optional[Dict[str, Any]] = None,
|
|
839
|
+
pretty_print: bool = True,
|
|
840
|
+
) -> str:
|
|
841
|
+
"""
|
|
842
|
+
Generate XML directly from an Employee instance.
|
|
843
|
+
This method streamlines the process of creating XML from a flat employee model.
|
|
844
|
+
|
|
845
|
+
Args:
|
|
846
|
+
employee: A valid Employee instance
|
|
847
|
+
action_code: The action code for the XML ("ADD" for new hire, "CHANGE" for updates)
|
|
848
|
+
logical_id: Optional logical ID for the sender
|
|
849
|
+
extension_data: Optional extension data dictionary
|
|
850
|
+
pay_elements_data: Optional pay elements data dictionary
|
|
851
|
+
pretty_print: Whether to format the XML with indentation
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
str: Complete Alight HR-XML string ready for transmission
|
|
855
|
+
|
|
856
|
+
Example:
|
|
857
|
+
>>> alight = Alight()
|
|
858
|
+
>>> employee = Employee(person_id="12345", given_name="John", family_name="Doe", birth_date="1980-01-01")
|
|
859
|
+
>>> xml = alight.generate_employee_xml(employee)
|
|
860
|
+
"""
|
|
861
|
+
if self.debug:
|
|
862
|
+
print(f"🔄 Generating {'NewHire' if action_code == 'ADD' else 'Change'} XML from Employee object")
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
# Convert Employee to IndicativeDataType model
|
|
866
|
+
employee_model = employee if isinstance(employee, EmployeeCreate) else EmployeeCreate(**employee)
|
|
867
|
+
indicative_data = employee_model.to_model()
|
|
868
|
+
|
|
869
|
+
# Process optional extensions
|
|
870
|
+
extension = None
|
|
871
|
+
pay_elements = None
|
|
872
|
+
|
|
873
|
+
extension_dict: Optional[Dict[str, Any]] = None
|
|
874
|
+
if extension_data:
|
|
875
|
+
extension_dict = dict(extension_data)
|
|
876
|
+
if time_elements_data:
|
|
877
|
+
extension_dict = extension_dict or {}
|
|
878
|
+
extension_dict.setdefault("pay_serv_emp_time_elements", {})
|
|
879
|
+
extension_dict["pay_serv_emp_time_elements"].update(time_elements_data)
|
|
880
|
+
if time_quotas_data:
|
|
881
|
+
extension_dict = extension_dict or {}
|
|
882
|
+
extension_dict.setdefault("pay_serv_emp_time_quotas", {})
|
|
883
|
+
extension_dict["pay_serv_emp_time_quotas"].update(time_quotas_data)
|
|
884
|
+
|
|
885
|
+
time_elements_model = None
|
|
886
|
+
time_quotas_model = None
|
|
887
|
+
if extension_dict:
|
|
888
|
+
# Build extension first (without time elements/quotas keys)
|
|
889
|
+
ext_only = dict(extension_dict)
|
|
890
|
+
ext_only.pop("pay_serv_emp_time_elements", None)
|
|
891
|
+
ext_only.pop("pay_serv_emp_time_quotas", None)
|
|
892
|
+
if ext_only:
|
|
893
|
+
extension = self.create_extension_from_data(ext_only)
|
|
894
|
+
# Build time elements/quotas models if provided
|
|
895
|
+
te = extension_dict.get("pay_serv_emp_time_elements")
|
|
896
|
+
if te:
|
|
897
|
+
# Coerce wrapper types expected by XSD
|
|
898
|
+
from xsdata.models.datatype import XmlDate
|
|
899
|
+
items = te.get("time_element", []) or []
|
|
900
|
+
for item in items:
|
|
901
|
+
if isinstance(item.get("valid_from"), (datetime,)):
|
|
902
|
+
item["valid_from"] = XmlDate.from_datetime(item["valid_from"]) # type: ignore
|
|
903
|
+
elif hasattr(item.get("valid_from"), "toordinal"):
|
|
904
|
+
item["valid_from"] = XmlDate.from_date(item["valid_from"]) # type: ignore
|
|
905
|
+
if isinstance(item.get("valid_to"), (datetime,)):
|
|
906
|
+
item["valid_to"] = XmlDate.from_datetime(item["valid_to"]) # type: ignore
|
|
907
|
+
elif hasattr(item.get("valid_to"), "toordinal"):
|
|
908
|
+
item["valid_to"] = XmlDate.from_date(item["valid_to"]) # type: ignore
|
|
909
|
+
if "units" in item and isinstance(item.get("units"), (str, int, float)):
|
|
910
|
+
units_str = str(item["units"])
|
|
911
|
+
if units_str.endswith(".00"):
|
|
912
|
+
units_str = units_str[:-3]
|
|
913
|
+
elif units_str.endswith(".0"):
|
|
914
|
+
units_str = units_str[:-2]
|
|
915
|
+
item["units"] = {"value": units_str}
|
|
916
|
+
if "unit_type" in item and isinstance(item.get("unit_type"), str):
|
|
917
|
+
item["unit_type"] = {"value": item["unit_type"]}
|
|
918
|
+
# id may be a list of wrappers in XSD
|
|
919
|
+
if "id" in item:
|
|
920
|
+
id_val = item.get("id")
|
|
921
|
+
if isinstance(id_val, list):
|
|
922
|
+
new_list = []
|
|
923
|
+
for iv in id_val:
|
|
924
|
+
if isinstance(iv, dict) and "value" in iv:
|
|
925
|
+
new_list.append(iv)
|
|
926
|
+
else:
|
|
927
|
+
new_list.append({"value": iv})
|
|
928
|
+
item["id"] = new_list
|
|
929
|
+
elif isinstance(id_val, str):
|
|
930
|
+
item["id"] = [{"value": id_val}]
|
|
931
|
+
if "absence_reason" in item and isinstance(item.get("absence_reason"), str):
|
|
932
|
+
item["absence_reason"] = {"value": item["absence_reason"]}
|
|
933
|
+
try:
|
|
934
|
+
time_elements_model = construct_model(PayServEmpTimeElements, te)
|
|
935
|
+
except Exception:
|
|
936
|
+
time_elements_model = PayServEmpTimeElements.model_validate(te)
|
|
937
|
+
tq = extension_dict.get("pay_serv_emp_time_quotas")
|
|
938
|
+
if tq:
|
|
939
|
+
try:
|
|
940
|
+
time_quotas_model = construct_model(PayServEmpTimeQuotas, tq)
|
|
941
|
+
except Exception:
|
|
942
|
+
time_quotas_model = PayServEmpTimeQuotas.model_validate(tq)
|
|
943
|
+
|
|
944
|
+
if pay_elements_data:
|
|
945
|
+
pay_elements = self.create_pay_elements_from_data(pay_elements_data)
|
|
946
|
+
|
|
947
|
+
# Create complete envelope
|
|
948
|
+
envelope_kwargs: Dict[str, Any] = {}
|
|
949
|
+
if logical_id is not None:
|
|
950
|
+
envelope_kwargs["logical_id"] = logical_id
|
|
951
|
+
|
|
952
|
+
if envelope_options:
|
|
953
|
+
envelope_kwargs.update(dict(envelope_options))
|
|
954
|
+
|
|
955
|
+
envelope_kwargs.setdefault("creation_datetime", datetime.now())
|
|
956
|
+
envelope_kwargs.setdefault("bod_id", str(uuid.uuid4()).upper())
|
|
957
|
+
envelope_kwargs.setdefault("logical_id", logical_id or "TST-GB003-1001")
|
|
958
|
+
|
|
959
|
+
# BOD REMOVAL: Exclude BOD fields for new hires (action_code="ADD")
|
|
960
|
+
if action_code == "ADD":
|
|
961
|
+
envelope_kwargs["exclude_bod_fields"] = True
|
|
962
|
+
|
|
963
|
+
envelope = self.create_complete_alight_envelope(
|
|
964
|
+
indicative_data=indicative_data,
|
|
965
|
+
extension=extension,
|
|
966
|
+
pay_elements=pay_elements,
|
|
967
|
+
time_elements=time_elements_model,
|
|
968
|
+
time_quotas=time_quotas_model,
|
|
969
|
+
action_code=action_code,
|
|
970
|
+
**envelope_kwargs,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
# Serialize to XML
|
|
974
|
+
xml_string = self.serialize_with_namespaces(envelope, pretty_print=pretty_print)
|
|
975
|
+
|
|
976
|
+
if self.debug:
|
|
977
|
+
print("✅ Successfully generated XML from Employee object")
|
|
978
|
+
|
|
979
|
+
return xml_string
|
|
980
|
+
|
|
981
|
+
except Exception as e:
|
|
982
|
+
if self.debug:
|
|
983
|
+
print(f"❌ Failed to generate XML from Employee: {e}")
|
|
984
|
+
raise ValueError(f"Failed to generate XML from Employee: {e}")
|
|
985
|
+
|
|
986
|
+
def generate_newhire_from_employee(
|
|
987
|
+
self,
|
|
988
|
+
employee: EmployeeCreate,
|
|
989
|
+
logical_id: Optional[str] = None,
|
|
990
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
991
|
+
pay_elements_data: Optional[Dict[str, Any]] = None,
|
|
992
|
+
pretty_print: bool = True
|
|
993
|
+
) -> str:
|
|
994
|
+
"""
|
|
995
|
+
Generate a NewHire XML document directly from an Employee instance.
|
|
996
|
+
Convenience method for generate_employee_xml with action_code="ADD".
|
|
997
|
+
|
|
998
|
+
Args:
|
|
999
|
+
employee: A valid Employee instance
|
|
1000
|
+
logical_id: Optional logical ID for the sender
|
|
1001
|
+
extension_data: Optional extension data dictionary
|
|
1002
|
+
pay_elements_data: Optional pay elements data dictionary
|
|
1003
|
+
pretty_print: Whether to format the XML with indentation
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
str: Complete NewHire HR-XML string
|
|
1007
|
+
|
|
1008
|
+
Example:
|
|
1009
|
+
>>> xml = alight.generate_newhire_from_employee(
|
|
1010
|
+
... employee=EmployeeCreate(person_id="35561", employee_id="35561ZZGB"),
|
|
1011
|
+
... pay_elements_data={"pay_element": [{"id": [{"value": "0010"}], "amount": {"value": "42000"}}]},
|
|
1012
|
+
... )
|
|
1013
|
+
"""
|
|
1014
|
+
return self.generate_employee_xml(
|
|
1015
|
+
employee=employee,
|
|
1016
|
+
action_code="ADD",
|
|
1017
|
+
logical_id=logical_id,
|
|
1018
|
+
extension_data=extension_data,
|
|
1019
|
+
pay_elements_data=pay_elements_data,
|
|
1020
|
+
pretty_print=pretty_print
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
def generate_change_from_employee(
|
|
1024
|
+
self,
|
|
1025
|
+
employee: EmployeeCreate,
|
|
1026
|
+
logical_id: Optional[str] = None,
|
|
1027
|
+
extension_data: Optional[Dict[str, Any]] = None,
|
|
1028
|
+
pay_elements_data: Optional[Dict[str, Any]] = None,
|
|
1029
|
+
pretty_print: bool = True
|
|
1030
|
+
) -> str:
|
|
1031
|
+
"""
|
|
1032
|
+
Generate an Employee Change XML document directly from an Employee instance.
|
|
1033
|
+
Convenience method for generate_employee_xml with action_code="CHANGE".
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
employee: A valid Employee instance
|
|
1037
|
+
logical_id: Optional logical ID for the sender
|
|
1038
|
+
extension_data: Optional extension data dictionary
|
|
1039
|
+
pay_elements_data: Optional pay elements data dictionary
|
|
1040
|
+
pretty_print: Whether to format the XML with indentation
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
str: Complete Employee Change HR-XML string
|
|
1044
|
+
|
|
1045
|
+
Example:
|
|
1046
|
+
>>> xml = alight.generate_change_from_employee(
|
|
1047
|
+
... employee=EmployeeCreate(person_id="35561", employee_id="35561ZZGB"),
|
|
1048
|
+
... extension_data={"bank_accounts": {"bank_account": [{"iban": {"value": "GB00..."}}]}},
|
|
1049
|
+
... )
|
|
1050
|
+
"""
|
|
1051
|
+
return self.generate_employee_xml(
|
|
1052
|
+
employee=employee,
|
|
1053
|
+
action_code="CHANGE",
|
|
1054
|
+
logical_id=logical_id,
|
|
1055
|
+
extension_data=extension_data,
|
|
1056
|
+
pay_elements_data=pay_elements_data,
|
|
1057
|
+
pretty_print=pretty_print
|
|
1058
|
+
)
|