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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. brynq_sdk_sage_germany/__init__.py +278 -0
  2. brynq_sdk_sage_germany/absences.py +175 -0
  3. brynq_sdk_sage_germany/allowances.py +100 -0
  4. brynq_sdk_sage_germany/contracts.py +145 -0
  5. brynq_sdk_sage_germany/cost_centers.py +89 -0
  6. brynq_sdk_sage_germany/employees.py +140 -0
  7. brynq_sdk_sage_germany/helpers.py +391 -0
  8. brynq_sdk_sage_germany/organization.py +90 -0
  9. brynq_sdk_sage_germany/payroll.py +167 -0
  10. brynq_sdk_sage_germany/payslips.py +106 -0
  11. brynq_sdk_sage_germany/salaries.py +95 -0
  12. brynq_sdk_sage_germany/schemas/__init__.py +44 -0
  13. brynq_sdk_sage_germany/schemas/absences.py +311 -0
  14. brynq_sdk_sage_germany/schemas/allowances.py +147 -0
  15. brynq_sdk_sage_germany/schemas/cost_centers.py +46 -0
  16. brynq_sdk_sage_germany/schemas/employees.py +487 -0
  17. brynq_sdk_sage_germany/schemas/organization.py +172 -0
  18. brynq_sdk_sage_germany/schemas/organization_assignment.py +61 -0
  19. brynq_sdk_sage_germany/schemas/payroll.py +287 -0
  20. brynq_sdk_sage_germany/schemas/payslips.py +34 -0
  21. brynq_sdk_sage_germany/schemas/salaries.py +101 -0
  22. brynq_sdk_sage_germany/schemas/start_end_dates.py +194 -0
  23. brynq_sdk_sage_germany/schemas/vacation_account.py +117 -0
  24. brynq_sdk_sage_germany/schemas/work_hours.py +94 -0
  25. brynq_sdk_sage_germany/start_end_dates.py +123 -0
  26. brynq_sdk_sage_germany/vacation_account.py +70 -0
  27. brynq_sdk_sage_germany/work_hours.py +97 -0
  28. brynq_sdk_sage_germany-1.0.0.dist-info/METADATA +21 -0
  29. brynq_sdk_sage_germany-1.0.0.dist-info/RECORD +31 -0
  30. brynq_sdk_sage_germany-1.0.0.dist-info/WHEEL +5 -0
  31. brynq_sdk_sage_germany-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,90 @@
