Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
862700d
fix(pool): enforce missing star-topology rule for alternative headqua…
mohan06-mbrd Aug 6, 2026
2cec18e
fix(pool): validate overlapping relations by relation id to keep alt-…
mohan06-mbrd Aug 6, 2026
bb3850f
feat(pool): distribute ultimateOwnerBpnl to alternative headquarters …
mohan06-mbrd Aug 7, 2026
919b611
test(pool): wrap alternative headquarter relation creation in transac…
mohan06-mbrd Aug 7, 2026
fa96d11
fix(pool): resolve alternatives via main only and ignore stale IsOwne…
mohan06-mbrd Aug 7, 2026
6821080
Merge branch 'main' into alternate_headquarter_expansion
mohan06-mbrd Aug 7, 2026
795610c
fix: add AlternativeHeadquarterCannotOwnUltimately error mapping and …
mohan06-mbrd Aug 7, 2026
f640dac
Merge branch 'main' into alternate_headquarter_expansion
mohan06-mbrd Aug 10, 2026
8338382
refactor(pool): address code review feedback for alternative headquar…
mohan06-mbrd Aug 10, 2026
1827c62
test(system-tester): add ultimate owner distribution feature tests
mohan06-mbrd Aug 10, 2026
72edbb4
added step defn for ultimate owner
mohan06-mbrd Aug 12, 2026
747c1cd
feat(system-tester): implement ultimate owner distribution step defin…
mohan06-mbrd Aug 12, 2026
6cf7083
fix: use regex pattern for cucumber step definition instead of invali…
mohan06-mbrd Aug 12, 2026
21b9655
fix: remove duplicate step definition annotations
mohan06-mbrd Aug 12, 2026
0c65a68
Merge branch 'main' into feature/ultimate-owner-system-tests
mohan06-mbrd Aug 13, 2026
52fccd4
refactor: improve ultimate owner tests with business language and pro…
mohan06-mbrd Aug 13, 2026
3e4f885
fix: use correct Gate API input DTO for ultimate owner marking
mohan06-mbrd Aug 13, 2026
1ee1eee
fix: correct property names for legal entity representation
mohan06-mbrd Aug 13, 2026
32fa244
fix: use empty states collection for Gate API compatibility
mohan06-mbrd Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ package org.eclipse.tractusx.bpdm.pool.entity

