diff --git a/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/__init__.py b/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/energy_certificate_link.json b/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/energy_certificate_link.json
new file mode 100644
index 0000000..759bb39
--- /dev/null
+++ b/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/energy_certificate_link.json
@@ -0,0 +1,34 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2026-06-03 10:04:29.254339",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "energy_certificate"
+ ],
+ "fields": [
+ {
+ "fieldname": "energy_certificate",
+ "fieldtype": "Link",
+ "label": "Energy Certificate",
+ "options": "Energy Certificate Link"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-06-03 20:03:26.621275",
+ "modified_by": "Administrator",
+ "module": "Openimmo Propms",
+ "name": "Energy Certificate Link",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/energy_certificate_link.py b/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/energy_certificate_link.py
new file mode 100644
index 0000000..37b2f03
--- /dev/null
+++ b/openimmo_propms/openimmo_propms/doctype/energy_certificate_link/energy_certificate_link.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2026, Talib sheikh and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EnergyCertificateLink(Document):
+ pass
diff --git a/openimmo_propms/openimmo_propms/doctype/integration_field_mapping/integration_field_mapping.json b/openimmo_propms/openimmo_propms/doctype/integration_field_mapping/integration_field_mapping.json
index e65e259..24cc0af 100644
--- a/openimmo_propms/openimmo_propms/doctype/integration_field_mapping/integration_field_mapping.json
+++ b/openimmo_propms/openimmo_propms/doctype/integration_field_mapping/integration_field_mapping.json
@@ -8,7 +8,6 @@
"source_configuration_section",
"source_value_type",
"source_field",
- "static_value",
"column_break_source",
"transformation",
"value_mapping",
@@ -17,6 +16,7 @@
"export_strip_prefix",
"target_mapping_section",
"target_field",
+ "static_value",
"column_break_target",
"is_unique",
"auto_create_link",
@@ -30,6 +30,7 @@
"label": "Source Configuration"
},
{
+ "columns": 1,
"default": "Field",
"fieldname": "source_value_type",
"fieldtype": "Select",
@@ -38,6 +39,7 @@
"options": "Field\nStatic"
},
{
+ "columns": 2,
"description": "Example: immobilie.geo.plz",
"fieldname": "source_field",
"fieldtype": "Data",
@@ -46,9 +48,11 @@
"reqd": 1
},
{
+ "columns": 2,
"depends_on": "eval:doc.source_value_type=='Static'",
"fieldname": "static_value",
"fieldtype": "Data",
+ "in_list_view": 1,
"label": "Static Value",
"mandatory_depends_on": "eval:doc.source_value_type=='Static'"
},
@@ -95,6 +99,7 @@
"label": "Target Mapping"
},
{
+ "columns": 2,
"description": "Frappe fieldname (e.g. zip_code)",
"fieldname": "target_field",
"fieldtype": "Data",
@@ -110,14 +115,12 @@
"default": "0",
"fieldname": "is_unique",
"fieldtype": "Check",
- "in_list_view": 1,
"label": "Is Unique (Duplicate Check)"
},
{
"default": "0",
"fieldname": "auto_create_link",
"fieldtype": "Check",
- "in_list_view": 1,
"label": "Auto Create Link Record"
},
{
@@ -131,7 +134,6 @@
"description": "Specific DocType to create the record in.",
"fieldname": "link_target_doctype",
"fieldtype": "Link",
- "in_list_view": 1,
"label": "Link Target DocType",
"mandatory_depends_on": "eval:doc.auto_create_link == 1",
"options": "DocType"
@@ -140,7 +142,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-02-23 08:53:30.154407",
+ "modified": "2026-06-03 12:40:53.596880",
"modified_by": "Administrator",
"module": "Openimmo Propms",
"name": "Integration Field Mapping",
@@ -150,4 +152,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
-}
+}
\ No newline at end of file
diff --git a/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.js b/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.js
index c741112..75f47a3 100644
--- a/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.js
+++ b/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.js
@@ -1,10 +1,12 @@
frappe.ui.form.on("Integration Source", {
onload(frm) {
toggle_credential_fields(frm);
+ update_ftp_intro(frm);
},
refresh(frm) {
toggle_credential_fields(frm);
+ update_ftp_intro(frm);
if (frm.doc.source_type === "FTP") {
// 1. Connection Test Button
@@ -111,6 +113,10 @@ frappe.ui.form.on("Integration Source", {
}
},
+ ftp_transfer_enabled(frm) {
+ update_ftp_intro(frm);
+ },
+
source_type(frm) {
toggle_credential_fields(frm);
},
@@ -120,6 +126,22 @@ frappe.ui.form.on("Integration Source", {
},
});
+function update_ftp_intro(frm) {
+ if (frm.doc.source_type !== "FTP") {
+ frm.set_intro(""); // Clear intro if not FTP
+ return;
+ }
+
+ // Clear existing intro first to avoid duplication
+ frm.set_intro("");
+
+ if (frm.doc.ftp_transfer_enabled) {
+ frm.set_intro(__("Exporting will trigger FTP Transfer."), "green");
+ } else {
+ frm.set_intro(__("FTP Transfer is Disabled. Files will only be generated locally."), "red");
+ }
+}
+
function toggle_credential_fields(frm) {
const is_import = frm.doc.operation_type === "Import";
const is_export = frm.doc.operation_type === "Export";
diff --git a/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.json b/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.json
index 6e577c7..f22ce71 100644
--- a/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.json
+++ b/openimmo_propms/openimmo_propms/doctype/integration_source/integration_source.json
@@ -387,6 +387,7 @@
"label": "Field Mapping"
},
{
+ "allow_bulk_edit": 1,
"fieldname": "field_mappings",
"fieldtype": "Table",
"label": "Field Mappings",
@@ -430,7 +431,7 @@
"link_fieldname": "source_name"
}
],
- "modified": "2026-05-02 10:20:23.772618",
+ "modified": "2026-06-03 11:14:55.523019",
"modified_by": "Administrator",
"module": "Openimmo Propms",
"name": "Integration Source",
@@ -464,4 +465,4 @@
}
],
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/openimmo_propms/openimmo_propms/doctype/property_type/__init__.py b/openimmo_propms/openimmo_propms/doctype/property_type/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/openimmo_propms/openimmo_propms/doctype/property_type/property_type.js b/openimmo_propms/openimmo_propms/doctype/property_type/property_type.js
new file mode 100644
index 0000000..1ad86c8
--- /dev/null
+++ b/openimmo_propms/openimmo_propms/doctype/property_type/property_type.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2026, Talib sheikh and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Property Type", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/openimmo_propms/openimmo_propms/doctype/property_type/property_type.json b/openimmo_propms/openimmo_propms/doctype/property_type/property_type.json
new file mode 100644
index 0000000..e86243a
--- /dev/null
+++ b/openimmo_propms/openimmo_propms/doctype/property_type/property_type.json
@@ -0,0 +1,173 @@
+{
+ "actions": [],
+ "allow_import": 1,
+ "allow_rename": 1,
+ "autoname": "field:property_type_name",
+ "creation": "2026-06-02 08:19:19.220638",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "property_type_name",
+ "parent_property_type",
+ "is_group",
+ "is_active",
+ "description",
+ "mapping_section",
+ "openimmo_objektart",
+ "openimmo_attribute",
+ "openimmo_value",
+ "immowelt_value",
+ "usage_section",
+ "use_residential",
+ "use_commercial",
+ "use_investment",
+ "use_mixed",
+ "lft",
+ "rgt",
+ "old_parent"
+ ],
+ "fields": [
+ {
+ "fieldname": "property_type_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Property Type Name",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "parent_property_type",
+ "fieldtype": "Link",
+ "label": "Parent Property Type",
+ "options": "Property Type"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_group",
+ "fieldtype": "Check",
+ "label": "Is Group"
+ },
+ {
+ "default": "1",
+ "fieldname": "is_active",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Is Active"
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "label": "Description"
+ },
+ {
+ "fieldname": "mapping_section",
+ "fieldtype": "Section Break",
+ "label": "Portal Mapping"
+ },
+ {
+ "fieldname": "openimmo_objektart",
+ "fieldtype": "Select",
+ "label": "OpenImmo Objektart",
+ "options": "\nzimmer\nwohnung\nhaus\ngrundstueck\nbuero_praxen\neinzelhandel\ngastgewerbe\nhallen_lager_prod\nland_und_forstwirtschaft\nparken\nsonstige\nfreizeitimmobilie_gewerblich\nzinshaus_renditeobjekt"
+ },
+ {
+ "fieldname": "openimmo_attribute",
+ "fieldtype": "Select",
+ "label": "OpenImmo Attribute",
+ "options": "\nzimmertyp\nwohnungtyp\nhaustyp\ngrundst_typ\nbuero_typ\nhandel_typ\ngastgew_typ\nhallen_typ\nland_typ\nparken_typ\nsonstige_typ\nfreizeit_typ\nzins_typ"
+ },
+ {
+ "fieldname": "openimmo_value",
+ "fieldtype": "Select",
+ "label": "OpenImmo Value",
+ "options": "\nZIMMER\nDACHGESCHOSS\nMAISONETTE\nLOFT-STUDIO-ATELIER\nPENTHOUSE\nTERRASSEN\nETAGE\nERDGESCHOSS\nSOUTERRAIN\nAPARTMENT\nFERIENWOHNUNG\nGALERIE\nROHDACHBODEN\nATTIKAWOHNUNG\nREIHENHAUS\nREIHENEND\nREIHENMITTEL\nREIHENECK\nDOPPELHAUSHAELFTE\nEINFAMILIENHAUS\nSTADTHAUS\nBUNGALOW\nVILLA\nRESTHOF\nBAUERNHAUS\nLANDHAUS\nSCHLOSS\nZWEIFAMILIENHAUS\nMEHRFAMILIENHAUS\nFERIENHAUS\nBERGHUETTE\nCHALET\nSTRANDHAUS\nLAUBE-DATSCHE-GARTENHAUS\nAPARTMENTHAUS\nBURG\nHERRENHAUS\nFINCA\nRUSTICO\nFERTIGHAUS\nWOHNEN\nGEWERBE\nINDUSTRIE\nLAND_FORSTWIRTSCHAFT\nFREIZEIT\nGEMISCHT\nGEWERBEPARK\nSONDERNUTZUNG\nSEELIEGENSCHAFT\nBUEROFLAECHE\nBUEROHAUS\nBUEROZENTRUM\nLOFT_ATELIER\nPRAXIS\nPRAXISFLAECHE\nPRAXISHAUS\nAUSSTELLUNGSFLAECHE\nCOWORKING\nSHARED_OFFICE\nLADENLOKAL\nEINZELHANDELSLADEN\nVERBRAUCHERMARKT\nEINKAUFSZENTRUM\nKAUFHAUS\nFACTORY_OUTLET\nKIOSK\nVERKAUFSFLAECHE\nGASTRONOMIE\nGASTRONOMIE_UND_WOHNUNG\nPENSIONEN\nHOTELS\nWEITERE_BEHERBERGUNGSBETRIEBE\nBAR\nCAFE\nDISCOTHEK\nRESTAURANT\nRAUCHERLOKAL\nEINRAUMLOKAL\nHALLE\nINDUSTRIEHALLE\nLAGER\nLAGERFLAECHEN\nLAGER_MIT_FREIFLAECHE\nHOCHREGALLAGER\nSPEDITIONSLAGER\nPRODUKTION\nWERKSTATT\nSERVICE\nFREIFLAECHEN\nKUEHLHAUS\nLANDWIRTSCHAFTLICHE_BETRIEBE\nBAUERNHOF\nAUSSIEDLERHOF\nGARTENBAU\nACKERBAU\nWEINBAU\nVIEHWIRTSCHAFT\nJAGD_UND_FORSTWIRTSCHAFT\nTEICH_UND_FISCHWIRTSCHAFT\nSCHEUNEN\nREITERHOEFE\nSONSTIGE_LANDWIRTSCHAFTSIMMOBILIEN\nANWESEN\nJAGDREVIER\nSTELLPLATZ\nCARPORT\nDOPPELGARAGE\nDUPLEX\nTIEFGARAGE\nBOOTSLIEGEPLATZ\nEINZELGARAGE\nPARKHAUS\nTIEFGARAGENSTELLPLATZ\nPARKPLATZ_STROM\nTANKSTELLE\nKRANKENHAUS\nSONSTIGE\nSPORTANLAGEN\nVERGNUEGUNGSPARKS_UND_CENTER\nFREIZEITANLAGE\nWOHN_UND_GESCHAEFTSHAUS\nGESCHAEFTSHAUS\nBUEROGEBAEUDE\nSB_MAERKTE\nEINKAUFSCENTREN\nWOHNANLAGEN\nVERBRAUCHERMAERKTE\nINDUSTRIEANLAGEN\nPFLEGEHEIM\nSANATORIUM\nSENIORENHEIM\nBETREUTES-WOHNEN\nKEINE_ANGABE"
+ },
+ {
+ "fieldname": "immowelt_value",
+ "fieldtype": "Data",
+ "label": "Immowelt Value"
+ },
+ {
+ "fieldname": "usage_section",
+ "fieldtype": "Section Break",
+ "label": "Usage Configuration"
+ },
+ {
+ "default": "0",
+ "fieldname": "use_residential",
+ "fieldtype": "Check",
+ "label": "Is Residential"
+ },
+ {
+ "default": "0",
+ "fieldname": "use_commercial",
+ "fieldtype": "Check",
+ "label": "Is Commercial"
+ },
+ {
+ "default": "0",
+ "fieldname": "use_investment",
+ "fieldtype": "Check",
+ "label": "Is Investment"
+ },
+ {
+ "default": "0",
+ "fieldname": "use_mixed",
+ "fieldtype": "Check",
+ "label": "Is Mixed"
+ },
+ {
+ "fieldname": "lft",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Left",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "rgt",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Right",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "old_parent",
+ "fieldtype": "Link",
+ "label": "Old Parent",
+ "options": "Property Type"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_tree": 1,
+ "links": [],
+ "modified": "2026-06-02 09:34:22.258083",
+ "modified_by": "Administrator",
+ "module": "Openimmo Propms",
+ "name": "Property Type",
+ "naming_rule": "By fieldname",
+ "nsm_parent_field": "parent_property_type",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "property_type_name"
+}
diff --git a/openimmo_propms/openimmo_propms/doctype/property_type/property_type.py b/openimmo_propms/openimmo_propms/doctype/property_type/property_type.py
new file mode 100644
index 0000000..595a716
--- /dev/null
+++ b/openimmo_propms/openimmo_propms/doctype/property_type/property_type.py
@@ -0,0 +1,14 @@
+# Copyright (c) 2026, Talib sheikh and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class PropertyType(Document):
+ def validate(self):
+ # Strip whitespace from all string fields
+ for field in ["property_type_name", "property_category", "openimmo_objektart", "openimmo_attribute", "openimmo_value", "immowelt_value"]:
+ val = self.get(field)
+ if val:
+ self.set(field, val.strip())
diff --git a/openimmo_propms/openimmo_propms/doctype/property_type/test_property_type.py b/openimmo_propms/openimmo_propms/doctype/property_type/test_property_type.py
new file mode 100644
index 0000000..aea2f47
--- /dev/null
+++ b/openimmo_propms/openimmo_propms/doctype/property_type/test_property_type.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2026, Talib sheikh and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestPropertyType(FrappeTestCase):
+ pass
diff --git a/openimmo_propms/services/export_engine.py b/openimmo_propms/services/export_engine.py
index c91c1bb..a0ec72c 100644
--- a/openimmo_propms/services/export_engine.py
+++ b/openimmo_propms/services/export_engine.py
@@ -626,6 +626,11 @@ def _get_requested_fieldnames(source):
meta = frappe.get_meta(source.target_doctype)
valid_fields = {f.fieldname for f in meta.fields}
+ # Always try to fetch property type and unit ID for resolution
+ for field in ["custom_property_type", "custom_unit_id"]:
+ if field in valid_fields:
+ fieldnames.add(field)
+
for mapping in source.field_mappings:
fieldname = _get_configured_fieldname(mapping.target_field)
if fieldname and "." not in fieldname and fieldname in valid_fields:
diff --git a/openimmo_propms/services/export_mapper.py b/openimmo_propms/services/export_mapper.py
index ee723be..c8553c0 100644
--- a/openimmo_propms/services/export_mapper.py
+++ b/openimmo_propms/services/export_mapper.py
@@ -1,4 +1,6 @@
import frappe
+import math
+from frappe.utils import get_url
from openimmo_propms.services.mapper import apply_data_transformation
@@ -33,7 +35,11 @@ def build_property_data(source, record):
if val not in (None, ""):
# Map to indexed XML path
indexed_path = xml_path.replace("anhang", f"anhang.{i}")
- mapped_data[indexed_path] = _normalize_value(val)
+ mapped_data[indexed_path] = _normalize_export_value(
+ source,
+ indexed_path,
+ val,
+ )
continue
value = _get_record_value(record_data, fieldname, source.target_doctype)
@@ -46,11 +52,18 @@ def build_property_data(source, record):
value = apply_data_transformation(value, mapping, record_data)
value = _strip_prefix_for_export(value, mapping.get("export_strip_prefix"))
+ value = _normalize_export_value(source, xml_path, value)
mapped_data[xml_path] = value
# Smart fallback for mandatory OpenImmo fields
_ensure_mandatory_openimmo_fields(mapped_data, record_data, source)
+ # 10b. Marketing Title Fallback Logic
+ _apply_marketing_title_fallback(mapped_data, record_data)
+
+ # Automatically set nutzungsart attributes from Property Type master
+ _set_nutzungsart_attributes(mapped_data, record_data.get("custom_property_type"))
+
# Manually collect images if not mapped via standard field_mappings
image_gallery = record_data.get("custom_image_gallery")
if image_gallery and isinstance(image_gallery, list):
@@ -58,7 +71,7 @@ def build_property_data(source, record):
img_path = row.get("picture") if isinstance(row, dict) else getattr(row, "picture", None)
if img_path:
indexed_path = f"anhaenge.anhang.{i}.daten.pfad"
- mapped_data[indexed_path] = img_path
+ mapped_data[indexed_path] = _normalize_export_value(source, indexed_path, img_path)
# Attribute mappings remain (these are structural, not content)
mapped_data[f"anhaenge.anhang.{i}@location"] = "EXTERN"
@@ -78,6 +91,20 @@ def build_property_data(source, record):
return mapped_data
+def _apply_marketing_title_fallback(mapped_data, record_data):
+ """Applies fallback chain: custom_marketing_title -> name1."""
+ # 1. Primary: If already mapped from custom_marketing_title, do nothing
+ if mapped_data.get("freitexte.objekttitel"):
+ return
+
+ # 2. Fallback: Try name1
+ title = record_data.get("name1")
+
+ # Only assign if a title was found
+ if title:
+ mapped_data["freitexte.objekttitel"] = title
+
+
def _ensure_mandatory_openimmo_fields(mapped_data, record_data, source):
"""Ensure Immowelt doesn't reject due to missing type or category tags."""
@@ -118,42 +145,73 @@ def _ensure_mandatory_openimmo_fields(mapped_data, record_data, source):
mapped_data["objektkategorie.vermarktungsart@KAUF"] = True
# 3. Resolve Object Type (e.g., )
+ _resolve_property_type_from_record(mapped_data, record_data, source)
+
+ # Verify that an object type was mapped
has_type_tag = any(
- key.startswith("objektkategorie.objektart.") and "@" not in key
+ key.startswith("objektkategorie.objektart.")
for key in mapped_data
)
if not has_type_tag:
- # Check both raw record and already mapped text in 'objektart'
- type_hint = _normalize_value(
- mapped_data.get("objektkategorie.objektart") or
- record_data.get(source.name_field) or
- record_data.get("property_type") or
- record_data.get("type") or ""
- ).lower()
-
- if any(w in type_hint for w in ["flat", "apartment", "wohnung", "etage"]):
- mapped_data["objektkategorie.objektart.wohnung@wohnungtyp"] = "ETAGE"
- elif any(w in type_hint for w in ["house", "haus", "villa"]):
- mapped_data["objektkategorie.objektart.haus@haustyp"] = "EINFAMILIENHAUS"
- elif any(w in type_hint for w in ["office", "buero", "praxis", "laden", "shop"]):
- mapped_data["objektkategorie.objektart.buero_praxen@buerotyp"] = "BUEROFLAECHE"
- elif any(w in type_hint for w in ["garage", "stellplatz", "parking"]):
- mapped_data["objektkategorie.objektart.parken@parken_typ"] = "STELLPLATZ"
- elif any(w in type_hint for w in ["zimmer"]):
- mapped_data["objektkategorie.objektart.zimmer@zimmertyp"] = "ZIMMER"
+ frappe.log_error(f"Missing Property Type mapping for property: {record_data.get('name')}. Please configure the Property Type Master.")
+
+ # 4. Ensure some optional but helpful 'proper' tags exist
+ if "objektkategorie.user_defined_simplefield@feldname" not in mapped_data:
+ mapped_data["objektkategorie.user_defined_simplefield@feldname"] = ""
+
+
+def _resolve_property_type_from_record(mapped_data, record_data, source):
+ """Fetch Property Type details and map to OpenImmo objektart."""
+ # Use erpnext_id from mapping if available, otherwise record name or custom_unit_id
+ erpnext_id = mapped_data.get("erpnext_id") or record_data.get("name") or record_data.get("custom_unit_id")
+
+ property_type_name = record_data.get("custom_property_type")
+
+ if not property_type_name and erpnext_id and source.target_doctype:
+ # Try fetching from the actual record in the target doctype
+ property_type_name = frappe.db.get_value(
+ source.target_doctype, erpnext_id, "custom_property_type"
+ )
+
+ # Fallback: maybe the ID is custom_unit_id as in user's example
+ if not property_type_name:
+ property_type_name = frappe.db.get_value(
+ source.target_doctype,
+ {"custom_unit_id": erpnext_id},
+ "custom_property_type"
+ )
+
+ if not property_type_name:
+ return
+
+ try:
+ prop_type = frappe.get_cached_doc("Property Type", property_type_name)
+ except Exception:
+ # If it's a string name but doc doesn't exist by name, try getting by property_type_name field
+ prop_type_name = frappe.db.get_value("Property Type", {"property_type_name": property_type_name}, "name")
+ if prop_type_name:
+ prop_type = frappe.get_cached_doc("Property Type", prop_type_name)
else:
- mapped_data["objektkategorie.objektart.zusatz_erweit@objektart_standard"] = "SONSTIGE"
+ return
- # If 'objektart' text was mapped (like 'Apartment'), we usually want to clear it
- # to avoid having both text AND sub-tags inside , which is non-standard.
- if has_type_tag or not mapped_data.get("objektkategorie.objektart.zusatz_erweit@objektart_standard") == "SONSTIGE":
+ objektart = prop_type.get("openimmo_objektart")
+ attribute = prop_type.get("openimmo_attribute")
+ value = prop_type.get("openimmo_value")
+
+ if objektart:
+ # Clear any existing text mapping to objektart to avoid validation errors
if "objektkategorie.objektart" in mapped_data:
- # Move the text to a more appropriate place or clear it
- if not mapped_data.get("freitexte.objekttitel"):
- mapped_data["freitexte.objekttitel"] = mapped_data["objektkategorie.objektart"]
del mapped_data["objektkategorie.objektart"]
+ path = f"objektkategorie.objektart.{objektart}"
+ if attribute and value:
+ # Ensure value is uppercase for OpenImmo standards
+ mapped_data[f"{path}@{attribute}"] = str(value).upper()
+ else:
+ # Force tag creation (e.g. )
+ mapped_data[path] = ""
+
# 4. Ensure some optional but helpful 'proper' tags exist
if "objektkategorie.user_defined_simplefield@feldname" not in mapped_data:
mapped_data["objektkategorie.user_defined_simplefield@feldname"] = ""
@@ -256,3 +314,102 @@ def _normalize_value(value):
cleaned = [str(item) for item in value if item not in (None, "")]
return "\n".join(cleaned) if cleaned else None
return value
+
+
+def _normalize_export_value(source, xml_path, value):
+ # Fix for 'anzahl_stellplaetze' positive integer constraint
+ if "anzahl_stellplaetze" in xml_path:
+ try:
+ val_int = int(float(value))
+ if val_int <= 0:
+ return None # Omit tag if 0 or less to pass XSD
+ return val_int
+ except (ValueError, TypeError):
+ return None
+
+ # 31. Round all area fields up to next 5
+ area_fields = [
+ "wohnflaeche", "nutzflaeche", "gesamtflaeche", "ladenflaeche",
+ "lagerflaeche", "verkaufsflaeche", "bueroflaeche", "kellerflaeche",
+ "gartenflaeche", "balkon_terrasse_flaeche"
+ ]
+ if any(field in xml_path for field in area_fields):
+ try:
+ val_float = float(value)
+ return math.ceil(val_float / 5) * 5
+ except (ValueError, TypeError):
+ pass
+
+ # Fix for 'haustiere' boolean mapping
+ if "haustiere" in xml_path:
+ val_str = str(value).strip().lower()
+ if val_str in ["ja", "nach absprache", "1", "true", "yes"]:
+ return True
+ elif val_str in ["nein", "0", "false"]:
+ return False
+ else:
+ return None # Omit element if invalid
+
+ # Fix for 'moebliert' enumeration mapping
+ if "moebliert" in xml_path and "@moeb" in xml_path:
+ val_str = str(value).strip().lower()
+ if val_str in ["voll", "teil"]:
+ return val_str.upper()
+ elif val_str in ["1", "true", "yes", "checked", "on"]:
+ return "VOLL"
+ else:
+ return None # Omit element if '0', 'false', or invalid/unchecked
+
+ # Fix for 'heizungsart' and 'befeuerung' attribute mapping
+ if ("heizungsart" in xml_path or "befeuerung" in xml_path) and "@" in xml_path:
+ # If the attribute exists and has a value, it should be 'true'
+ val_str = str(value).strip().lower()
+ if val_str not in ["0", "false", "none", "", "no"]:
+ return "true"
+ return None # Omit attribute if false/0/no
+
+ normalized = _normalize_value(value)
+ if not _is_media_path_field(xml_path):
+ return normalized
+ return _build_absolute_media_url(source, normalized)
+
+def _set_nutzungsart_attributes(mapped_data, property_type_name):
+ """Fetches usage attributes from Property Type master and maps to XML."""
+ if not property_type_name:
+ return
+
+ try:
+ # Fetch document from Property Type master
+ prop_type = frappe.get_cached_doc("Property Type", property_type_name)
+
+ # Map XML attributes
+ mapped_data["objektkategorie.nutzungsart@WOHNEN"] = bool(prop_type.use_residential)
+ mapped_data["objektkategorie.nutzungsart@GEWERBE"] = bool(prop_type.use_commercial)
+ mapped_data["objektkategorie.nutzungsart@ANLAGE"] = bool(prop_type.use_investment)
+ mapped_data["objektkategorie.nutzungsart@WAZ"] = bool(prop_type.use_mixed)
+
+ except Exception:
+ pass
+
+
+def _is_media_path_field(xml_path):
+ path = (xml_path or "").strip()
+ return path == "pfad" or path.endswith(".pfad")
+
+
+def _build_absolute_media_url(source, image_value):
+ if not image_value:
+ return image_value
+
+ image_url = str(image_value).strip()
+ if not image_url:
+ return image_url
+
+ if image_url.startswith(("http://", "https://")):
+ return image_url
+
+ base_url = (getattr(source, "base_media_url", "") or "").strip() or get_url()
+ if not base_url:
+ return image_url
+
+ return "{0}/{1}".format(base_url.rstrip("/"), image_url.lstrip("/"))
diff --git a/openimmo_propms/services/xml_builder.py b/openimmo_propms/services/xml_builder.py
index 2ce3f8f..3ca2773 100644
--- a/openimmo_propms/services/xml_builder.py
+++ b/openimmo_propms/services/xml_builder.py
@@ -37,7 +37,7 @@ def ensure_xml_path(parent, path):
def set_xml_value(parent, path, value):
"""Set a text value or attribute at a dotted XML path."""
- if value is None or value == "":
+ if value is None:
return
# Standard OpenImmo booleans are lowercase "true" or "false"
diff --git a/openimmo_propms/services/xsd_builder.py b/openimmo_propms/services/xsd_builder.py
index a7dd39a..ea1555f 100644
--- a/openimmo_propms/services/xsd_builder.py
+++ b/openimmo_propms/services/xsd_builder.py
@@ -97,9 +97,14 @@ def _fill_element(self, node, schema_element, data, prefix=""):
if schema_element.type.is_simple() or schema_element.type.has_simple_content():
val = data.get(prefix)
- # Mandatory field 'stand_vom' needs a date
- if schema_element.name == 'stand_vom' and (val in [None, ""]):
- val = nowdate()
+ # Mandatory field 'stand_vom' needs a date (YYYY-MM-DD)
+ if schema_element.name == 'stand_vom':
+ from frappe.utils import getdate
+ if val in [None, ""]:
+ val = nowdate()
+ else:
+ # Ensure it's a date object (handles strings and datetime objects)
+ val = getdate(val)
if val not in [None, ""]:
node.text = self._format_value(val)
@@ -184,11 +189,6 @@ def _get_indices(self, data, prefix):
indices.add(int(parts[0]))
return sorted(list(indices))
- def _format_value(self, val):
- if isinstance(val, bool):
- return "true" if val else "false"
- return str(val)
-
def generate_xsd_based_xml(properties_data, anbieter_context, version="1.2.7"):
builder = XSDDrivenBuilder()
return builder.generate_xml(properties_data, anbieter_context, version)