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.
Files changed (44) hide show
  1. brynq_sdk_alight/__init__.py +1058 -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 +656 -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 +145 -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.dev0.dist-info/METADATA +20 -0
  42. brynq_sdk_alight-1.0.0.dev0.dist-info/RECORD +44 -0
  43. brynq_sdk_alight-1.0.0.dev0.dist-info/WHEEL +5 -0
  44. 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
+ )