enum class TriggerEventType {
ReplacedAddress,
OwnershipValidityBoundary
OwnershipValidityBoundary,
AlternativeHeadquarterValidityBoundary
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ data class StreetScriptVariant(
val additionalNamePrefix: String? = null,
val nameSuffix: String? = null,
val additionalNameSuffix: String? = null
)
) {
companion object {
val empty = StreetScriptVariant(null, null, null, null, null, null)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ class AddressRelationTriggerExecutionService(
TriggerEventType.OwnershipValidityBoundary -> {
logger.error { "Encountered OwnershipValidityBoundary trigger in address relation trigger service. This should not happen as these triggers are handled by LegalEntityRelationTriggerExecutionService. Trigger will be deactivated." }
}
TriggerEventType.AlternativeHeadquarterValidityBoundary -> {
logger.error { "Encountered AlternativeHeadquarterValidityBoundary trigger in address relation trigger service. This should not happen as these triggers are handled by LegalEntityRelationTriggerExecutionService. Trigger will be deactivated." }
}
}

nextUnprocessedTrigger.isProcessed = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,23 @@ package org.eclipse.tractusx.bpdm.pool.service
import org.eclipse.tractusx.bpdm.pool.api.model.LegalEntityRelationType
import org.eclipse.tractusx.bpdm.pool.dto.UpsertResult
import org.eclipse.tractusx.bpdm.pool.entity.LegalEntityDb
import org.eclipse.tractusx.bpdm.pool.entity.LegalEntityRelationEventTriggerDb
import org.eclipse.tractusx.bpdm.pool.entity.RelationDb
import org.eclipse.tractusx.bpdm.pool.entity.TriggerEventType
import org.eclipse.tractusx.bpdm.pool.exception.BpdmValidationException
import org.eclipse.tractusx.bpdm.pool.repository.LegalEntityRelationEventTriggerRepository
import org.eclipse.tractusx.bpdm.pool.repository.RelationRepository
import org.eclipse.tractusx.bpdm.pool.service.operation.UltimateOwnerRecalculationService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate

@Service
class AlternativeHeadquarterRelationUpsertService(
private val relationUpsertService: RelationUpsertService,
private val relationRepository: RelationRepository
private val relationRepository: RelationRepository,
private val ultimateOwnerRecalculationService: UltimateOwnerRecalculationService,
private val legalEntityRelationEventTriggerRepository: LegalEntityRelationEventTriggerRepository
): IRelationUpsertStrategyService {

@Transactional
Expand All @@ -54,9 +61,42 @@ class AlternativeHeadquarterRelationUpsertService(
)
)

ultimateOwnerRecalculationService.recalculate(listOf(alternative))
handleValidityBoundaryTriggers(result.value)

return result
}

private fun handleValidityBoundaryTriggers(relation: RelationDb) {
val today = LocalDate.now()

val validFromDates = relation.validityPeriods
.map { it.validFrom }
.filter { it > today }

val expiryDates = relation.validityPeriods
.mapNotNull { it.validTo }
.map { it.plusDays(1) }
.filter { it > today }

val desiredTriggerDates = (validFromDates + expiryDates).toSet()

val existingUnprocessedTriggers = legalEntityRelationEventTriggerRepository
.findByRelationAndEventType(relation, TriggerEventType.AlternativeHeadquarterValidityBoundary)
.filterNot { it.isProcessed }
val existingTriggerDates = existingUnprocessedTriggers.map { it.triggerDate }.toSet()

val triggersToDelete = existingUnprocessedTriggers.filterNot { it.triggerDate in desiredTriggerDates }
val triggerDatesToCreate = desiredTriggerDates - existingTriggerDates

legalEntityRelationEventTriggerRepository.deleteAll(triggersToDelete)
legalEntityRelationEventTriggerRepository.saveAll(
triggerDatesToCreate.map {
LegalEntityRelationEventTriggerDb(it, false, TriggerEventType.AlternativeHeadquarterValidityBoundary, relation)
}
)
}


private fun validateStarTopology(alternative: LegalEntityDb, main: LegalEntityDb, upsertRequest: IRelationUpsertStrategyService.UpsertRequest) {
val allAltHqRelations = relationRepository.findInSourceOrTarget(LegalEntityRelationType.IsAlternativeHeadquarterFor, alternative)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

package org.eclipse.tractusx.bpdm.pool.service

import mu.KotlinLogging
import org.eclipse.tractusx.bpdm.pool.entity.TriggerEventType
import org.eclipse.tractusx.bpdm.pool.repository.LegalEntityRelationEventTriggerRepository
import org.eclipse.tractusx.bpdm.pool.service.operation.UltimateOwnerRecalculationService
import org.springframework.stereotype.Service
Expand All @@ -30,12 +32,21 @@ class LegalEntityRelationTriggerExecutionService(
private val ultimateOwnerRecalculationService: UltimateOwnerRecalculationService,
private val legalEntityRelationEventTriggerRepository: LegalEntityRelationEventTriggerRepository
) : IsBatchProcessService {
private val logger = KotlinLogging.logger { }

@Transactional
override fun executeNextBatch(): Boolean {
val nextUnprocessedTrigger = legalEntityRelationEventTriggerRepository.findNextUnprocessed(LocalDate.now())
?: return false

ultimateOwnerRecalculationService.recalculate(listOf(nextUnprocessedTrigger.relation.startNode))
when (nextUnprocessedTrigger.eventType) {
TriggerEventType.OwnershipValidityBoundary,
TriggerEventType.AlternativeHeadquarterValidityBoundary ->
ultimateOwnerRecalculationService.recalculate(listOf(nextUnprocessedTrigger.relation.startNode))

TriggerEventType.ReplacedAddress ->
logger.error { "Encountered ReplacedAddress trigger in legal entity relation trigger service. This should not happen. Trigger will be deactivated." }
}

nextUnprocessedTrigger.isProcessed = true
legalEntityRelationEventTriggerRepository.save(nextUnprocessedTrigger)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ class UltimateOwnerRecalculationService(
) {

/**
* Re-derives the ultimate owner of [legalEntities] and of every entity they own, transitively — a flag or relation
* change moves the ultimate owner of the whole subtree, not just the entity written.
* Re-derives the ultimate owner of [legalEntities], of every entity they own transitively, and of the currently valid
* alternative headquarters of every visited entity. A flag or relation change moves the ultimate owner of the whole
* affected subtree, not just the entity written.
*/
@Transactional
fun recalculate(legalEntities: List<LegalEntityDb>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,22 @@ class UltimateOwnerResolutionService(
*/
@Transactional(readOnly = true)
fun resolve(legalEntity: LegalEntityDb): String? =
when (val resolution = resolveWithCycleProtection(legalEntity, mutableSetOf())) {
resolveWithAlternativeGuard(legalEntity, mutableSetOf())

private fun resolveWithAlternativeGuard(legalEntity: LegalEntityDb, visited: MutableSet<String>): String? {
if (!visited.add(legalEntity.bpn)) {
logger.warn { "Cycle detected in alternative headquarter chain at BPNL: ${legalEntity.bpn}" }
return null
}
val alternativeMain = mainOfAlternative(legalEntity)
if (alternativeMain != null) {
return resolveWithAlternativeGuard(alternativeMain, visited)
}
return when (val resolution = resolveWithCycleProtection(legalEntity, mutableSetOf())) {
is Resolution.UltimateOwner -> resolution.bpnl
Resolution.CycleDetected -> null
}
}

/**
* Resolves [legalEntities] and every entity owned by them, transitively — the set whose ultimate owner can change
Expand All @@ -79,10 +91,27 @@ class UltimateOwnerResolutionService(

resolved[legalEntity] = resolve(legalEntity)

if (mainOfAlternative(legalEntity) != null) {
return
}

currentlyValidRelations(relationRepository.findByTypeAndEndNode(LegalEntityRelationType.IsAlternativeHeadquarterFor, legalEntity))
.forEach { alternativeRelation ->
val alternative = alternativeRelation.startNode
if (visited.add(alternative.bpn)) {
resolved[alternative] = resolved[legalEntity]
}
}

currentlyValidRelations(relationRepository.findByTypeAndEndNode(LegalEntityRelationType.IsOwnedBy, legalEntity))
.forEach { collectWithDescendants(it.startNode, visited, resolved) }
}

private fun mainOfAlternative(legalEntity: LegalEntityDb): LegalEntityDb? =
currentlyValidRelations(relationRepository.findByTypeAndStartNode(LegalEntityRelationType.IsAlternativeHeadquarterFor, legalEntity))
.firstOrNull()
?.endNode

private fun resolveWithCycleProtection(legalEntity: LegalEntityDb, visited: MutableSet<String>): Resolution {
val currentBpn = legalEntity.bpn

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import java.time.LocalDate

/**
* Validates that an alternative headquarter cannot carry the ultimate-owner flag.
* An entity is considered an alternative if it is the source (startNode) in an IsAlternativeHeadquarterFor relation
* that is valid today.
*/
@Service
class AlternativeHeadquarterUltimateOwnerValidator(
class AlternativeHeadquarterValidator(
private val relationRepository: RelationRepository
) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class LegalEntityUpdateParser(
private val legalEntityHeaderParser: LegalEntityHeaderParser,
private val duplicateValidator: LegalEntityIdentifierDuplicateValidator,
private val ultimateOwnerUniquenessValidator: UltimateOwnerUniquenessValidator,
private val alternativeHeadquarterUltimateOwnerValidator: AlternativeHeadquarterUltimateOwnerValidator,
private val alternativeHeadquarterValidator: AlternativeHeadquarterValidator,
private val addressContentParser: AddressContentParser,
private val scriptVariantCoverageValidator: ScriptVariantCoverageValidator,
private val partnerReader: AddressPartnerScriptCodeReader
Expand Down Expand Up @@ -80,7 +80,7 @@ class LegalEntityUpdateParser(
// folded in at this level rather than into the header result.
val resolvedTargets = targetResults.map { (it as? ParseResult.Success)?.parsed }
val ownershipViolations = ultimateOwnerUniquenessValidator.validate(resolvedTargets, headers.map { it.ownershipUltimate })
val alternativeViolations = alternativeHeadquarterUltimateOwnerValidator.validate(resolvedTargets, headers.map { it.ownershipUltimate })
val alternativeViolations = alternativeHeadquarterValidator.validate(resolvedTargets, headers.map { it.ownershipUltimate })

return updateResults
.zip(ownershipViolations) { result, violations -> result.combine(violations) { it } }
Expand Down
Loading
Loading