1
+ """
2
+ Organization endpoint implementations for Sage Germany.
3
+ """
4
+
5
+
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import pandas as pd
10
+ from brynq_sdk_functions import Functions
11
+
12
+ from .helpers import organization_flat_to_nested
13
+ from .schemas.organization import OrganizationCreate, OrganizationGet
14
+
15
+
16
+ class Organization:
17
+ """
18
+ Handles organization structure and cost allocation operations scoped to employees.
19
+ """
20
+
21
+ def __init__(self, sage) -> None:
22
+ self.sage = sage
23
+ self.base_url = "/employeenew/person/Organisation"
24
+
25
+ def _collect_employee_keys(self) -> List[Dict[str, int]]:
26
+ """
27
+ Retrieve distinct MdNr/AnNr pairs using the employee search endpoint.
28
+ """
29
+ return self.sage._employee_search()
30
+
31
+ def get(
32
+ self,
33
+ date: Optional[str] = None,
34
+ company_id: Optional[int] = None,
35
+ employee_number: Optional[int] = None,
36
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
37
+ """
38
+ Retrieve organization data (cost centers, cost units, org structures) for employees.
39
+ """
40
+ try:
41
+ if company_id is not None and employee_number is not None:
42
+ employee_keys = [{"MdNr": company_id, "AnNr": employee_number}]
43
+ else:
44
+ employee_keys = self._collect_employee_keys()
45
+
46
+ if not employee_keys:
47
+ return pd.DataFrame(), pd.DataFrame()
48
+
49
+ organization_records: List[Dict[str, Any]] = []
50
+ effective_date = date or datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00")
51
+
52
+ for key in employee_keys:
53
+ response = self.sage.get(
54
+ path=self.base_url,
55
+ params={"MdNr": key["MdNr"], "AnNr": key["AnNr"], "Date": effective_date},
56
+ )
57
+ response.raise_for_status()
58
+ payload = response.json()
59
+ if isinstance(payload, dict):
60
+ organization_records.append(payload)
61
+
62
+ if not organization_records:
63
+ return pd.DataFrame(), pd.DataFrame()
64
+
65
+ dataframe = pd.json_normalize(organization_records, sep="__")
66
+
67
+ valid_data, invalid_data = Functions.validate_data(
68
+ df=dataframe,
69
+ schema=OrganizationGet,
70
+ )
71
+ return valid_data, invalid_data
72
+ except Exception as exc:
73
+ raise RuntimeError("Failed to retrieve organization data.") from exc
74
+
75
+ def create(self, data: Dict[str, Any]) -> Dict[str, Any]:
76
+ """
77
+ Create or update organization allocation data for an employee.
78
+ """
79
+ try:
80
+ nested_payload = organization_flat_to_nested(data)
81
+ payload = OrganizationCreate(**nested_payload).model_dump(by_alias=True, exclude_none=True, mode="json")
82
+
83
+ response = self.sage.post(
84
+ path=self.base_url,
85
+ body=payload,
86
+ )
87
+ response.raise_for_status()
88
+ return response
89
+ except Exception as exc:
90
+ raise RuntimeError("Failed to create Sage Germany organization allocation.") from exc
@@ -0,0 +1,167 @@
1
+ """
2
+ Payroll endpoint implementations for Sage Germany.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
9
+
10
+ import pandas as pd
11
+ from brynq_sdk_functions import Functions
12
+
13
+ from .helpers import sage_flat_to_nested_with_prefix
14
+ from .schemas import PayrollGet, PayrollMasterDataCreate, PayrollMasterDataGet
15
+
16
+ if TYPE_CHECKING:
17
+ from .employees import Employees
18
+
19
+
20
+ class Payroll:
21
+ """
22
+ Handles payroll-related operations scoped to employees.
23
+ """
24
+
25
+ def __init__(self, sage) -> None:
26
+ self.sage = sage
27
+ self.master_data_url = "/employeenew/abrechnungsdaten/grunddaten"
28
+ self.master_data_create_url = "/employeenew/abrechnungsdaten/grunddaten"
29
+ self.payroll_url = "/pay/abrechnung/abrechnungsdaten"
30
+
31
+ def get_master_data(
32
+ self,
33
+ date: Optional[str] = None,
34
+ company_id: Optional[int] = None,
35
+ employee_number: Optional[int] = None,
36
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
37
+ """
38
+ Retrieve payroll master data for a specific employee or all employees.
39
+ """
40
+ try:
41
+ if company_id is not None and employee_number is not None:
42
+ employee_keys = [{"MdNr": company_id, "AnNr": employee_number}]
43
+ else:
44
+ employee_keys = self.sage._employee_search()
45
+
46
+ if not employee_keys:
47
+ return pd.DataFrame(), pd.DataFrame()
48
+
49
+ master_records: List[Dict[str, Any]] = []
50
+ effective_date = date or datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00")
51
+
52
+ for key in employee_keys:
53
+ response = self.sage.get(
54
+ path=self.master_data_url,
55
+ params={"MdNr": key["MdNr"], "AnNr": key["AnNr"], "date": effective_date},
56
+ )
57
+ response.raise_for_status()
58
+ payload = response.json()
59
+ if isinstance(payload, dict):
60
+ master_records.append(payload)
61
+
62
+ if not master_records:
63
+ return pd.DataFrame(), pd.DataFrame()
64
+
65
+ dataframe = pd.json_normalize(master_records, sep="__")
66
+
67
+ valid_data, invalid_data = Functions.validate_data(
68
+ df=dataframe,
69
+ schema=PayrollMasterDataGet,
70
+ )
71
+ return valid_data, invalid_data
72
+ except Exception as exc:
73
+ raise RuntimeError("Failed to retrieve payroll master data.") from exc
74
+
75
+ def get(
76
+ self,
77
+ date: Optional[str] = None,
78
+ company_id: Optional[int] = None,
79
+ employee_number: Optional[int] = None,
80
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
81
+ """
82
+ Retrieve payroll run results (Brutto/Netto, tax, and insurance breakdowns).
83
+ """
84
+ try:
85
+ if company_id is not None and employee_number is not None:
86
+ employee_keys = [{"MdNr": company_id, "AnNr": employee_number}]
87
+ else:
88
+ employee_keys = self.sage._employee_search()
89
+
90
+ if not employee_keys:
91
+ return pd.DataFrame(), pd.DataFrame()
92
+
93
+ payroll_records: List[Dict[str, Any]] = []
94
+ effective_date = date or datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00")
95
+
96
+ for key in employee_keys:
97
+ response = self.sage.get(
98
+ path=self.payroll_url,
99
+ params={"MdNr": key["MdNr"], "AnNr": key["AnNr"], "date": effective_date},
100
+ )
101
+ response.raise_for_status()
102
+ payload = response.json()
103
+
104
+ if isinstance(payload, dict):
105
+ payload_items = [payload]
106
+ elif isinstance(payload, list):
107
+ payload_items = payload
108
+ else:
109
+ continue
110
+
111
+ for entry in payload_items:
112
+ if not isinstance(entry, dict):
113
+ continue
114
+ enriched_entry = entry.copy()
115
+ enriched_entry["company_id"] = key["MdNr"]
116
+ enriched_entry["employee_number"] = enriched_entry.get("Personalnummer", key["AnNr"])
117
+ enriched_entry["source_date"] = effective_date
118
+ payroll_records.append(enriched_entry)
119
+
120
+ if not payroll_records:
121
+ return pd.DataFrame(), pd.DataFrame()
122
+
123
+ dataframe = pd.json_normalize(payroll_records, sep="__").copy()
124
+
125
+ dataframe["company_id"] = (
126
+ pd.to_numeric(dataframe["company_id"], errors="coerce").astype("Int64")
127
+ )
128
+ dataframe["employee_number"] = (
129
+ pd.to_numeric(dataframe["employee_number"], errors="coerce").astype("Int64")
130
+ )
131
+
132
+ source_dates = pd.to_datetime(
133
+ dataframe["source_date"], errors="coerce"
134
+ ).dt.strftime("%Y-%m-%d")
135
+
136
+ dataframe["combined_key"] = (
137
+ dataframe["company_id"].astype("string")
138
+ + "_"
139
+ + dataframe["employee_number"].astype("string")
140
+ + "_"
141
+ + source_dates.fillna("")
142
+ )
143
+
144
+ valid_data, invalid_data = Functions.validate_data(
145
+ df=dataframe,
146
+ schema=PayrollGet,
147
+ )
148
+ return valid_data, invalid_data
149
+ except Exception as exc:
150
+ raise RuntimeError("Failed to retrieve payroll data.") from exc
151
+
152
+ def create_master_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
153
+ """
154
+ Create or update payroll master data (abrechnungsdaten/grunddaten).
155
+ """
156
+ try:
157
+ nested_payload = sage_flat_to_nested_with_prefix(data, PayrollMasterDataCreate)
158
+ payload = PayrollMasterDataCreate(**nested_payload).model_dump(by_alias=True, exclude_none=True, mode="json")
159
+
160
+ response = self.sage.post(
161
+ path=self.master_data_create_url,
162
+ body=payload,
163
+ )
164
+ response.raise_for_status()
165
+ return response
166
+ except Exception as exc:
167
+ raise RuntimeError("Failed to create Sage Germany payroll master data.") from exc
@@ -0,0 +1,106 @@
1
+ """
2
+ Payroll document endpoints (payslip metadata + download) for Sage Germany.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import pandas as pd
10
+ from brynq_sdk_functions import Functions
11
+
12
+ from .schemas import PayslipsGet
13
+
14
+
15
+
16
+
17
+ class Payslips:
18
+ """
19
+ Retrieves and downloads payslip-style payroll documents (Verdienstbescheinigung §108 GewO).
20
+ """
21
+
22
+ def __init__(self, sage) -> None:
23
+ self.sage = sage
24
+ self.base_url = "/Employee/Pay/Documents"
25
+
26
+ def _fetch_payload(self, request_params: Dict[str, Any]) -> List[Dict[str, Any]]:
27
+ response = self.sage.get(
28
+ path=self.base_url,
29
+ params=request_params,
30
+ )
31
+ response.raise_for_status()
32
+ payload = response.json()
33
+ return payload if isinstance(payload, list) else []
34
+
35
+ def _fetch_all_employee_payslips(self) -> List[Dict[str, Any]]:
36
+ employee_keys = self.sage._employee_search() # noqa: SLF001
37
+ if not employee_keys:
38
+ return []
39
+
40
+ records: List[Dict[str, Any]] = []
41
+ for key in employee_keys:
42
+ key_params: Dict[str, Any] = {
43
+ "MdNr": key["MdNr"],
44
+ "AnNr": key["AnNr"],
45
+ "sbc": "false",
46
+ }
47
+ records.extend(self._fetch_payload(key_params))
48
+ return records
49
+
50
+ def get(
51
+ self,
52
+ period: Optional[str] = None,
53
+ company_id: Optional[int] = None,
54
+ employee_number: Optional[int] = None,
55
+ date: Optional[str] = None,
56
+ employee_shipping_type: Optional[int] = None,
57
+ sbc: Optional[bool] = False,
58
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
59
+ """
60
+ List payslips for the provided filters.
61
+ """
62
+ try:
63
+ params: Dict[str, Any] = {}
64
+ if period:
65
+ params["periode"] = period
66
+ if company_id is not None:
67
+ params["MdNr"] = company_id
68
+ if employee_number is not None:
69
+ params["AnNr"] = employee_number
70
+ if date:
71
+ params["Date"] = date
72
+ if employee_shipping_type is not None:
73
+ params["employeeshippingtype"] = employee_shipping_type
74
+
75
+ params["sbc"] = str(sbc).lower()
76
+
77
+ if params:
78
+ records = self._fetch_payload(params)
79
+ else:
80
+ records = self._fetch_all_employee_payslips()
81
+
82
+ if not records:
83
+ return pd.DataFrame(), pd.DataFrame()
84
+
85
+ df = pd.json_normalize(records, sep="__")
86
+ valid_data, invalid_data = Functions.validate_data(
87
+ df=df,
88
+ schema=PayslipsGet,
89
+ )
90
+ return valid_data, invalid_data
91
+ except Exception as exc:
92
+ raise RuntimeError("Failed to retrieve payslips.") from exc
93
+
94
+ def download(self, document_id: int, inline: bool = False) -> bytes:
95
+ """
96
+ Download a single payslip (PDF binary).
97
+ """
98
+ try:
99
+ response = self.sage.get(
100
+ path=self.base_url,
101
+ params={"Id": document_id, "Inline": str(inline).lower()},
102
+ )
103
+ response.raise_for_status()
104
+ return response.content
105
+ except Exception as exc:
106
+ raise RuntimeError(f"Failed to download payslip {document_id}.") from exc
@@ -0,0 +1,95 @@
1
+ """
2
+ Salary endpoint implementations for Sage Germany.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional, Tuple, Union
6
+
7
+ import pandas as pd
8
+ from brynq_sdk_functions import Functions
9
+
10
+ from .schemas.salaries import SalaryGet, SalaryImport
11
+
12
+
13
+ class Salary:
14
+ """
15
+ Handles salary-related operations scoped to employees.
16
+ """
17
+
18
+ def __init__(self, sage) -> None:
19
+ self.sage = sage
20
+ self.base_url = "/employeenew/entgelte/lohngehalt"
21
+
22
+ def get(
23
+ self,
24
+ company_id: Optional[int] = None,
25
+ employee_number: Optional[int] = None,
26
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
27
+ """
28
+ Retrieve salary records for a single employee or all employees.
29
+ """
30
+ try:
31
+ if company_id is not None and employee_number is not None:
32
+ employee_keys = [{"MdNr": company_id, "AnNr": employee_number}]
33
+ else:
34
+ employee_keys = self.sage._employee_search()
35
+
36
+ if not employee_keys:
37
+ return pd.DataFrame(), pd.DataFrame()
38
+
39
+ salary_records: List[Dict[str, Any]] = []
40
+ for key in employee_keys:
41
+ response = self.sage.get(
42
+ path=self.base_url,
43
+ params={"MdNr": key["MdNr"], "AnNr": key["AnNr"]},
44
+ )
45
+ response.raise_for_status()
46
+ payload = response.json()
47
+ if isinstance(payload, dict):
48
+ salary_records.append(payload)
49
+
50
+ if not salary_records:
51
+ return pd.DataFrame(), pd.DataFrame()
52
+
53
+ df = pd.json_normalize(salary_records, sep="__")
54
+
55
+ valid_data, invalid_data = Functions.validate_data(
56
+ df=df,
57
+ schema=SalaryGet,
58
+ )
59
+ return valid_data, invalid_data
60
+ except Exception as exc:
61
+ raise RuntimeError("Failed to retrieve salary data.") from exc
62
+
63
+ def update(self, data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Dict[str, Any]:
64
+ """
65
+ Import or update gross salary (Bruttolohn) records.
66
+
67
+ Accepts a single dict or a list of dicts with Sage field names such as
68
+ Mdnr, Annr, AbrJahr, AbrMon, Tag, Lanr, Anzahl, Betrag, KoSt1, KoTr1, AA1, Zuschlag.
69
+ """
70
+ try:
71
+ if isinstance(data, dict):
72
+ items = [data]
73
+ elif isinstance(data, list):
74
+ if not data:
75
+ raise ValueError("Salary update payload list must not be empty.")
76
+ if not all(isinstance(item, dict) for item in data):
77
+ raise ValueError("Salary update payload list must contain only dictionaries.")
78
+ items = data
79
+ else:
80
+ raise ValueError("Salary update payload must be a dictionary or list of dictionaries.")
81
+
82
+ # API requires a list payload even for single salary import items.
83
+ payload = [
84
+ SalaryImport(**item).model_dump(by_alias=True, exclude_none=True, mode="json")
85
+ for item in items
86
+ ]
87
+
88
+ response = self.sage.post(
89
+ path="/Pay/Import/Bruttolohn",
90
+ body=payload,
91
+ )
92
+ response.raise_for_status()
93
+ return response
94
+ except Exception as exc:
95
+ raise RuntimeError("Failed to update Sage Germany salary records.") from exc
@@ -0,0 +1,44 @@
1
+ """
2
+ Schema namespace for Sage Germany endpoints.
3
+ """
4
+
5
+ from .employees import EmployeeUpdateRequest, EmployeeCreateForm, EmployeesGet
6
+ from .absences import AbsenceTypesGet, AbsencesGet, AbsenceCreate, AbsenceDelete
7
+
8
+ from .salaries import SalaryGet, SalaryImport
9
+
10
+
11
+ from .allowances import AllowancesGet, AllowanceCreateRequest
12
+ from .start_end_dates import StartEndDatesGet
13
+ from .work_hours import WorkHoursGet, WorkHoursCreate
14
+ from .payroll import PayrollGet, PayrollMasterDataGet, PayrollMasterDataCreate
15
+ from .payslips import PayslipsGet
16
+ from .vacation_account import VacationAccountGet
17
+ from .cost_centers import CostCentersGet, CostCenterCreate
18
+ from .organization import OrganizationGet, OrganizationCreate
19
+
20
+ __all__ = [
21
+ "EmployeesGet",
22
+ "EmployeeUpdateRequest",
23
+ "EmployeeCreateForm",
24
+ "AbsencesGet",
25
+ "AbsenceTypesGet",
26
+ "AbsenceCreate",
27
+ "AbsenceDelete",
28
+ "SalaryGet",
29
+ "SalaryImport",
30
+ "AllowancesGet",
31
+ "AllowanceCreateRequest",
32
+ "StartEndDatesGet",
33
+ "WorkHoursGet",
34
+ "WorkHoursCreate",
35
+ "PayrollGet",
36
+ "PayrollMasterDataGet",
37
+ "PayrollMasterDataCreate",
38
+ "PayslipsGet",
39
+ "VacationAccountGet",
40
+ "CostCentersGet",
41
+ "CostCenterCreate",
42
+ "OrganizationGet",
43
+ "OrganizationCreate",
44
+ ]