brynq-sdk-alight 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 (44) hide show
  1. brynq_sdk_alight/__init__.py +1019 -0
  2. brynq_sdk_alight/address.py +72 -0
  3. brynq_sdk_alight/archive/flat_wrapper.py +139 -0
  4. brynq_sdk_alight/archive/hrxml_generator.py +280 -0
  5. brynq_sdk_alight/archive/managers.py +132 -0
  6. brynq_sdk_alight/archive/managers_generic.py +114 -0
  7. brynq_sdk_alight/archive/managers_old_complex.py +294 -0
  8. brynq_sdk_alight/archive/managers_simple.py +229 -0
  9. brynq_sdk_alight/employee.py +81 -0
  10. brynq_sdk_alight/job.py +89 -0
  11. brynq_sdk_alight/leave.py +97 -0
  12. brynq_sdk_alight/pay_elements.py +97 -0
  13. brynq_sdk_alight/salary.py +89 -0
  14. brynq_sdk_alight/schemas/__init__.py +26 -0
  15. brynq_sdk_alight/schemas/absence.py +83 -0
  16. brynq_sdk_alight/schemas/address.py +113 -0
  17. brynq_sdk_alight/schemas/employee.py +641 -0
  18. brynq_sdk_alight/schemas/generated_envelope_xsd_schema/__init__.py +38683 -0
  19. brynq_sdk_alight/schemas/generated_envelope_xsd_schema/process_pay_serv_emp.py +622264 -0
  20. brynq_sdk_alight/schemas/generated_xsd_schemas/__init__.py +10965 -0
  21. brynq_sdk_alight/schemas/generated_xsd_schemas/csec_person.py +39808 -0
  22. brynq_sdk_alight/schemas/generated_xsd_schemas/hrxml_indicative_data.py +90318 -0
  23. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_bod.py +33869 -0
  24. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_currency_code_iso_7_04.py +365 -0
  25. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_language_code_iso_7_04.py +16 -0
  26. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_mimemedia_type_code_iana_7_04.py +16 -0
  27. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_list_unit_code_unece_7_04.py +14 -0
  28. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_code_lists.py +535 -0
  29. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_qualified_data_types.py +84 -0
  30. brynq_sdk_alight/schemas/generated_xsd_schemas/openapplications_unqualified_data_types.py +1449 -0
  31. brynq_sdk_alight/schemas/job.py +129 -0
  32. brynq_sdk_alight/schemas/leave.py +58 -0
  33. brynq_sdk_alight/schemas/payments.py +207 -0
  34. brynq_sdk_alight/schemas/salary.py +67 -0
  35. brynq_sdk_alight/schemas/termination.py +48 -0
  36. brynq_sdk_alight/schemas/timequota.py +66 -0
  37. brynq_sdk_alight/schemas/utils.py +452 -0
  38. brynq_sdk_alight/termination.py +103 -0
  39. brynq_sdk_alight/time_elements.py +121 -0
  40. brynq_sdk_alight/time_quotas.py +114 -0
  41. brynq_sdk_alight-1.0.0.dist-info/METADATA +20 -0
  42. brynq_sdk_alight-1.0.0.dist-info/RECORD +44 -0
  43. brynq_sdk_alight-1.0.0.dist-info/WHEEL +5 -0
  44. brynq_sdk_alight-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1019 @@
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
+ ) -> ProcessPayServEmp:
367
+ """
368
+ Create the complete Alight XML envelope using XSData models.
369
+
370
+ Args:
371
+ indicative_data: The HR-XML indicative data
372
+ extension: Optional Alight-specific extensions
373
+ pay_elements: Optional pay elements
374
+ action_code: Action code for the process (default: "ADD")
375
+ logical_id: Logical ID for the sender (default: "TST-GB003-1001")
376
+ language_code: Language code (default: "en-US")
377
+ system_environment_code: System environment (default: "TST NF")
378
+ release_id: Release ID (default: "DEFAULT")
379
+
380
+ Returns:
381
+ ProcessPayServEmp: Complete envelope ready for serialization
382
+
383
+ Example:
384
+ >>> envelope = alight.create_complete_alight_envelope(
385
+ ... indicative_data=alight.create_hrxml_from_data({...}),
386
+ ... pay_elements=alight.create_pay_elements_from_data({"pay_element": [{"id": [{"value": "0010"}]}]}),
387
+ ... action_code="CHANGE",
388
+ ... )
389
+ """
390
+ # Create timestamp and BODID
391
+ timestamp = creation_datetime or datetime.now()
392
+ bod_id_value = (bod_id or str(uuid.uuid4())).upper()
393
+ app_bodid_value = (application_bodid or bod_id_value).upper()
394
+
395
+ try:
396
+ # Create Sender
397
+ sender_data = {
398
+ "logical_id": {"value": logical_id},
399
+ "component_id": {"value": application_sender_component_id},
400
+ "reference_id": {"value": application_sender_reference_id},
401
+ "confirmation_code": {"value": application_sender_confirmation_code}
402
+ }
403
+ try:
404
+ sender = construct_model(Sender, sender_data)
405
+ except Exception:
406
+ sender = Sender.model_validate(sender_data)
407
+
408
+ # Create ApplicationArea
409
+ app_area_data = {
410
+ "sender": sender,
411
+ "creation_date_time": {"value": XmlDateTime.from_datetime(timestamp)},
412
+ "bodid": {"value": app_bodid_value}
413
+ }
414
+ if application_language_code:
415
+ app_area_data["language_code"] = application_language_code
416
+ if application_system_environment_code:
417
+ app_area_data["system_environment_code"] = application_system_environment_code
418
+ if application_release_id:
419
+ app_area_data["release_id"] = application_release_id
420
+ try:
421
+ application_area = construct_model(ApplicationArea, app_area_data)
422
+ except Exception:
423
+ application_area = ApplicationArea.model_validate(app_area_data)
424
+
425
+ # Create PayServEmp (contains our IndicativeData + Extensions)
426
+ # Need to create IndicativeData wrapper (not IndicativeDataType)
427
+ indicative_data_wrapper = IndicativeData(
428
+ indicative_person_dossier=indicative_data.indicative_person_dossier
429
+ )
430
+
431
+ pay_serv_emp_data = {
432
+ "indicative_data": indicative_data_wrapper
433
+ }
434
+
435
+ # Add optional components if provided
436
+ if extension:
437
+ pay_serv_emp_data["pay_serv_emp_extension"] = extension
438
+ if pay_elements:
439
+ pay_serv_emp_data["pay_serv_emp_pay_elements"] = pay_elements
440
+ if time_elements:
441
+ pay_serv_emp_data["pay_serv_emp_time_elements"] = time_elements
442
+ if time_quotas:
443
+ pay_serv_emp_data["pay_serv_emp_time_quotas"] = time_quotas
444
+
445
+ try:
446
+ pay_serv_emp = construct_model(PayServEmp, pay_serv_emp_data)
447
+ except Exception:
448
+ pay_serv_emp = PayServEmp.model_validate(pay_serv_emp_data)
449
+
450
+ # Create Process with ActionExpression
451
+ process_data = {
452
+ "action_criteria": [{
453
+ "action_expression": [{
454
+ "action_code": action_code
455
+ }]
456
+ }]
457
+ }
458
+
459
+ # Create DataArea
460
+ data_area_data = {
461
+ "process": process_data,
462
+ "pay_serv_emp": [pay_serv_emp]
463
+ }
464
+ try:
465
+ data_area = construct_model(DataArea, data_area_data)
466
+ except Exception:
467
+ data_area = DataArea.model_validate(data_area_data)
468
+
469
+ # Create the complete ProcessPayServEmp envelope
470
+ envelope_data = {
471
+ "application_area": application_area,
472
+ "data_area": data_area,
473
+ "language_code": language_code,
474
+ "system_environment_code": system_environment_code,
475
+ "release_id": release_id
476
+ }
477
+ if data_area_language_code:
478
+ envelope_data["language_code"] = data_area_language_code
479
+ if data_area_system_environment_code:
480
+ envelope_data["system_environment_code"] = data_area_system_environment_code
481
+ if data_area_release_id:
482
+ envelope_data["release_id"] = data_area_release_id
483
+
484
+ try:
485
+ complete_envelope = construct_model(ProcessPayServEmp, envelope_data)
486
+ except Exception:
487
+ complete_envelope = ProcessPayServEmp.model_validate(envelope_data)
488
+ return complete_envelope
489
+
490
+ except Exception as e:
491
+ raise ValueError(f"Failed to create complete Alight envelope: {e}")
492
+
493
+ def serialize_with_namespaces(
494
+ self,
495
+ envelope: ProcessPayServEmp,
496
+ pretty_print: bool = True,
497
+ custom_namespaces: Optional[Dict[str, str]] = None
498
+ ) -> str:
499
+ """
500
+ Serialize ProcessPayServEmp envelope to XML with proper namespace handling.
501
+
502
+ Args:
503
+ envelope: The ProcessPayServEmp envelope to serialize
504
+ pretty_print: Whether to format the XML with indentation (default: True)
505
+ custom_namespaces: Custom namespace mappings (prefix -> namespace URI)
506
+
507
+ Returns:
508
+ str: Serialized XML string
509
+
510
+ Example:
511
+ >>> xml = alight.serialize_with_namespaces(envelope, custom_namespaces={"cust": "http://example.com"})
512
+ """
513
+ # Default namespace mappings for Alight standard prefixes
514
+ namespace_map = custom_namespaces or {
515
+ "nga": "http://www.ngahr.com/ngapexxml/1",
516
+ "oa": "http://www.openapplications.org/oagis/9",
517
+ "hr": "http://www.hr-xml.org/3"
518
+ }
519
+
520
+ try:
521
+ # Use ns_map parameter for clean namespace mapping
522
+ pydantic_serializer = PydanticXmlSerializer()
523
+ raw_xml = pydantic_serializer.render(envelope, ns_map=namespace_map)
524
+
525
+ if pretty_print:
526
+ # Pretty print the XML
527
+ dom = minidom.parseString(raw_xml)
528
+ pretty_xml = dom.toprettyxml(indent=" ", encoding=None)
529
+
530
+ # Clean up extra blank lines
531
+ lines = [line for line in pretty_xml.split('\n') if line.strip()]
532
+ return '\n'.join(lines)
533
+ else:
534
+ return raw_xml
535
+
536
+ except Exception as e:
537
+ # Fallback without ns_map if it fails
538
+ try:
539
+ pydantic_serializer = PydanticXmlSerializer()
540
+ raw_xml = pydantic_serializer.render(envelope)
541
+
542
+ if pretty_print:
543
+ dom = minidom.parseString(raw_xml)
544
+ pretty_xml = dom.toprettyxml(indent=" ", encoding=None)
545
+ lines = [line for line in pretty_xml.split('\n') if line.strip()]
546
+ return '\n'.join(lines)
547
+ else:
548
+ return raw_xml
549
+
550
+ except Exception as e2:
551
+ raise ValueError(f"Failed to serialize envelope: {e2}")
552
+
553
+ def generate_complete_hrxml(
554
+ self,
555
+ indicative_data: Dict[str, Any],
556
+ extension_data: Optional[Dict[str, Any]] = None,
557
+ pay_elements_data: Optional[Dict[str, Any]] = None,
558
+ action_code: str = "ADD",
559
+ logical_id: str = "TST-GB003-1001",
560
+ pretty_print: bool = True
561
+ ) -> str:
562
+ """
563
+ High-level function to generate complete Alight HR-XML from data dictionaries.
564
+
565
+ Args:
566
+ person_data: Dictionary containing person/employee/employment data
567
+ extension_data: Optional dictionary containing Alight extensions
568
+ pay_elements_data: Optional dictionary containing pay elements
569
+ action_code: Action code for the process (default: "ADD")
570
+ logical_id: Logical ID for the sender (default: "TST-GB003-1001")
571
+ pretty_print: Whether to format the XML with indentation (default: True)
572
+
573
+ Returns:
574
+ str: Complete Alight HR-XML string
575
+
576
+ Example:
577
+ >>> xml = alight.generate_complete_hrxml(
578
+ ... indicative_data=alight.create_hrxml_from_data({...}).model_dump(by_alias=True),
579
+ ... extension_data={"bank_accounts": {"bank_account": [{"iban": {"value": "GB00..."}}]}},
580
+ ... pay_elements_data={"pay_element": [{"id": [{"value": "0010"}], "amount": {"value": "1200"}}]},
581
+ ... action_code="CHANGE",
582
+ ... )
583
+ """
584
+ try:
585
+ # Create optional components
586
+ extension = None
587
+ pay_elements = None
588
+
589
+ if extension_data:
590
+ extension = self.create_extension_from_data(extension_data)
591
+
592
+ if pay_elements_data:
593
+ pay_elements = self.create_pay_elements_from_data(pay_elements_data)
594
+
595
+ # Create complete envelope
596
+ envelope = self.create_complete_alight_envelope(
597
+ indicative_data=indicative_data,
598
+ extension=extension,
599
+ pay_elements=pay_elements,
600
+ action_code=action_code,
601
+ logical_id=logical_id
602
+ )
603
+
604
+ # Serialize to XML
605
+ xml_string = self.serialize_with_namespaces(envelope, pretty_print=pretty_print)
606
+
607
+ return xml_string
608
+
609
+ except Exception as e:
610
+ raise ValueError(f"Failed to generate complete HR-XML: {e}")
611
+
612
+ def generate_newhire_xml(
613
+ self,
614
+ indicative_data: Dict[str, Any],
615
+ extension_data: Optional[Dict[str, Any]] = None,
616
+ pay_elements_data: Optional[Dict[str, Any]] = None,
617
+ logical_id: Optional[str] = None,
618
+ pretty_print: bool = True
619
+ ) -> str:
620
+ """
621
+ Generate a complete NewHire HR-XML document for Alight integration.
622
+
623
+ Args:
624
+ person_data: Dictionary containing person, employee, employment, deployment, and remuneration data
625
+ extension_data: Optional dictionary containing Alight-specific extensions (payment instructions, etc.)
626
+ pay_elements_data: Optional dictionary containing pay elements
627
+ 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)
628
+ pretty_print: Whether to format the XML with indentation (default: True)
629
+
630
+ Returns:
631
+ str: Complete Alight HR-XML string ready for transmission
632
+
633
+ Example:
634
+ >>> alight = Alight()
635
+ >>> person_data = {
636
+ ... "indicative_person_dossier": {
637
+ ... "indicative_person": [{
638
+ ... "person_id": [{"value": "12345"}],
639
+ ... "person_name": [{"given_name": [{"value": "John"}], "family_name": [{"value": "Doe"}]}]
640
+ ... }]
641
+ ... }
642
+ ... }
643
+ >>> xml = alight.generate_newhire_xml(person_data)
644
+ """
645
+ if self.debug:
646
+ print(f"🔄 Generating NewHire XML for logical_id: {logical_id}")
647
+
648
+ try:
649
+ xml_string = self.generate_complete_hrxml(
650
+ indicative_data=indicative_data,
651
+ extension_data=extension_data,
652
+ pay_elements_data=pay_elements_data,
653
+ action_code="ADD",
654
+ logical_id=logical_id,
655
+ pretty_print=pretty_print
656
+ )
657
+
658
+ if self.debug:
659
+ print("✅ Successfully generated NewHire XML")
660
+
661
+ return xml_string
662
+
663
+ except Exception as e:
664
+ if self.debug:
665
+ print(f"❌ Failed to generate NewHire XML: {e}")
666
+ raise ValueError(f"Failed to generate NewHire XML: {e}")
667
+
668
+ def generate_employee_change_xml(
669
+ self,
670
+ indicative_data: Dict[str, Any],
671
+ extension_data: Optional[Dict[str, Any]] = None,
672
+ pay_elements_data: Optional[Dict[str, Any]] = None,
673
+ logical_id: Optional[str] = None,
674
+ pretty_print: bool = True
675
+ ) -> str:
676
+ """
677
+ Generate a complete Employee Change HR-XML document for Alight integration.
678
+
679
+ Args:
680
+ person_data: Dictionary containing person, employee, employment, deployment, and remuneration data
681
+ extension_data: Optional dictionary containing Alight-specific extensions
682
+ pay_elements_data: Optional dictionary containing pay elements
683
+ logical_id: Optional logical ID for the sender (defaults to TST-GB003-1001)
684
+ pretty_print: Whether to format the XML with indentation (default: True)
685
+
686
+ Returns:
687
+ str: Complete Alight HR-XML string ready for transmission
688
+
689
+ Example:
690
+ >>> xml = alight.generate_employee_change_xml(
691
+ ... indicative_data=alight.create_hrxml_from_data({...}).model_dump(by_alias=True),
692
+ ... pay_elements_data={"pay_element": [{"id": [{"value": "0010"}], "amount": {"value": "45000"}}]},
693
+ ... logical_id="GCC-LEGAL-0001",
694
+ ... )
695
+ """
696
+ if self.debug:
697
+ print(f"🔄 Generating Employee Change XML for logical_id: {logical_id or 'TST-GB003-1001'}")
698
+
699
+ try:
700
+ xml_string = self.generate_complete_hrxml(
701
+ indicative_data=indicative_data,
702
+ extension_data=extension_data,
703
+ pay_elements_data=pay_elements_data,
704
+ action_code="CHANGE",
705
+ logical_id=logical_id or "ADT-NL001-1001",
706
+ pretty_print=pretty_print
707
+ )
708
+
709
+ if self.debug:
710
+ print("✅ Successfully generated Employee Change XML")
711
+
712
+ return xml_string
713
+
714
+ except Exception as e:
715
+ if self.debug:
716
+ print(f"❌ Failed to generate Employee Change XML: {e}")
717
+ raise ValueError(f"Failed to generate Employee Change XML: {e}")
718
+
719
+ def save_xml_to_file(self, xml_content: str, filename: str) -> str:
720
+ """
721
+ Save XML content to a file.
722
+
723
+ Args:
724
+ xml_content: The XML string to save
725
+ filename: The filename to save to
726
+
727
+ Returns:
728
+ str: The full path to the saved file
729
+
730
+ Example:
731
+ >>> path = alight.save_xml_to_file(xml_content, "exports/newhire.xml")
732
+ """
733
+ try:
734
+ with open(filename, 'w', encoding='utf-8') as f:
735
+ f.write(xml_content)
736
+
737
+ if self.debug:
738
+ print(f"💾 XML saved to: {filename}")
739
+
740
+ return filename
741
+
742
+ except Exception as e:
743
+ if self.debug:
744
+ print(f"❌ Failed to save XML to file: {e}")
745
+ raise ValueError(f"Failed to save XML to file: {e}")
746
+
747
+ def validate_person_data(self, person_data: Dict[str, Any]) -> bool:
748
+ """
749
+ Validate person data structure before generating XML.
750
+
751
+ Args:
752
+ person_data: Dictionary containing person data
753
+
754
+ Returns:
755
+ bool: True if valid, raises ValueError if not
756
+
757
+ Example:
758
+ >>> alight.validate_person_data({"indicative_person_dossier": {"indicative_person": []}})
759
+ """
760
+ try:
761
+ # Try to create HR-XML from the data to validate structure
762
+ self.create_hrxml_from_data(person_data)
763
+ return True
764
+ except Exception as e:
765
+ raise ValueError(f"Invalid person data structure: {e}")
766
+
767
+ def upload_to_strada(self, xml_content: str):
768
+ """
769
+ Submit a base64-encoded BOD payload to the Strada gateway.
770
+
771
+ The endpoint expects raw XML wrapped in JSON; returning UUID lets callers poll for status later on.
772
+
773
+ Example:
774
+ >>> bod_id = alight.upload_to_strada(xml_content)
775
+ """
776
+ data = base64.b64encode(xml_content.encode('utf-8')).decode('utf-8')
777
+ resp = self.session.post(url=f"{self.base_url}/bods/submit", json={"bod": data})
778
+ resp.raise_for_status()
779
+ return resp.json().get("UUID")
780
+
781
+ def get_bods_status(self, bods_id: str):
782
+ """
783
+ Fetch the payroll processing status for a previously submitted BOD.
784
+
785
+ Mirrors the Strada UI status check so long-running jobs can be monitored programmatically.
786
+
787
+ Example:
788
+ >>> status = alight.get_bods_status("123E4567-E89B-12D3-A456-426614174000")
789
+ """
790
+ resp = self.session.get(url=f"https://apigateway.stradaglobal.com/extwebmethods/bods/{bods_id}/gccs/{self.gcc}/payroll-status")
791
+ resp.raise_for_status()
792
+ return resp.json()
793
+
794
+ def generate_employee_xml(
795
+ self,
796
+ employee: Union[EmployeeCreate, Dict[str, Any]],
797
+ action_code: str = "ADD",
798
+ logical_id: Optional[str] = None,
799
+ extension_data: Optional[Dict[str, Any]] = None,
800
+ pay_elements_data: Optional[Dict[str, Any]] = None,
801
+ time_elements_data: Optional[Dict[str, Any]] = None,
802
+ time_quotas_data: Optional[Dict[str, Any]] = None,
803
+ envelope_options: Optional[Dict[str, Any]] = None,
804
+ pretty_print: bool = True,
805
+ ) -> str:
806
+ """
807
+ Generate XML directly from an Employee instance.
808
+ This method streamlines the process of creating XML from a flat employee model.
809
+
810
+ Args:
811
+ employee: A valid Employee instance
812
+ action_code: The action code for the XML ("ADD" for new hire, "CHANGE" for updates)
813
+ logical_id: Optional logical ID for the sender
814
+ extension_data: Optional extension data dictionary
815
+ pay_elements_data: Optional pay elements data dictionary
816
+ pretty_print: Whether to format the XML with indentation
817
+
818
+ Returns:
819
+ str: Complete Alight HR-XML string ready for transmission
820
+
821
+ Example:
822
+ >>> alight = Alight()
823
+ >>> employee = Employee(person_id="12345", given_name="John", family_name="Doe", birth_date="1980-01-01")
824
+ >>> xml = alight.generate_employee_xml(employee)
825
+ """
826
+ if self.debug:
827
+ print(f"🔄 Generating {'NewHire' if action_code == 'ADD' else 'Change'} XML from Employee object")
828
+
829
+ try:
830
+ # Convert Employee to IndicativeDataType model
831
+ employee_model = employee if isinstance(employee, EmployeeCreate) else EmployeeCreate(**employee)
832
+ indicative_data = employee_model.to_model()
833
+
834
+ # Process optional extensions
835
+ extension = None
836
+ pay_elements = None
837
+
838
+ extension_dict: Optional[Dict[str, Any]] = None
839
+ if extension_data:
840
+ extension_dict = dict(extension_data)
841
+ if time_elements_data:
842
+ extension_dict = extension_dict or {}
843
+ extension_dict.setdefault("pay_serv_emp_time_elements", {})
844
+ extension_dict["pay_serv_emp_time_elements"].update(time_elements_data)
845
+ if time_quotas_data:
846
+ extension_dict = extension_dict or {}
847
+ extension_dict.setdefault("pay_serv_emp_time_quotas", {})
848
+ extension_dict["pay_serv_emp_time_quotas"].update(time_quotas_data)
849
+
850
+ time_elements_model = None
851
+ time_quotas_model = None
852
+ if extension_dict:
853
+ # Build extension first (without time elements/quotas keys)
854
+ ext_only = dict(extension_dict)
855
+ ext_only.pop("pay_serv_emp_time_elements", None)
856
+ ext_only.pop("pay_serv_emp_time_quotas", None)
857
+ if ext_only:
858
+ extension = self.create_extension_from_data(ext_only)
859
+ # Build time elements/quotas models if provided
860
+ te = extension_dict.get("pay_serv_emp_time_elements")
861
+ if te:
862
+ # Coerce wrapper types expected by XSD
863
+ from xsdata.models.datatype import XmlDate
864
+ items = te.get("time_element", []) or []
865
+ for item in items:
866
+ if isinstance(item.get("valid_from"), (datetime,)):
867
+ item["valid_from"] = XmlDate.from_datetime(item["valid_from"]) # type: ignore
868
+ elif hasattr(item.get("valid_from"), "toordinal"):
869
+ item["valid_from"] = XmlDate.from_date(item["valid_from"]) # type: ignore
870
+ if isinstance(item.get("valid_to"), (datetime,)):
871
+ item["valid_to"] = XmlDate.from_datetime(item["valid_to"]) # type: ignore
872
+ elif hasattr(item.get("valid_to"), "toordinal"):
873
+ item["valid_to"] = XmlDate.from_date(item["valid_to"]) # type: ignore
874
+ if "units" in item and isinstance(item.get("units"), (str, int, float)):
875
+ units_str = str(item["units"])
876
+ if units_str.endswith(".00"):
877
+ units_str = units_str[:-3]
878
+ elif units_str.endswith(".0"):
879
+ units_str = units_str[:-2]
880
+ item["units"] = {"value": units_str}
881
+ if "unit_type" in item and isinstance(item.get("unit_type"), str):
882
+ item["unit_type"] = {"value": item["unit_type"]}
883
+ # id may be a list of wrappers in XSD
884
+ if "id" in item:
885
+ id_val = item.get("id")
886
+ if isinstance(id_val, list):
887
+ new_list = []
888
+ for iv in id_val:
889
+ if isinstance(iv, dict) and "value" in iv:
890
+ new_list.append(iv)
891
+ else:
892
+ new_list.append({"value": iv})
893
+ item["id"] = new_list
894
+ elif isinstance(id_val, str):
895
+ item["id"] = [{"value": id_val}]
896
+ if "absence_reason" in item and isinstance(item.get("absence_reason"), str):
897
+ item["absence_reason"] = {"value": item["absence_reason"]}
898
+ try:
899
+ time_elements_model = construct_model(PayServEmpTimeElements, te)
900
+ except Exception:
901
+ time_elements_model = PayServEmpTimeElements.model_validate(te)
902
+ tq = extension_dict.get("pay_serv_emp_time_quotas")
903
+ if tq:
904
+ try:
905
+ time_quotas_model = construct_model(PayServEmpTimeQuotas, tq)
906
+ except Exception:
907
+ time_quotas_model = PayServEmpTimeQuotas.model_validate(tq)
908
+
909
+ if pay_elements_data:
910
+ pay_elements = self.create_pay_elements_from_data(pay_elements_data)
911
+
912
+ # Create complete envelope
913
+ envelope_kwargs: Dict[str, Any] = {}
914
+ if logical_id is not None:
915
+ envelope_kwargs["logical_id"] = logical_id
916
+
917
+ if envelope_options:
918
+ envelope_kwargs.update(dict(envelope_options))
919
+
920
+ envelope_kwargs.setdefault("creation_datetime", datetime.now())
921
+ envelope_kwargs.setdefault("bod_id", str(uuid.uuid4()).upper())
922
+ envelope_kwargs.setdefault("logical_id", logical_id or "TST-GB003-1001")
923
+
924
+ envelope = self.create_complete_alight_envelope(
925
+ indicative_data=indicative_data,
926
+ extension=extension,
927
+ pay_elements=pay_elements,
928
+ time_elements=time_elements_model,
929
+ time_quotas=time_quotas_model,
930
+ action_code=action_code,
931
+ **envelope_kwargs,
932
+ )
933
+
934
+ # Serialize to XML
935
+ xml_string = self.serialize_with_namespaces(envelope, pretty_print=pretty_print)
936
+
937
+ if self.debug:
938
+ print("✅ Successfully generated XML from Employee object")
939
+
940
+ return xml_string
941
+
942
+ except Exception as e:
943
+ if self.debug:
944
+ print(f"❌ Failed to generate XML from Employee: {e}")
945
+ raise ValueError(f"Failed to generate XML from Employee: {e}")
946
+
947
+ def generate_newhire_from_employee(
948
+ self,
949
+ employee: EmployeeCreate,
950
+ logical_id: Optional[str] = None,
951
+ extension_data: Optional[Dict[str, Any]] = None,
952
+ pay_elements_data: Optional[Dict[str, Any]] = None,
953
+ pretty_print: bool = True
954
+ ) -> str:
955
+ """
956
+ Generate a NewHire XML document directly from an Employee instance.
957
+ Convenience method for generate_employee_xml with action_code="ADD".
958
+
959
+ Args:
960
+ employee: A valid Employee instance
961
+ logical_id: Optional logical ID for the sender
962
+ extension_data: Optional extension data dictionary
963
+ pay_elements_data: Optional pay elements data dictionary
964
+ pretty_print: Whether to format the XML with indentation
965
+
966
+ Returns:
967
+ str: Complete NewHire HR-XML string
968
+
969
+ Example:
970
+ >>> xml = alight.generate_newhire_from_employee(
971
+ ... employee=EmployeeCreate(person_id="35561", employee_id="35561ZZGB"),
972
+ ... pay_elements_data={"pay_element": [{"id": [{"value": "0010"}], "amount": {"value": "42000"}}]},
973
+ ... )
974
+ """
975
+ return self.generate_employee_xml(
976
+ employee=employee,
977
+ action_code="ADD",
978
+ logical_id=logical_id,
979
+ extension_data=extension_data,
980
+ pay_elements_data=pay_elements_data,
981
+ pretty_print=pretty_print
982
+ )
983
+
984
+ def generate_change_from_employee(
985
+ self,
986
+ employee: EmployeeCreate,
987
+ logical_id: Optional[str] = None,
988
+ extension_data: Optional[Dict[str, Any]] = None,
989
+ pay_elements_data: Optional[Dict[str, Any]] = None,
990
+ pretty_print: bool = True
991
+ ) -> str:
992
+ """
993
+ Generate an Employee Change XML document directly from an Employee instance.
994
+ Convenience method for generate_employee_xml with action_code="CHANGE".
995
+
996
+ Args:
997
+ employee: A valid Employee instance
998
+ logical_id: Optional logical ID for the sender
999
+ extension_data: Optional extension data dictionary
1000
+ pay_elements_data: Optional pay elements data dictionary
1001
+ pretty_print: Whether to format the XML with indentation
1002
+
1003
+ Returns:
1004
+ str: Complete Employee Change HR-XML string
1005
+
1006
+ Example:
1007
+ >>> xml = alight.generate_change_from_employee(
1008
+ ... employee=EmployeeCreate(person_id="35561", employee_id="35561ZZGB"),
1009
+ ... extension_data={"bank_accounts": {"bank_account": [{"iban": {"value": "GB00..."}}]}},
1010
+ ... )
1011
+ """
1012
+ return self.generate_employee_xml(
1013
+ employee=employee,
1014
+ action_code="CHANGE",
1015
+ logical_id=logical_id,
1016
+ extension_data=extension_data,
1017
+ pay_elements_data=pay_elements_data,
1018
+ pretty_print=pretty_print
1019
+ )