TIME-domain foundation - Leitstand

This commit is contained in:
trifonovt 2026-04-21 19:01:11 +02:00
parent 8b22e96597
commit 9b0777e6ce
41 changed files with 1209 additions and 52 deletions

View File

@ -0,0 +1,23 @@
# TIME Phase T2 Leitstand Import
This phase adds the NEW-only Leitstand source import layer on top of the T1 TIME foundation.
## Included
- source-specific import tables in `TIME` schema (`ls_*`)
- NEW-only external JDBC configuration for Leitstand
- source client for the provided SQL Server 2000 tables
- startup sync runner, disabled by default
- importer that:
- upserts Leitstand source rows into `TIME.ls_*`
- creates/updates canonical `TIME.time_entry` + `DOC.doc_document` roots from `E_90716`
- writes source links for time recording, person, and activity type
- updates `TIME.time_sync_run` and `TIME.time_sync_state`
## Not included yet
- search projection
- text representations
- embeddings
- structured search
- Toggl import

23
pom.xml
View File

@ -207,6 +207,29 @@
<version>2.6.0</version> <version>2.6.0</version>
</dependency> </dependency>
<!--
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>sqljdbc4</artifactId>
<version>4.0</version>
<scope>compile</scope>
</dependency>
-->
<dependency>
<groupId>net.sourceforge.jtds</groupId>
<artifactId>jtds</artifactId>
<version>1.3.1</version>
</dependency>
<!-- Testing --> <!-- Testing -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@ -4,17 +4,13 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/**
* NEW-runtime configuration scaffold for the TIME domain.
* T1 only introduces schema and persistence foundations; source import starts in T2.
*/
@Configuration @Configuration
@ConfigurationProperties(prefix = "dip.time") @ConfigurationProperties(prefix = "dip.time")
@Data @Data
public class TimeDomainProperties { public class TimeDomainProperties {
private boolean enabled = false; private boolean enabled = false;
private SourceProperties leitstand = new SourceProperties(); private LeitstandProperties leitstand = new LeitstandProperties();
private SourceProperties togglTrack = new SourceProperties(); private SourceProperties togglTrack = new SourceProperties();
@Data @Data
@ -23,4 +19,23 @@ public class TimeDomainProperties {
private String importBatchId; private String importBatchId;
private int reconcileLookbackDays = 7; private int reconcileLookbackDays = 7;
} }
@Data
public static class LeitstandProperties extends SourceProperties {
private boolean startupSyncEnabled = false;
private boolean createCanonicalTimeEntries = true;
private boolean incrementalEnabled = true;
private String scopeKey = "leitstand-default";
private JdbcProperties jdbc = new JdbcProperties();
}
@Data
public static class JdbcProperties {
private String url;
private String username;
private String password;
private String driverClassName = "com.microsoft.sqlserver.jdbc.SQLServerDriver";
private int fetchSize = 500;
private int queryTimeoutSeconds = 300;
}
} }

View File

@ -23,6 +23,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** /**
* Shared canonical time-entry fact that can be sourced from multiple external systems. * Shared canonical time-entry fact that can be sourced from multiple external systems.
@ -51,7 +53,8 @@ public class TimeEntry {
private Document document; private Document document;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source_system", nullable = false, length = 32) @JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "source_system", columnDefinition = "TIME.time_source_system")
private TimeSourceSystem sourceSystem; private TimeSourceSystem sourceSystem;
@Column(name = "external_id", nullable = false, length = 255) @Column(name = "external_id", nullable = false, length = 255)

View File

@ -22,6 +22,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** /**
* Reverse-link helper that maps a canonical time entry to one or more source-system entity identifiers. * Reverse-link helper that maps a canonical time entry to one or more source-system entity identifiers.
@ -48,7 +50,8 @@ public class TimeEntrySourceLink {
private TimeEntry timeEntry; private TimeEntry timeEntry;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source_system", nullable = false, length = 32) @JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "source_system", columnDefinition = "TIME.time_source_system")
private TimeSourceSystem sourceSystem; private TimeSourceSystem sourceSystem;
@Column(name = "source_entity_type", nullable = false, length = 120) @Column(name = "source_entity_type", nullable = false, length = 120)

View File

@ -19,6 +19,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** /**
* Audit row for one synchronization run of a time-entry source system. * Audit row for one synchronization run of a time-entry source system.
@ -42,18 +44,21 @@ public class TimeSyncRun {
private UUID id; private UUID id;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source_system", nullable = false, length = 32) @JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "source_system", columnDefinition = "TIME.time_source_system")
private TimeSourceSystem sourceSystem; private TimeSourceSystem sourceSystem;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "run_type", nullable = false, length = 32) @JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "run_type", columnDefinition = "TIME.time_sync_run_system")
private TimeSyncRunType runType; private TimeSyncRunType runType;
@Column(name = "scope_key", nullable = false, length = 255) @Column(name = "scope_key", nullable = false, length = 255)
private String scopeKey; private String scopeKey;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32) @JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "status", columnDefinition = "TIME.time_sync_run_status")
private TimeSyncRunStatus status; private TimeSyncRunStatus status;
@Builder.Default @Builder.Default

View File

@ -22,6 +22,8 @@ import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** /**
* Mutable synchronization cursor/state per source system and scope. * Mutable synchronization cursor/state per source system and scope.
@ -43,7 +45,8 @@ public class TimeSyncState {
private UUID id; private UUID id;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source_system", nullable = false, length = 32) @JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "source_system", columnDefinition = "TIME.time_source_system")
private TimeSourceSystem sourceSystem; private TimeSourceSystem sourceSystem;
@Column(name = "scope_key", nullable = false, length = 255) @Column(name = "scope_key", nullable = false, length = 255)

View File

@ -0,0 +1,16 @@
package at.procon.dip.domain.time.entity.leitstand;
import jakarta.persistence.Column;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
@MappedSuperclass
@Getter
@Setter
public abstract class AbstractLeitstandDbkEntity extends AbstractLeitstandStampedEntity {
@Id
@Column(name = "dbk", nullable = false, length = 24)
private String dbk;
}

View File

@ -0,0 +1,24 @@
package at.procon.dip.domain.time.entity.leitstand;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.Setter;
@MappedSuperclass
@Getter
@Setter
public abstract class AbstractLeitstandStampedEntity {
@Column(name = "dbk_uid", length = 7) private String dbkUid;
@Column(name = "upd", length = 24) private String upd;
@Column(name = "upd_anz") private Integer updAnz;
@Column(name = "upd_uid", length = 7) private String updUid;
@Column(name = "status") private Integer status;
@Column(name = "created_at", nullable = false, updatable = false) private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt = OffsetDateTime.now();
@PrePersist protected void onCreate() { createdAt = OffsetDateTime.now(); updatedAt = OffsetDateTime.now(); }
@PreUpdate protected void onUpdate() { updatedAt = OffsetDateTime.now(); }
}

View File

@ -0,0 +1,24 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_activity_type", indexes = {
@Index(name = "idx_ls_activity_type_code", columnList = "l_code")
})
@Getter
@Setter
@NoArgsConstructor
public class LeitstandActivityType {
@Id @Column(name = "id", nullable = false) private Integer id;
@Column(name = "l_code", length = 32) private String lCode;
@Column(name = "bez", length = 255) private String bez;
}

View File

@ -0,0 +1,30 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_contract", indexes = {
@Index(name = "idx_ls_contract_upd", columnList = "upd"),
@Index(name = "idx_ls_contract_org_dbk", columnList = "organization_dbk"),
@Index(name = "idx_ls_contract_name", columnList = "name"),
@Index(name = "idx_ls_contract_iref", columnList = "iref")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandContract extends AbstractLeitstandDbkEntity {
@Column(name = "organization_dbk", length = 24) private String organizationDbk;
@Column(name = "lfdnr") private Integer lfdnr;
@Column(name = "name", length = 255) private String name;
@Column(name = "iref", length = 255) private String iref;
@Column(name = "eref", length = 255) private String eref;
@Column(name = "description", length = 255) private String description;
@Column(name = "valid_from") private OffsetDateTime validFrom;
@Column(name = "valid_to") private OffsetDateTime validTo;
}

View File

@ -0,0 +1,34 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_contract_position", indexes = {
@Index(name = "idx_ls_contract_position_upd", columnList = "upd"),
@Index(name = "idx_ls_contract_position_contract_dbk", columnList = "contract_dbk"),
@Index(name = "idx_ls_contract_position_name", columnList = "name"),
@Index(name = "idx_ls_contract_position_iref", columnList = "iref")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandContractPosition extends AbstractLeitstandDbkEntity {
@Column(name = "contract_dbk", length = 24) private String contractDbk;
@Column(name = "lfdnr") private Integer lfdnr;
@Column(name = "project_name", length = 255) private String projectName;
@Column(name = "sales_price", precision = 18, scale = 4) private BigDecimal salesPrice;
@Column(name = "purchase_price", precision = 18, scale = 4) private BigDecimal purchasePrice;
@Column(name = "description", length = 255) private String description;
@Column(name = "valid_from") private OffsetDateTime validFrom;
@Column(name = "valid_to") private OffsetDateTime validTo;
@Column(name = "name", length = 255) private String name;
@Column(name = "iref", length = 255) private String iref;
@Column(name = "eref", length = 255) private String eref;
}

View File

@ -0,0 +1,40 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_cost_unit", indexes = {
@Index(name = "idx_ls_cost_unit_upd", columnList = "upd"),
@Index(name = "idx_ls_cost_unit_mcl_id", columnList = "mcl_id"),
@Index(name = "idx_ls_cost_unit_person_dbk", columnList = "person_dbk"),
@Index(name = "idx_ls_cost_unit_org_dbk", columnList = "organization_dbk"),
@Index(name = "idx_ls_cost_unit_contract_dbk", columnList = "contract_dbk"),
@Index(name = "idx_ls_cost_unit_contract_position_dbk", columnList = "contract_position_dbk")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandCostUnit extends AbstractLeitstandDbkEntity {
@Column(name = "person_dbk", length = 24) private String personDbk;
@Column(name = "organization_dbk", length = 24) private String organizationDbk;
@Column(name = "contract_dbk", length = 24) private String contractDbk;
@Column(name = "legacy_relation_4_dbk", length = 24) private String legacyRelation4Dbk;
@Column(name = "legacy_relation_5_dbk", length = 24) private String legacyRelation5Dbk;
@Column(name = "contract_position_dbk", length = 24) private String contractPositionDbk;
@Column(name = "project_name", length = 255) private String projectName;
@Column(name = "project_id") private Integer projectId;
@Column(name = "project_task") private Integer projectTask;
@Column(name = "mcl_id", length = 255) private String mclId;
@Column(name = "mcl_name", length = 255) private String mclName;
@Column(name = "mcl_desc", length = 255) private String mclDesc;
@Column(name = "valid_from") private OffsetDateTime validFrom;
@Column(name = "valid_to") private OffsetDateTime validTo;
@Column(name = "effort_plan", precision = 19, scale = 3) private BigDecimal effortPlan;
}

View File

@ -0,0 +1,30 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_organization", indexes = {
@Index(name = "idx_ls_organization_upd", columnList = "upd"),
@Index(name = "idx_ls_organization_name", columnList = "name"),
@Index(name = "idx_ls_organization_org_nr", columnList = "org_nr")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandOrganization extends AbstractLeitstandDbkEntity {
@Column(name = "betreu", length = 2) private String betreu;
@Column(name = "name", length = 255) private String name;
@Column(name = "street", length = 50) private String street;
@Column(name = "postal_code", length = 6) private String postalCode;
@Column(name = "city", length = 50) private String city;
@Column(name = "phone", length = 30) private String phone;
@Column(name = "fax", length = 30) private String fax;
@Column(name = "email", length = 50) private String email;
@Column(name = "org_nr") private Integer orgNumber;
@Column(name = "short_name", length = 30) private String shortName;
}

View File

@ -0,0 +1,36 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_person", indexes = {
@Index(name = "idx_ls_person_upd", columnList = "upd"),
@Index(name = "idx_ls_person_person_nr", columnList = "pers_nr"),
@Index(name = "idx_ls_person_org_ref", columnList = "organization_dbk")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandPerson extends AbstractLeitstandDbkEntity {
@Column(name = "pers_nr") private Integer personNumber;
@Column(name = "last_name", length = 35) private String lastName;
@Column(name = "first_name", length = 25) private String firstName;
@Column(name = "category_dbk", length = 24) private String categoryDbk;
@Column(name = "salutation", length = 15) private String salutation;
@Column(name = "title", length = 30) private String title;
@Column(name = "street", length = 36) private String street;
@Column(name = "postal_code", length = 6) private String postalCode;
@Column(name = "city", length = 30) private String city;
@Column(name = "country_code", length = 4) private String countryCode;
@Column(name = "phone", length = 30) private String phone;
@Column(name = "fax", length = 30) private String fax;
@Column(name = "email", length = 50) private String email;
@Column(name = "cost_per_hour", precision = 8, scale = 3) private BigDecimal costPerHour;
@Column(name = "organization_dbk", length = 24) private String organizationDbk;
}

View File

@ -0,0 +1,29 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_person_task_assignment", indexes = {
@Index(name = "idx_ls_person_task_assignment_upd", columnList = "upd"),
@Index(name = "idx_ls_person_task_assignment_task_dbk", columnList = "task_dbk"),
@Index(name = "idx_ls_person_task_assignment_person_dbk", columnList = "person_dbk"),
@Index(name = "idx_ls_person_task_assignment_cost_unit_dbk", columnList = "cost_unit_dbk")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandPersonTaskAssignment extends AbstractLeitstandDbkEntity {
@Column(name = "task_dbk", length = 24) private String taskDbk;
@Column(name = "person_dbk", length = 24) private String personDbk;
@Column(name = "cost_unit_dbk", length = 24) private String costUnitDbk;
@Column(name = "rtype", length = 1) private String rtype;
@Column(name = "last_work") private OffsetDateTime lastWork;
@Column(name = "effort_plan", precision = 19, scale = 3) private BigDecimal effortPlan;
}

View File

@ -0,0 +1,46 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_task", indexes = {
@Index(name = "idx_ls_task_upd", columnList = "upd"),
@Index(name = "idx_ls_task_mcl_id", columnList = "mcl_id"),
@Index(name = "idx_ls_task_primary_cost_unit_dbk", columnList = "primary_cost_unit_dbk"),
@Index(name = "idx_ls_task_parent_task_dbk", columnList = "parent_task_dbk")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandTask extends AbstractLeitstandDbkEntity {
@Column(name = "primary_cost_unit_dbk", length = 24) private String primaryCostUnitDbk;
@Column(name = "secondary_cost_unit_dbk", length = 24) private String secondaryCostUnitDbk;
@Column(name = "tertiary_cost_unit_dbk", length = 24) private String tertiaryCostUnitDbk;
@Column(name = "parent_task_dbk", length = 24) private String parentTaskDbk;
@Column(name = "lfdnr") private Integer lfdnr;
@Column(name = "task_type", length = 1) private String taskType;
@Column(name = "mcl_id", length = 255) private String mclId;
@Column(name = "mcl_name", length = 255) private String mclName;
@Column(name = "mcl_desc", length = 8000) private String mclDesc;
@Column(name = "valid_from") private OffsetDateTime validFrom;
@Column(name = "valid_to") private OffsetDateTime validTo;
@Column(name = "effort_plan", precision = 19, scale = 1) private BigDecimal effortPlan;
@Column(name = "last_work") private OffsetDateTime lastWork;
@Column(name = "completion_percent") private Integer completionPercent;
@Column(name = "done_date") private OffsetDateTime doneDate;
@Column(name = "remark", length = 255) private String remark;
@Column(name = "n_rid") private Integer nRid;
@Column(name = "legacy_90702_rid") private Integer legacy90702Rid;
@Column(name = "amount", precision = 18, scale = 4) private BigDecimal amount;
@Column(name = "created_source_at") private OffsetDateTime createdSourceAt;
@Column(name = "legacy_90700_rid") private Integer legacy90700Rid;
@Column(name = "legacy_90699_rid") private Integer legacy90699Rid;
@Column(name = "quantity") private Integer quantity;
}

View File

@ -0,0 +1,40 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import at.procon.dip.domain.time.entity.TimeEntry;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_time_recording", indexes = {
@Index(name = "idx_ls_time_recording_upd", columnList = "upd"),
@Index(name = "idx_ls_time_recording_person_dbk", columnList = "person_dbk"),
@Index(name = "idx_ls_time_recording_activity_type_id", columnList = "activity_type_id"),
@Index(name = "idx_ls_time_recording_from", columnList = "recorded_from"),
@Index(name = "idx_ls_time_recording_to", columnList = "recorded_to")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandTimeRecording extends AbstractLeitstandDbkEntity {
@OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "time_entry_id", unique = true) private TimeEntry timeEntry;
@Column(name = "person_dbk", length = 24) private String personDbk;
@Column(name = "lfdnr") private Integer lfdnr;
@Column(name = "record_type", length = 32) private String recordType;
@Column(name = "mcl_id", columnDefinition = "TEXT") private String mclId;
@Column(name = "mcl_desc", columnDefinition = "TEXT") private String mclDesc;
@Column(name = "recorded_from") private OffsetDateTime recordedFrom;
@Column(name = "recorded_to") private OffsetDateTime recordedTo;
@Column(name = "effort", precision = 10, scale = 3) private BigDecimal effort;
@Column(name = "remark", columnDefinition = "TEXT") private String remark;
@Column(name = "url", length = 1000) private String url;
@Column(name = "activity_type_id") private Integer activityTypeId;
}

View File

@ -0,0 +1,26 @@
package at.procon.dip.domain.time.entity.leitstand;
import at.procon.dip.architecture.SchemaNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(schema = SchemaNames.TIME, name = "ls_time_recording_assignment", indexes = {
@Index(name = "idx_ls_time_recording_assignment_upd", columnList = "upd"),
@Index(name = "idx_ls_time_recording_assignment_person_task_assignment_dbk", columnList = "person_task_assignment_dbk"),
@Index(name = "idx_ls_time_recording_assignment_time_recording_dbk", columnList = "time_recording_dbk")
})
@Getter @Setter @NoArgsConstructor
public class LeitstandTimeRecordingAssignment extends AbstractLeitstandDbkEntity {
@Column(name = "person_task_assignment_dbk", length = 24) private String personTaskAssignmentDbk;
@Column(name = "time_recording_dbk", length = 24) private String timeRecordingDbk;
@Column(name = "mcl_desc", columnDefinition = "TEXT") private String mclDesc;
@Column(name = "effort", precision = 19, scale = 3) private BigDecimal effort;
@Column(name = "remark", columnDefinition = "TEXT") private String remark;
}

View File

@ -0,0 +1,43 @@
package at.procon.dip.domain.time.leitstand.config;
import at.procon.dip.domain.time.config.TimeDomainProperties;
import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode;
import at.procon.dip.runtime.config.RuntimeMode;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
@Configuration
@ConditionalOnRuntimeMode(RuntimeMode.NEW)
@ConditionalOnProperty(prefix = "dip.time.leitstand", name = "enabled", havingValue = "true")
@RequiredArgsConstructor
public class LeitstandTimeImportConfiguration {
private final TimeDomainProperties properties;
private DataSource createLeitstandDataSource() {
var jdbc = properties.getLeitstand().getJdbc();
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setDriverClassName(jdbc.getDriverClassName());
ds.setUrl(jdbc.getUrl());
ds.setUsername(jdbc.getUsername());
ds.setPassword(jdbc.getPassword());
return ds;
}
@Bean(name = "applicationJdbcTemplate")
public JdbcTemplate applicationJdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean(name = "leitstandTimeNamedParameterJdbcTemplate")
public NamedParameterJdbcTemplate leitstandNamedParameterJdbcTemplate() {
return new NamedParameterJdbcTemplate(createLeitstandDataSource());
}
}

View File

@ -0,0 +1,106 @@
package at.procon.dip.domain.time.leitstand.source;
import at.procon.dip.domain.time.config.TimeDomainProperties;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class JdbcLeitstandTimeSourceClient implements LeitstandTimeSourceClient {
private final TimeDomainProperties properties;
private final NamedParameterJdbcTemplate jdbcTemplate;
public JdbcLeitstandTimeSourceClient(TimeDomainProperties properties,
@Qualifier("leitstandTimeNamedParameterJdbcTemplate") NamedParameterJdbcTemplate jdbcTemplate) {
this.properties = properties;
this.jdbcTemplate = jdbcTemplate;
}
@Override
public List<LeitstandSourceRows.ActivityTypeRow> fetchActivityTypes() {
return jdbcTemplate.query("select ID, L_CODE, BEZ from E_90735 order by ID", Map.of(),
(rs, n) -> new LeitstandSourceRows.ActivityTypeRow(intVal(rs, "ID"), trim(rs.getString("L_CODE")), trim(rs.getString("BEZ"))));
}
@Override
public List<LeitstandSourceRows.PersonRow> fetchPersons(String watermark) {
return queryByWatermark("E_10009", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, PERS_NR, NAME, VORNAME, REKEY01, ANREDE, TITEL, STRASSE, PLZ, ORT, LANDCODE, TEL_NR, FAX_NR, E_MAIL, MCL_COSTPH, N_REKEY02 from E_10009", watermark,
(rs, n) -> new LeitstandSourceRows.PersonRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), intVal(rs, "PERS_NR"), trim(rs.getString("NAME")), trim(rs.getString("VORNAME")), trim(rs.getString("REKEY01")), trim(rs.getString("ANREDE")), trim(rs.getString("TITEL")), trim(rs.getString("STRASSE")), trim(rs.getString("PLZ")), trim(rs.getString("ORT")), trim(rs.getString("LANDCODE")), trim(rs.getString("TEL_NR")), trim(rs.getString("FAX_NR")), trim(rs.getString("E_MAIL")), dec(rs, "MCL_COSTPH"), trim(rs.getString("N_REKEY02"))));
}
@Override
public List<LeitstandSourceRows.OrganizationRow> fetchOrganizations(String watermark) {
return queryByWatermark("E_10072", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, BETREU, NAME, STRASSE, PLZ, ORT, TEL_NR, FAX_NR, E_MAIL, ORG_NR, org_kurz from E_10072", watermark,
(rs, n) -> new LeitstandSourceRows.OrganizationRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("BETREU")), trim(rs.getString("NAME")), trim(rs.getString("STRASSE")), trim(rs.getString("PLZ")), trim(rs.getString("ORT")), trim(rs.getString("TEL_NR")), trim(rs.getString("FAX_NR")), trim(rs.getString("E_MAIL")), intVal(rs, "ORG_NR"), trim(rs.getString("org_kurz"))));
}
@Override
public List<LeitstandSourceRows.ContractRow> fetchContracts(String watermark) {
return queryByWatermark("E_90711", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, REKEY01, LFDNR, NAME, IREF, EREF, MCL_DESC, MCL_FROM, MCL_TO from E_90711", watermark,
(rs, n) -> new LeitstandSourceRows.ContractRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("REKEY01")), intVal(rs, "LFDNR"), trim(rs.getString("NAME")), trim(rs.getString("IREF")), trim(rs.getString("EREF")), trim(rs.getString("MCL_DESC")), ts(rs, "MCL_FROM"), ts(rs, "MCL_TO")));
}
@Override
public List<LeitstandSourceRows.ContractPositionRow> fetchContractPositions(String watermark) {
return queryByWatermark("E_90712", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, REKEY01, LFDNR, PROJECTNAME, VK_PREIS, EK_PREIS, MCL_DESC, MCL_FROM, MCL_TO, NAME, IREF, EREF from E_90712", watermark,
(rs, n) -> new LeitstandSourceRows.ContractPositionRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("REKEY01")), intVal(rs, "LFDNR"), trim(rs.getString("PROJECTNAME")), dec(rs, "VK_PREIS"), dec(rs, "EK_PREIS"), trim(rs.getString("MCL_DESC")), ts(rs, "MCL_FROM"), ts(rs, "MCL_TO"), trim(rs.getString("NAME")), trim(rs.getString("IREF")), trim(rs.getString("EREF"))));
}
@Override
public List<LeitstandSourceRows.CostUnitRow> fetchCostUnits(String watermark) {
return queryByWatermark("E_90705", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, N_REKEY01, N_REKEY02, N_REKEY03, N_REKEY04, N_REKEY05, N_REKEY06, PROJECT_NAME, PROJECT_ID, PROJECT_TASK, MCL_ID, MCL_NAME, MCL_DESC, MCL_FROM, MCL_TO, MCL_EFFORT_PLAN from E_90705", watermark,
(rs, n) -> new LeitstandSourceRows.CostUnitRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("N_REKEY01")), trim(rs.getString("N_REKEY02")), trim(rs.getString("N_REKEY03")), trim(rs.getString("N_REKEY04")), trim(rs.getString("N_REKEY05")), trim(rs.getString("N_REKEY06")), trim(rs.getString("PROJECT_NAME")), intVal(rs, "PROJECT_ID"), intVal(rs, "PROJECT_TASK"), trim(rs.getString("MCL_ID")), trim(rs.getString("MCL_NAME")), trim(rs.getString("MCL_DESC")), ts(rs, "MCL_FROM"), ts(rs, "MCL_TO"), dec(rs, "MCL_EFFORT_PLAN")));
}
@Override
public List<LeitstandSourceRows.TaskRow> fetchTasks(String watermark) {
return queryByWatermark("E_90708", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, REKEY01, REKEY02, REKEY03, N_REKEY04, LFDNR, TASK_TYPE, MCL_ID, MCL_NAME, MCL_DESC, MCL_FROM, MCL_TO, MCL_EFFORT_PLAN, MCL_LASTWORK, MCL_COMPLETE, MCL_DONE_DATE, MCL_REMARK, N_RID, E_90702_RID, BETRAG, CR_DATE, E_90700_RID, E_90699_RID, ANZAHL from E_90708", watermark,
(rs, n) -> new LeitstandSourceRows.TaskRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("REKEY01")), trim(rs.getString("REKEY02")), trim(rs.getString("REKEY03")), trim(rs.getString("N_REKEY04")), intVal(rs, "LFDNR"), trim(rs.getString("TASK_TYPE")), trim(rs.getString("MCL_ID")), trim(rs.getString("MCL_NAME")), trim(rs.getString("MCL_DESC")), ts(rs, "MCL_FROM"), ts(rs, "MCL_TO"), dec(rs, "MCL_EFFORT_PLAN"), ts(rs, "MCL_LASTWORK"), intVal(rs, "MCL_COMPLETE"), ts(rs, "MCL_DONE_DATE"), trim(rs.getString("MCL_REMARK")), intVal(rs, "N_RID"), intVal(rs, "E_90702_RID"), dec(rs, "BETRAG"), ts(rs, "CR_DATE"), intVal(rs, "E_90700_RID"), intVal(rs, "E_90699_RID"), intVal(rs, "ANZAHL")));
}
@Override
public List<LeitstandSourceRows.PersonTaskAssignmentRow> fetchPersonTaskAssignments(String watermark) {
return queryByWatermark("E_90709", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, REKEY01, REKEY02, REKEY03, RTYPE, MCL_LASTWORK, MCL_EFFORT_PLAN from E_90709", watermark,
(rs, n) -> new LeitstandSourceRows.PersonTaskAssignmentRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("REKEY01")), trim(rs.getString("REKEY02")), trim(rs.getString("REKEY03")), trim(rs.getString("RTYPE")), ts(rs, "MCL_LASTWORK"), dec(rs, "MCL_EFFORT_PLAN")));
}
@Override
public List<LeitstandSourceRows.TimeRecordingRow> fetchTimeRecordings(String watermark) {
return queryByWatermark("E_90716", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, REKEY01, LFDNR, RECORD_TYPE, MCL_ID, MCL_DESC, MCL_FROM, MCL_TO, MCL_EFFORT, MCL_REMARK, MCL_URL, RID1 from E_90716", watermark,
(rs, n) -> new LeitstandSourceRows.TimeRecordingRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("REKEY01")), intVal(rs, "LFDNR"), trim(rs.getString("RECORD_TYPE")), trim(rs.getString("MCL_ID")), trim(rs.getString("MCL_DESC")), ts(rs, "MCL_FROM"), ts(rs, "MCL_TO"), dec(rs, "MCL_EFFORT"), trim(rs.getString("MCL_REMARK")), trim(rs.getString("MCL_URL")), intVal(rs, "RID1")));
}
@Override
public List<LeitstandSourceRows.TimeRecordingAssignmentRow> fetchTimeRecordingAssignments(String watermark) {
return queryByWatermark("E_90710", "select DBK, DBK_UID, UPD, UPD_ANZ, UPD_UID, STATUS, REKEY01, REKEY02, MCL_DESC, MCL_EFFORT, MCL_REMARK from E_90710", watermark,
(rs, n) -> new LeitstandSourceRows.TimeRecordingAssignmentRow(trim(rs.getString("DBK")), trim(rs.getString("DBK_UID")), trim(rs.getString("UPD")), intVal(rs, "UPD_ANZ"), trim(rs.getString("UPD_UID")), intVal(rs, "STATUS"), trim(rs.getString("REKEY01")), trim(rs.getString("REKEY02")), trim(rs.getString("MCL_DESC")), dec(rs, "MCL_EFFORT"), trim(rs.getString("MCL_REMARK"))));
}
private <T> List<T> queryByWatermark(String table, String baseSelect, String watermark, RowMapper<T> mapper) {
StringBuilder sql = new StringBuilder(baseSelect);
Map<String, ?> params = Map.of();
if (watermark != null && !watermark.isBlank() && properties.getLeitstand().isIncrementalEnabled()) {
sql.append(" where UPD > :watermark");
params = Map.of("watermark", watermark);
}
sql.append(" order by ");
if (!"E_90735".equals(table)) sql.append("UPD asc, ");
sql.append("DBK asc");
return jdbcTemplate.query(sql.toString(), params, mapper);
}
private Integer intVal(ResultSet rs, String name) throws SQLException { Object v = rs.getObject(name); return v == null ? null : ((Number) v).intValue(); }
private BigDecimal dec(ResultSet rs, String name) throws SQLException { Object v = rs.getObject(name); if (v == null) return null; return v instanceof BigDecimal bd ? bd : new BigDecimal(v.toString()); }
private OffsetDateTime ts(ResultSet rs, String name) throws SQLException { Timestamp ts = rs.getTimestamp(name); return ts == null ? null : ts.toInstant().atZone(ZoneId.systemDefault()).toOffsetDateTime(); }
private String trim(String value) { return value == null ? null : value.trim(); }
}

View File

@ -0,0 +1,47 @@
package at.procon.dip.domain.time.leitstand.source;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public final class LeitstandSourceRows {
private LeitstandSourceRows() {}
public record ActivityTypeRow(Integer id, String lCode, String bez) {}
public record PersonRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
Integer personNumber, String lastName, String firstName, String categoryDbk,
String salutation, String title, String street, String postalCode, String city,
String countryCode, String phone, String fax, String email, BigDecimal costPerHour,
String organizationDbk) {}
public record OrganizationRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
String betreu, String name, String street, String postalCode, String city,
String phone, String fax, String email, Integer orgNumber, String shortName) {}
public record ContractRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
String organizationDbk, Integer lfdnr, String name, String iref, String eref,
String description, OffsetDateTime validFrom, OffsetDateTime validTo) {}
public record ContractPositionRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
String contractDbk, Integer lfdnr, String projectName, BigDecimal salesPrice,
BigDecimal purchasePrice, String description, OffsetDateTime validFrom,
OffsetDateTime validTo, String name, String iref, String eref) {}
public record CostUnitRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
String personDbk, String organizationDbk, String contractDbk, String legacyRelation4Dbk,
String legacyRelation5Dbk, String contractPositionDbk, String projectName,
Integer projectId, Integer projectTask, String mclId, String mclName, String mclDesc,
OffsetDateTime validFrom, OffsetDateTime validTo, BigDecimal effortPlan) {}
public record TaskRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
String primaryCostUnitDbk, String secondaryCostUnitDbk, String tertiaryCostUnitDbk,
String parentTaskDbk, Integer lfdnr, String taskType, String mclId, String mclName,
String mclDesc, OffsetDateTime validFrom, OffsetDateTime validTo, BigDecimal effortPlan,
OffsetDateTime lastWork, Integer completionPercent, OffsetDateTime doneDate, String remark,
Integer nRid, Integer legacy90702Rid, BigDecimal amount, OffsetDateTime createdSourceAt,
Integer legacy90700Rid, Integer legacy90699Rid, Integer quantity) {}
public record PersonTaskAssignmentRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid,
Integer status, String taskDbk, String personDbk, String costUnitDbk,
String rtype, OffsetDateTime lastWork, BigDecimal effortPlan) {}
public record TimeRecordingRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid, Integer status,
String personDbk, Integer lfdnr, String recordType, String mclId, String mclDesc,
OffsetDateTime recordedFrom, OffsetDateTime recordedTo, BigDecimal effort,
String remark, String url, Integer activityTypeId) {}
public record TimeRecordingAssignmentRow(String dbk, String dbkUid, String upd, Integer updAnz, String updUid,
Integer status, String personTaskAssignmentDbk, String timeRecordingDbk,
String mclDesc, BigDecimal effort, String remark) {}
}

View File

@ -0,0 +1,16 @@
package at.procon.dip.domain.time.leitstand.source;
import java.util.List;
public interface LeitstandTimeSourceClient {
List<LeitstandSourceRows.ActivityTypeRow> fetchActivityTypes();
List<LeitstandSourceRows.PersonRow> fetchPersons(String watermark);
List<LeitstandSourceRows.OrganizationRow> fetchOrganizations(String watermark);
List<LeitstandSourceRows.ContractRow> fetchContracts(String watermark);
List<LeitstandSourceRows.ContractPositionRow> fetchContractPositions(String watermark);
List<LeitstandSourceRows.CostUnitRow> fetchCostUnits(String watermark);
List<LeitstandSourceRows.TaskRow> fetchTasks(String watermark);
List<LeitstandSourceRows.PersonTaskAssignmentRow> fetchPersonTaskAssignments(String watermark);
List<LeitstandSourceRows.TimeRecordingRow> fetchTimeRecordings(String watermark);
List<LeitstandSourceRows.TimeRecordingAssignmentRow> fetchTimeRecordingAssignments(String watermark);
}

View File

@ -11,6 +11,6 @@ public interface TimeEntrySourceLinkRepository extends JpaRepository<TimeEntrySo
List<TimeEntrySourceLink> findByTimeEntry_Id(UUID timeEntryId); List<TimeEntrySourceLink> findByTimeEntry_Id(UUID timeEntryId);
Optional<TimeEntrySourceLink> findBySourceSystemAndSourceEntityTypeAndSourceExternalId( Optional<TimeEntrySourceLink> findByTimeEntry_IdAndSourceSystemAndSourceEntityTypeAndSourceExternalId(
TimeSourceSystem sourceSystem, String sourceEntityType, String sourceExternalId); UUID timeEntryId, TimeSourceSystem sourceSystem, String sourceEntityType, String sourceExternalId);
} }

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandActivityType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandActivityTypeRepository extends JpaRepository<LeitstandActivityType, Integer> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandContractPosition;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandContractPositionRepository extends JpaRepository<LeitstandContractPosition, String> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandContract;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandContractRepository extends JpaRepository<LeitstandContract, String> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandCostUnit;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandCostUnitRepository extends JpaRepository<LeitstandCostUnit, String> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandOrganization;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandOrganizationRepository extends JpaRepository<LeitstandOrganization, String> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandPerson;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandPersonRepository extends JpaRepository<LeitstandPerson, String> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandPersonTaskAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandPersonTaskAssignmentRepository extends JpaRepository<LeitstandPersonTaskAssignment, String> {}

View File

@ -0,0 +1,6 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandTask;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandTaskRepository extends JpaRepository<LeitstandTask, String> {}

View File

@ -0,0 +1,9 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandTimeRecordingAssignment;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandTimeRecordingAssignmentRepository extends JpaRepository<LeitstandTimeRecordingAssignment, String> {
List<LeitstandTimeRecordingAssignment> findByTimeRecordingDbk(String timeRecordingDbk);
}

View File

@ -0,0 +1,10 @@
package at.procon.dip.domain.time.repository.leitstand;
import at.procon.dip.domain.time.entity.leitstand.LeitstandTimeRecording;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeitstandTimeRecordingRepository extends JpaRepository<LeitstandTimeRecording, String> {
Optional<LeitstandTimeRecording> findByTimeEntry_Id(UUID timeEntryId);
}

View File

@ -0,0 +1,269 @@
package at.procon.dip.domain.time.service;
import at.procon.dip.domain.access.DocumentVisibility;
import at.procon.dip.domain.document.DocumentFamily;
import at.procon.dip.domain.document.DocumentStatus;
import at.procon.dip.domain.document.DocumentType;
import at.procon.dip.domain.document.entity.Document;
import at.procon.dip.domain.document.repository.DocumentRepository;
import at.procon.dip.domain.time.config.TimeDomainProperties;
import at.procon.dip.domain.time.entity.*;
import at.procon.dip.domain.time.leitstand.source.LeitstandSourceRows;
import at.procon.dip.domain.time.leitstand.source.LeitstandTimeSourceClient;
import at.procon.dip.domain.time.repository.TimeEntryRepository;
import at.procon.dip.domain.time.repository.TimeEntrySourceLinkRepository;
import at.procon.dip.domain.time.repository.TimeSyncRunRepository;
import at.procon.dip.domain.time.repository.TimeSyncStateRepository;
import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode;
import at.procon.dip.runtime.config.RuntimeMode;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@ConditionalOnRuntimeMode(RuntimeMode.NEW)
@ConditionalOnProperty(prefix = "dip.time.leitstand", name = "enabled", havingValue = "true")
@Slf4j
public class LeitstandTimeImportService {
private static final String GLOBAL_SCOPE = "leitstand:all";
private final TimeDomainProperties properties;
private final LeitstandTimeSourceClient sourceClient;
private final JdbcTemplate jdbcTemplate;
private final TimeSyncRunRepository syncRunRepository;
private final TimeSyncStateRepository syncStateRepository;
private final DocumentRepository documentRepository;
private final TimeEntryRepository timeEntryRepository;
private final TimeEntrySourceLinkRepository sourceLinkRepository;
public LeitstandTimeImportService(
@Qualifier("applicationJdbcTemplate") JdbcTemplate targetJdbcTemplate,
TimeDomainProperties properties,
LeitstandTimeSourceClient sourceClient,
TimeSyncRunRepository syncRunRepository,
TimeSyncStateRepository syncStateRepository,
DocumentRepository documentRepository,
TimeEntryRepository timeEntryRepository,
TimeEntrySourceLinkRepository sourceLinkRepository
) {
this. properties = properties;
this.jdbcTemplate = targetJdbcTemplate;
this.sourceClient = sourceClient;
this.syncRunRepository = syncRunRepository;
this.syncStateRepository = syncStateRepository;
this.documentRepository = documentRepository;
this.timeEntryRepository = timeEntryRepository;
this.sourceLinkRepository = sourceLinkRepository;
}
public void runSync() {
TimeSyncRun run = syncRunRepository.save(TimeSyncRun.builder()
.sourceSystem(TimeSourceSystem.LEITSTAND)
.runType(syncStateRepository.findBySourceSystemAndScopeKey(TimeSourceSystem.LEITSTAND, GLOBAL_SCOPE).isPresent() ? TimeSyncRunType.INCREMENTAL : TimeSyncRunType.INITIAL)
.scopeKey(properties.getLeitstand().getScopeKey())
.status(TimeSyncRunStatus.RUNNING)
.initiatedBy("startup-runner")
.build());
try {
syncActivityTypes(run);
syncPersons(run);
syncOrganizations(run);
syncContracts(run);
syncContractPositions(run);
syncCostUnits(run);
syncTasks(run);
syncPersonTaskAssignments(run);
List<LeitstandSourceRows.TimeRecordingRow> recordings = syncTimeRecordings(run);
syncTimeRecordingAssignments(run);
if (properties.getLeitstand().isCreateCanonicalTimeEntries()) {
upsertCanonicalTimeEntries(recordings);
}
run.setStatus(TimeSyncRunStatus.COMPLETED);
run.setFinishedAt(OffsetDateTime.now());
syncRunRepository.save(run);
updateGlobalState(run, null);
} catch (Exception ex) {
run.setStatus(TimeSyncRunStatus.FAILED);
run.setFinishedAt(OffsetDateTime.now());
run.setErrorMessage(ex.getMessage());
syncRunRepository.save(run);
updateGlobalState(run, ex.getMessage());
throw ex;
}
}
private void syncActivityTypes(TimeSyncRun run) {
List<LeitstandSourceRows.ActivityTypeRow> rows = sourceClient.fetchActivityTypes();
run.setRowsRead(run.getRowsRead() + rows.size());
jdbcTemplate.batchUpdate("""
INSERT INTO "time".ls_activity_type(id, l_code, bez)
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET l_code = EXCLUDED.l_code, bez = EXCLUDED.bez
""", new BatchPreparedStatementSetter() {
public void setValues(PreparedStatement ps, int i) throws java.sql.SQLException {
var r = rows.get(i);
ps.setObject(1, r.id()); ps.setString(2, r.lCode()); ps.setString(3, r.bez());
}
public int getBatchSize() { return rows.size(); }
});
updateTableState("activity-type", null, rows.isEmpty() ? null : String.valueOf(rows.get(rows.size()-1).id()));
}
private void syncPersons(TimeSyncRun run) {
String watermark = watermark("person");
List<LeitstandSourceRows.PersonRow> rows = sourceClient.fetchPersons(watermark);
run.setRowsRead(run.getRowsRead() + rows.size());
batchUpsert("""
INSERT INTO "time".ls_person(dbk, dbk_uid, upd, upd_anz, upd_uid, status, pers_nr, last_name, first_name, category_dbk, salutation, title, street, postal_code, city, country_code, phone, fax, email, cost_per_hour, organization_dbk)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, pers_nr=EXCLUDED.pers_nr, last_name=EXCLUDED.last_name, first_name=EXCLUDED.first_name, category_dbk=EXCLUDED.category_dbk, salutation=EXCLUDED.salutation, title=EXCLUDED.title, street=EXCLUDED.street, postal_code=EXCLUDED.postal_code, city=EXCLUDED.city, country_code=EXCLUDED.country_code, phone=EXCLUDED.phone, fax=EXCLUDED.fax, email=EXCLUDED.email, cost_per_hour=EXCLUDED.cost_per_hour, organization_dbk=EXCLUDED.organization_dbk, updated_at=CURRENT_TIMESTAMP
""", rows, (ps, r) -> { set(ps,1,r.dbk()); set(ps,2,r.dbkUid()); set(ps,3,r.upd()); set(ps,4,r.updAnz()); set(ps,5,r.updUid()); set(ps,6,r.status()); set(ps,7,r.personNumber()); set(ps,8,r.lastName()); set(ps,9,r.firstName()); set(ps,10,r.categoryDbk()); set(ps,11,r.salutation()); set(ps,12,r.title()); set(ps,13,r.street()); set(ps,14,r.postalCode()); set(ps,15,r.city()); set(ps,16,r.countryCode()); set(ps,17,r.phone()); set(ps,18,r.fax()); set(ps,19,r.email()); set(ps,20,r.costPerHour()); set(ps,21,r.organizationDbk());});
updateTableState("person", maxUpd(rows, LeitstandSourceRows.PersonRow::upd), lastId(rows, LeitstandSourceRows.PersonRow::dbk));
}
private void syncOrganizations(TimeSyncRun run) { var rows = sourceClient.fetchOrganizations(watermark("organization")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_organization(dbk, dbk_uid, upd, upd_anz, upd_uid, status, betreu, name, street, postal_code, city, phone, fax, email, org_nr, short_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, betreu=EXCLUDED.betreu, name=EXCLUDED.name, street=EXCLUDED.street, postal_code=EXCLUDED.postal_code, city=EXCLUDED.city, phone=EXCLUDED.phone, fax=EXCLUDED.fax, email=EXCLUDED.email, org_nr=EXCLUDED.org_nr, short_name=EXCLUDED.short_name, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.betreu());set(ps,8,r.name());set(ps,9,r.street());set(ps,10,r.postalCode());set(ps,11,r.city());set(ps,12,r.phone());set(ps,13,r.fax());set(ps,14,r.email());set(ps,15,r.orgNumber());set(ps,16,r.shortName());}); updateTableState("organization", maxUpd(rows, LeitstandSourceRows.OrganizationRow::upd), lastId(rows, LeitstandSourceRows.OrganizationRow::dbk)); }
private void syncContracts(TimeSyncRun run) { var rows = sourceClient.fetchContracts(watermark("contract")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_contract(dbk, dbk_uid, upd, upd_anz, upd_uid, status, organization_dbk, lfdnr, name, iref, eref, description, valid_from, valid_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, organization_dbk=EXCLUDED.organization_dbk, lfdnr=EXCLUDED.lfdnr, name=EXCLUDED.name, iref=EXCLUDED.iref, eref=EXCLUDED.eref, description=EXCLUDED.description, valid_from=EXCLUDED.valid_from, valid_to=EXCLUDED.valid_to, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.organizationDbk());set(ps,8,r.lfdnr());set(ps,9,r.name());set(ps,10,r.iref());set(ps,11,r.eref());set(ps,12,r.description());set(ps,13,ts(r.validFrom()));set(ps,14,ts(r.validTo()));}); updateTableState("contract", maxUpd(rows, LeitstandSourceRows.ContractRow::upd), lastId(rows, LeitstandSourceRows.ContractRow::dbk)); }
private void syncContractPositions(TimeSyncRun run) { var rows = sourceClient.fetchContractPositions(watermark("contract-position")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_contract_position(dbk, dbk_uid, upd, upd_anz, upd_uid, status, contract_dbk, lfdnr, project_name, sales_price, purchase_price, description, valid_from, valid_to, name, iref, eref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, contract_dbk=EXCLUDED.contract_dbk, lfdnr=EXCLUDED.lfdnr, project_name=EXCLUDED.project_name, sales_price=EXCLUDED.sales_price, purchase_price=EXCLUDED.purchase_price, description=EXCLUDED.description, valid_from=EXCLUDED.valid_from, valid_to=EXCLUDED.valid_to, name=EXCLUDED.name, iref=EXCLUDED.iref, eref=EXCLUDED.eref, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.contractDbk());set(ps,8,r.lfdnr());set(ps,9,r.projectName());set(ps,10,r.salesPrice());set(ps,11,r.purchasePrice());set(ps,12,r.description());set(ps,13,ts(r.validFrom()));set(ps,14,ts(r.validTo()));set(ps,15,r.name());set(ps,16,r.iref());set(ps,17,r.eref());}); updateTableState("contract-position", maxUpd(rows, LeitstandSourceRows.ContractPositionRow::upd), lastId(rows, LeitstandSourceRows.ContractPositionRow::dbk)); }
private void syncCostUnits(TimeSyncRun run) { var rows = sourceClient.fetchCostUnits(watermark("cost-unit")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_cost_unit(dbk, dbk_uid, upd, upd_anz, upd_uid, status, person_dbk, organization_dbk, contract_dbk, legacy_relation_4_dbk, legacy_relation_5_dbk, contract_position_dbk, project_name, project_id, project_task, mcl_id, mcl_name, mcl_desc, valid_from, valid_to, effort_plan) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, person_dbk=EXCLUDED.person_dbk, organization_dbk=EXCLUDED.organization_dbk, contract_dbk=EXCLUDED.contract_dbk, legacy_relation_4_dbk=EXCLUDED.legacy_relation_4_dbk, legacy_relation_5_dbk=EXCLUDED.legacy_relation_5_dbk, contract_position_dbk=EXCLUDED.contract_position_dbk, project_name=EXCLUDED.project_name, project_id=EXCLUDED.project_id, project_task=EXCLUDED.project_task, mcl_id=EXCLUDED.mcl_id, mcl_name=EXCLUDED.mcl_name, mcl_desc=EXCLUDED.mcl_desc, valid_from=EXCLUDED.valid_from, valid_to=EXCLUDED.valid_to, effort_plan=EXCLUDED.effort_plan, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.personDbk());set(ps,8,r.organizationDbk());set(ps,9,r.contractDbk());set(ps,10,r.legacyRelation4Dbk());set(ps,11,r.legacyRelation5Dbk());set(ps,12,r.contractPositionDbk());set(ps,13,r.projectName());set(ps,14,r.projectId());set(ps,15,r.projectTask());set(ps,16,r.mclId());set(ps,17,r.mclName());set(ps,18,r.mclDesc());set(ps,19,ts(r.validFrom()));set(ps,20,ts(r.validTo()));set(ps,21,r.effortPlan());}); updateTableState("cost-unit", maxUpd(rows, LeitstandSourceRows.CostUnitRow::upd), lastId(rows, LeitstandSourceRows.CostUnitRow::dbk)); }
private void syncTasks(TimeSyncRun run) { var rows = sourceClient.fetchTasks(watermark("task")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_task(dbk, dbk_uid, upd, upd_anz, upd_uid, status, primary_cost_unit_dbk, secondary_cost_unit_dbk, tertiary_cost_unit_dbk, parent_task_dbk, lfdnr, task_type, mcl_id, mcl_name, mcl_desc, valid_from, valid_to, effort_plan, last_work, completion_percent, done_date, remark, n_rid, legacy_90702_rid, amount, created_source_at, legacy_90700_rid, legacy_90699_rid, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, primary_cost_unit_dbk=EXCLUDED.primary_cost_unit_dbk, secondary_cost_unit_dbk=EXCLUDED.secondary_cost_unit_dbk, tertiary_cost_unit_dbk=EXCLUDED.tertiary_cost_unit_dbk, parent_task_dbk=EXCLUDED.parent_task_dbk, lfdnr=EXCLUDED.lfdnr, task_type=EXCLUDED.task_type, mcl_id=EXCLUDED.mcl_id, mcl_name=EXCLUDED.mcl_name, mcl_desc=EXCLUDED.mcl_desc, valid_from=EXCLUDED.valid_from, valid_to=EXCLUDED.valid_to, effort_plan=EXCLUDED.effort_plan, last_work=EXCLUDED.last_work, completion_percent=EXCLUDED.completion_percent, done_date=EXCLUDED.done_date, remark=EXCLUDED.remark, n_rid=EXCLUDED.n_rid, legacy_90702_rid=EXCLUDED.legacy_90702_rid, amount=EXCLUDED.amount, created_source_at=EXCLUDED.created_source_at, legacy_90700_rid=EXCLUDED.legacy_90700_rid, legacy_90699_rid=EXCLUDED.legacy_90699_rid, quantity=EXCLUDED.quantity, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.primaryCostUnitDbk());set(ps,8,r.secondaryCostUnitDbk());set(ps,9,r.tertiaryCostUnitDbk());set(ps,10,r.parentTaskDbk());set(ps,11,r.lfdnr());set(ps,12,r.taskType());set(ps,13,r.mclId());set(ps,14,r.mclName());set(ps,15,r.mclDesc());set(ps,16,ts(r.validFrom()));set(ps,17,ts(r.validTo()));set(ps,18,r.effortPlan());set(ps,19,ts(r.lastWork()));set(ps,20,r.completionPercent());set(ps,21,ts(r.doneDate()));set(ps,22,r.remark());set(ps,23,r.nRid());set(ps,24,r.legacy90702Rid());set(ps,25,r.amount());set(ps,26,ts(r.createdSourceAt()));set(ps,27,r.legacy90700Rid());set(ps,28,r.legacy90699Rid());set(ps,29,r.quantity());}); updateTableState("task", maxUpd(rows, LeitstandSourceRows.TaskRow::upd), lastId(rows, LeitstandSourceRows.TaskRow::dbk)); }
private void syncPersonTaskAssignments(TimeSyncRun run) { var rows = sourceClient.fetchPersonTaskAssignments(watermark("person-task-assignment")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_person_task_assignment(dbk, dbk_uid, upd, upd_anz, upd_uid, status, task_dbk, person_dbk, cost_unit_dbk, rtype, last_work, effort_plan) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, task_dbk=EXCLUDED.task_dbk, person_dbk=EXCLUDED.person_dbk, cost_unit_dbk=EXCLUDED.cost_unit_dbk, rtype=EXCLUDED.rtype, last_work=EXCLUDED.last_work, effort_plan=EXCLUDED.effort_plan, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.taskDbk());set(ps,8,r.personDbk());set(ps,9,r.costUnitDbk());set(ps,10,r.rtype());set(ps,11,ts(r.lastWork()));set(ps,12,r.effortPlan());}); updateTableState("person-task-assignment", maxUpd(rows, LeitstandSourceRows.PersonTaskAssignmentRow::upd), lastId(rows, LeitstandSourceRows.PersonTaskAssignmentRow::dbk)); }
private List<LeitstandSourceRows.TimeRecordingRow> syncTimeRecordings(TimeSyncRun run) { var rows = sourceClient.fetchTimeRecordings(watermark("time-recording")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_time_recording(dbk, dbk_uid, upd, upd_anz, upd_uid, status, person_dbk, lfdnr, record_type, mcl_id, mcl_desc, recorded_from, recorded_to, effort, remark, url, activity_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, person_dbk=EXCLUDED.person_dbk, lfdnr=EXCLUDED.lfdnr, record_type=EXCLUDED.record_type, mcl_id=EXCLUDED.mcl_id, mcl_desc=EXCLUDED.mcl_desc, recorded_from=EXCLUDED.recorded_from, recorded_to=EXCLUDED.recorded_to, effort=EXCLUDED.effort, remark=EXCLUDED.remark, url=EXCLUDED.url, activity_type_id=EXCLUDED.activity_type_id, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.personDbk());set(ps,8,r.lfdnr());set(ps,9,r.recordType());set(ps,10,r.mclId());set(ps,11,r.mclDesc());set(ps,12,ts(r.recordedFrom()));set(ps,13,ts(r.recordedTo()));set(ps,14,r.effort());set(ps,15,r.remark());set(ps,16,r.url());set(ps,17,r.activityTypeId());}); updateTableState("time-recording", maxUpd(rows, LeitstandSourceRows.TimeRecordingRow::upd), lastId(rows, LeitstandSourceRows.TimeRecordingRow::dbk)); return rows; }
private void syncTimeRecordingAssignments(TimeSyncRun run) { var rows = sourceClient.fetchTimeRecordingAssignments(watermark("time-recording-assignment")); run.setRowsRead(run.getRowsRead()+rows.size()); batchUpsert("""
INSERT INTO "time".ls_time_recording_assignment(dbk, dbk_uid, upd, upd_anz, upd_uid, status, person_task_assignment_dbk, time_recording_dbk, mcl_desc, effort, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dbk) DO UPDATE SET dbk_uid=EXCLUDED.dbk_uid, upd=EXCLUDED.upd, upd_anz=EXCLUDED.upd_anz, upd_uid=EXCLUDED.upd_uid, status=EXCLUDED.status, person_task_assignment_dbk=EXCLUDED.person_task_assignment_dbk, time_recording_dbk=EXCLUDED.time_recording_dbk, mcl_desc=EXCLUDED.mcl_desc, effort=EXCLUDED.effort, remark=EXCLUDED.remark, updated_at=CURRENT_TIMESTAMP
""", rows, (ps,r)->{set(ps,1,r.dbk());set(ps,2,r.dbkUid());set(ps,3,r.upd());set(ps,4,r.updAnz());set(ps,5,r.updUid());set(ps,6,r.status());set(ps,7,r.personTaskAssignmentDbk());set(ps,8,r.timeRecordingDbk());set(ps,9,r.mclDesc());set(ps,10,r.effort());set(ps,11,r.remark());}); updateTableState("time-recording-assignment", maxUpd(rows, LeitstandSourceRows.TimeRecordingAssignmentRow::upd), lastId(rows, LeitstandSourceRows.TimeRecordingAssignmentRow::dbk)); }
@Transactional
protected void upsertCanonicalTimeEntries(List<LeitstandSourceRows.TimeRecordingRow> rows) {
Map<String, String> personNames = loadPersonNames();
for (var row : rows) {
String businessKey = "TIME:LEITSTAND:" + row.dbk();
Document document = documentRepository.findByBusinessKey(businessKey).orElseGet(() -> Document.builder().businessKey(businessKey).documentType(DocumentType.TIME_ENTRY).documentFamily(DocumentFamily.TIME).visibility(DocumentVisibility.PUBLIC).status(DocumentStatus.RECEIVED).build());
document.setTitle(buildTitle(row, personNames.get(row.personDbk())));
document.setSummary(buildSummary(row));
document = documentRepository.save(document);
Document finalDocument = document;
TimeEntry entry = timeEntryRepository.findBySourceSystemAndExternalId(TimeSourceSystem.LEITSTAND, row.dbk()).orElseGet(() -> TimeEntry.builder().sourceSystem(TimeSourceSystem.LEITSTAND).externalId(row.dbk()).document(finalDocument).build());
entry.setDocument(document);
entry.setPersonExternalId(row.personDbk());
entry.setPersonDisplayName(personNames.get(row.personDbk()));
entry.setEntryStart(row.recordedFrom());
entry.setEntryEnd(row.recordedTo());
entry.setDurationSeconds(toSeconds(row.effort()));
entry.setDescriptionShort(row.mclDesc());
entry.setDescriptionLong(row.remark());
entry.setRawStatus(row.status() == null ? null : String.valueOf(row.status()));
entry.setSearchAnchorLabel(document.getTitle());
entry = timeEntryRepository.save(entry);
jdbcTemplate.update("""
update "time".ls_time_recording set time_entry_id = ? where dbk = ?
""", entry.getId(), row.dbk());
upsertSourceLink(entry, "TIME_RECORDING", row.dbk(), null);
if (row.personDbk() != null) upsertSourceLink(entry, "PERSON", row.personDbk(), row.dbk());
if (row.activityTypeId() != null) upsertSourceLink(entry, "ACTIVITY_TYPE", String.valueOf(row.activityTypeId()), row.dbk());
}
}
private void upsertSourceLink(TimeEntry entry, String type, String externalId, String parentExternalId) {
TimeEntrySourceLink link = sourceLinkRepository.findByTimeEntry_Id(entry.getId()).stream()
.filter(it -> it.getSourceSystem() == TimeSourceSystem.LEITSTAND && type.equals(it.getSourceEntityType()) && externalId.equals(it.getSourceExternalId()))
.findFirst()
.orElseGet(() -> TimeEntrySourceLink.builder().timeEntry(entry).sourceSystem(TimeSourceSystem.LEITSTAND).sourceEntityType(type).sourceExternalId(externalId).build());
link.setParentSourceExternalId(parentExternalId);
sourceLinkRepository.save(link);
}
private Map<String, String> loadPersonNames() {
Map<String, String> result = new HashMap<>();
jdbcTemplate.query("""
select dbk, first_name, last_name from "time".ls_person
""", rs -> {
result.put(rs.getString("dbk"), firstNonBlank(joinName(rs.getString("first_name"), rs.getString("last_name")), rs.getString("last_name"), rs.getString("dbk")));
});
return result;
}
private String buildTitle(LeitstandSourceRows.TimeRecordingRow row, String personName) {
String base = firstNonBlank(row.mclDesc(), row.remark(), row.mclId());
if (base == null) base = "Time entry " + row.dbk();
return personName == null ? base : personName + " - " + base;
}
private String buildSummary(LeitstandSourceRows.TimeRecordingRow row) {
StringBuilder sb = new StringBuilder();
if (row.mclDesc() != null && !row.mclDesc().isBlank()) sb.append(row.mclDesc().trim());
if (row.remark() != null && !row.remark().isBlank()) { if (sb.length() > 0) sb.append(" | "); sb.append(row.remark().trim()); }
if (row.mclId() != null && !row.mclId().isBlank()) { if (sb.length() > 0) sb.append(" | "); sb.append("ID ").append(row.mclId().trim()); }
return sb.length() == 0 ? null : sb.toString();
}
private Long toSeconds(BigDecimal effort) { return effort == null ? null : effort.multiply(BigDecimal.valueOf(3600)).setScale(0, RoundingMode.HALF_UP).longValue(); }
private String joinName(String first, String last) { return ((first == null ? "" : first.trim()) + (last == null ? "" : " " + last.trim())).trim(); }
private String firstNonBlank(String... vals) { for (String v : vals) if (v != null && !v.trim().isEmpty()) return v.trim(); return null; }
private String watermark(String scope) { return properties.getLeitstand().isIncrementalEnabled() ? syncStateRepository.findBySourceSystemAndScopeKey(TimeSourceSystem.LEITSTAND, tableScope(scope)).map(TimeSyncState::getLastSeenWatermark).orElse(null) : null; }
private String tableScope(String scope) { return "leitstand:" + scope; }
private <T> String lastId(List<T> rows, java.util.function.Function<T,String> id) { return rows.isEmpty() ? null : id.apply(rows.get(rows.size()-1)); }
private <T> String maxUpd(List<T> rows, java.util.function.Function<T,String> upd) { return rows.stream().map(upd).filter(v -> v != null && !v.isBlank()).max(String::compareTo).orElse(null); }
@Transactional
protected void updateTableState(String scope, String watermark, String lastExternalId) {
TimeSyncState state = syncStateRepository.findBySourceSystemAndScopeKey(TimeSourceSystem.LEITSTAND, tableScope(scope)).orElseGet(() -> TimeSyncState.builder().sourceSystem(TimeSourceSystem.LEITSTAND).scopeKey(tableScope(scope)).build());
state.setLastAttemptedSyncAt(OffsetDateTime.now());
state.setLastSuccessfulSyncAt(OffsetDateTime.now());
state.setLastSeenWatermark(watermark);
state.setLastSeenExternalId(lastExternalId);
syncStateRepository.save(state);
}
@Transactional
protected void updateGlobalState(TimeSyncRun run, String notes) {
TimeSyncState state = syncStateRepository.findBySourceSystemAndScopeKey(TimeSourceSystem.LEITSTAND, GLOBAL_SCOPE).orElseGet(() -> TimeSyncState.builder().sourceSystem(TimeSourceSystem.LEITSTAND).scopeKey(GLOBAL_SCOPE).build());
state.setLastAttemptedSyncAt(run.getStartedAt());
if (run.getStatus() == TimeSyncRunStatus.COMPLETED) state.setLastSuccessfulSyncAt(run.getFinishedAt());
state.setLastRun(run);
state.setNotes(notes);
syncStateRepository.save(state);
}
private <T> void batchUpsert(String sql, List<T> rows, Binder<T> binder) {
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override public void setValues(PreparedStatement ps, int i) throws java.sql.SQLException { binder.bind(ps, rows.get(i)); }
@Override public int getBatchSize() { return rows.size(); }
});
}
private void set(PreparedStatement ps, int index, Object value) throws java.sql.SQLException { ps.setObject(index, value); }
private Timestamp ts(OffsetDateTime t) { return t == null ? null : Timestamp.from(t.toInstant()); }
@FunctionalInterface
private interface Binder<T> { void bind(PreparedStatement ps, T row) throws java.sql.SQLException; }
}

View File

@ -0,0 +1,25 @@
package at.procon.dip.domain.time.startup;
import at.procon.dip.domain.time.service.LeitstandTimeImportService;
import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode;
import at.procon.dip.runtime.config.RuntimeMode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnRuntimeMode(RuntimeMode.NEW)
@ConditionalOnProperty(prefix = "dip.time.leitstand", name = {"enabled", "startup-sync-enabled"}, havingValue = "true")
@RequiredArgsConstructor
@Slf4j
public class LeitstandTimeImportStartupRunner implements ApplicationRunner {
private final LeitstandTimeImportService importService;
@Override
public void run(ApplicationArguments args) {
log.info("Starting Leitstand TIME import (T2)");
importService.runSync();
}
}

View File

@ -293,13 +293,19 @@ dip:
# Delete tar.gz after ingestion # Delete tar.gz after ingestion
delete-after-ingestion: true delete-after-ingestion: true
time: time:
enabled: false enabled: true
leitstand: leitstand:
enabled: false enabled: true
startup-sync-enabled: true
import-batch-id: time-leitstand import-batch-id: time-leitstand
reconcile-lookback-days: 7 reconcile-lookback-days: 7
create-canonical-time-entries: true
jdbc:
url: jdbc:jtds:sqlserver://mag2:1433;databaseName=spc
username: sa
password: jhcbxr
driver-class-name: net.sourceforge.jtds.jdbc.Driver
toggl-track: toggl-track:
enabled: false enabled: false
import-batch-id: time-toggl import-batch-id: time-toggl

View File

@ -0,0 +1,30 @@
-- Attribute catalog for text-import name/value pairs.
CREATE TABLE IF NOT EXISTS DOC.doc_attribute_name (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
attribute_name VARCHAR(255) NOT NULL,
normalized_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS DOC.doc_document_attribute (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES DOC.doc_document(id) ON DELETE CASCADE,
attribute_name_id UUID NOT NULL REFERENCES DOC.doc_attribute_name(id),
attribute_value TEXT NOT NULL,
attribute_value_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_attr_name_normalized ON DOC.doc_attribute_name(normalized_name);
CREATE INDEX IF NOT EXISTS idx_doc_attr_name_name ON DOC.doc_attribute_name(attribute_name);
CREATE INDEX IF NOT EXISTS idx_doc_doc_attr_document ON DOC.doc_document_attribute(document_id);
CREATE INDEX IF NOT EXISTS idx_doc_doc_attr_name ON DOC.doc_document_attribute(attribute_name_id);
CREATE INDEX IF NOT EXISTS idx_doc_doc_attr_value_hash ON DOC.doc_document_attribute(attribute_value_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_doc_attr_doc_name_hash
ON DOC.doc_document_attribute(document_id, attribute_name_id, attribute_value_hash);
COMMENT ON TABLE DOC.doc_attribute_name IS 'Global catalog of reusable metadata attribute names';
COMMENT ON TABLE DOC.doc_document_attribute IS 'Intersection table linking documents to catalog-backed name/value attributes';

View File

@ -1,4 +1,4 @@
-- TIME Phase T1 foundation for shared time-entry structures in the NEW runtime. -- TIME Phase T1 foundation for shared time-entry structures in the NEW run"time".
-- No source import logic yet; this migration only creates the shared TIME schema and base state tables. -- No source import logic yet; this migration only creates the shared TIME schema and base state tables.
CREATE SCHEMA IF NOT EXISTS TIME; CREATE SCHEMA IF NOT EXISTS TIME;
@ -13,7 +13,7 @@ BEGIN
JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'time_source_system' AND n.nspname = 'time' WHERE t.typname = 'time_source_system' AND n.nspname = 'time'
) THEN ) THEN
CREATE TYPE TIME.time_source_system AS ENUM ('LEITSTAND', 'TOGGL_TRACK'); CREATE TYPE "time".time_source_system AS ENUM ('LEITSTAND', 'TOGGL_TRACK');
END IF; END IF;
END END
$$; $$;
@ -26,7 +26,7 @@ BEGIN
JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'time_sync_run_status' AND n.nspname = 'time' WHERE t.typname = 'time_sync_run_status' AND n.nspname = 'time'
) THEN ) THEN
CREATE TYPE TIME.time_sync_run_status AS ENUM ('RUNNING', 'COMPLETED', 'FAILED'); CREATE TYPE "time".time_sync_run_status AS ENUM ('RUNNING', 'COMPLETED', 'FAILED');
END IF; END IF;
END END
$$; $$;
@ -39,7 +39,7 @@ BEGIN
JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'time_sync_run_type' AND n.nspname = 'time' WHERE t.typname = 'time_sync_run_type' AND n.nspname = 'time'
) THEN ) THEN
CREATE TYPE TIME.time_sync_run_type AS ENUM ('INITIAL', 'INCREMENTAL', 'RECONCILE', 'MANUAL'); CREATE TYPE "time".time_sync_run_type AS ENUM ('INITIAL', 'INCREMENTAL', 'RECONCILE', 'MANUAL');
END IF; END IF;
END END
$$; $$;
@ -53,7 +53,7 @@ BEGIN
JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'doc' AND t.typname = 'doc_document_type' AND e.enumlabel = 'TIME_ENTRY' WHERE n.nspname = 'doc' AND t.typname = 'doc_document_type' AND e.enumlabel = 'TIME_ENTRY'
) THEN ) THEN
ALTER TYPE DOC.doc_document_type ADD VALUE 'TIME_ENTRY'; ALTER TYPE doc.doc_document_type ADD VALUE 'TIME_ENTRY';
END IF; END IF;
END END
$$; $$;
@ -67,15 +67,15 @@ BEGIN
JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'doc' AND t.typname = 'doc_document_family' AND e.enumlabel = 'TIME' WHERE n.nspname = 'doc' AND t.typname = 'doc_document_family' AND e.enumlabel = 'TIME'
) THEN ) THEN
ALTER TYPE DOC.doc_document_family ADD VALUE 'TIME'; ALTER TYPE doc.doc_document_family ADD VALUE 'TIME';
END IF; END IF;
END END
$$; $$;
CREATE TABLE IF NOT EXISTS TIME.time_entry ( CREATE TABLE IF NOT EXISTS "time".time_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL UNIQUE REFERENCES DOC.doc_document(id) ON DELETE CASCADE, document_id UUID NOT NULL UNIQUE REFERENCES doc.doc_document(id) ON DELETE CASCADE,
source_system TIME.time_source_system NOT NULL, source_system "time".time_source_system NOT NULL,
external_id VARCHAR(255) NOT NULL, external_id VARCHAR(255) NOT NULL,
person_external_id VARCHAR(255), person_external_id VARCHAR(255),
person_display_name VARCHAR(255), person_display_name VARCHAR(255),
@ -95,10 +95,10 @@ CREATE TABLE IF NOT EXISTS TIME.time_entry (
CONSTRAINT uq_time_entry_source UNIQUE (source_system, external_id) CONSTRAINT uq_time_entry_source UNIQUE (source_system, external_id)
); );
CREATE TABLE IF NOT EXISTS TIME.time_entry_source_link ( CREATE TABLE IF NOT EXISTS "time".time_entry_source_link (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
time_entry_id UUID NOT NULL REFERENCES TIME.time_entry(id) ON DELETE CASCADE, time_entry_id UUID NOT NULL REFERENCES "time".time_entry(id) ON DELETE CASCADE,
source_system TIME.time_source_system NOT NULL, source_system "time".time_source_system NOT NULL,
source_entity_type VARCHAR(120) NOT NULL, source_entity_type VARCHAR(120) NOT NULL,
source_external_id VARCHAR(255) NOT NULL, source_external_id VARCHAR(255) NOT NULL,
linked_role VARCHAR(120), linked_role VARCHAR(120),
@ -110,12 +110,12 @@ CREATE TABLE IF NOT EXISTS TIME.time_entry_source_link (
CONSTRAINT uq_time_entry_source_link UNIQUE (time_entry_id, source_system, source_entity_type, source_external_id) CONSTRAINT uq_time_entry_source_link UNIQUE (time_entry_id, source_system, source_entity_type, source_external_id)
); );
CREATE TABLE IF NOT EXISTS TIME.time_sync_run ( CREATE TABLE IF NOT EXISTS "time".time_sync_run (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_system TIME.time_source_system NOT NULL, source_system "time".time_source_system NOT NULL,
run_type TIME.time_sync_run_type NOT NULL, run_type "time".time_sync_run_type NOT NULL,
scope_key VARCHAR(255) NOT NULL, scope_key VARCHAR(255) NOT NULL,
status TIME.time_sync_run_status NOT NULL, status "time".time_sync_run_status NOT NULL,
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP WITH TIME ZONE, finished_at TIMESTAMP WITH TIME ZONE,
watermark_from VARCHAR(255), watermark_from VARCHAR(255),
@ -130,36 +130,36 @@ CREATE TABLE IF NOT EXISTS TIME.time_sync_run (
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS TIME.time_sync_state ( CREATE TABLE IF NOT EXISTS "time".time_sync_state (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_system TIME.time_source_system NOT NULL, source_system "time".time_source_system NOT NULL,
scope_key VARCHAR(255) NOT NULL, scope_key VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_successful_sync_at TIMESTAMP WITH TIME ZONE, last_successful_sync_at TIMESTAMP WITH TIME ZONE,
last_attempted_sync_at TIMESTAMP WITH TIME ZONE, last_attempted_sync_at TIMESTAMP WITH TIME ZONE,
last_seen_watermark VARCHAR(255), last_seen_watermark VARCHAR(255),
last_seen_external_id VARCHAR(255), last_seen_external_id VARCHAR(255),
last_run_id UUID REFERENCES TIME.time_sync_run(id) ON DELETE SET NULL, last_run_id UUID REFERENCES "time".time_sync_run(id) ON DELETE SET NULL,
notes TEXT, notes TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_time_sync_state_scope UNIQUE (source_system, scope_key) CONSTRAINT uq_time_sync_state_scope UNIQUE (source_system, scope_key)
); );
CREATE INDEX IF NOT EXISTS idx_time_entry_source_system ON TIME.time_entry(source_system); CREATE INDEX IF NOT EXISTS idx_time_entry_source_system ON "time".time_entry(source_system);
CREATE INDEX IF NOT EXISTS idx_time_entry_external_id ON TIME.time_entry(external_id); CREATE INDEX IF NOT EXISTS idx_time_entry_external_id ON "time".time_entry(external_id);
CREATE INDEX IF NOT EXISTS idx_time_entry_start ON TIME.time_entry(entry_start DESC); CREATE INDEX IF NOT EXISTS idx_time_entry_start ON "time".time_entry(entry_start DESC);
CREATE INDEX IF NOT EXISTS idx_time_entry_end ON TIME.time_entry(entry_end DESC); CREATE INDEX IF NOT EXISTS idx_time_entry_end ON "time".time_entry(entry_end DESC);
CREATE INDEX IF NOT EXISTS idx_time_entry_source_updated ON TIME.time_entry(source_updated_at DESC); CREATE INDEX IF NOT EXISTS idx_time_entry_source_updated ON "time".time_entry(source_updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_time_entry_link_entry ON TIME.time_entry_source_link(time_entry_id); CREATE INDEX IF NOT EXISTS idx_time_entry_link_entry ON "time".time_entry_source_link(time_entry_id);
CREATE INDEX IF NOT EXISTS idx_time_entry_link_source_lookup ON TIME.time_entry_source_link(source_system, source_entity_type, source_external_id); CREATE INDEX IF NOT EXISTS idx_time_entry_link_source_lookup ON "time".time_entry_source_link(source_system, source_entity_type, source_external_id);
CREATE INDEX IF NOT EXISTS idx_time_entry_link_role ON TIME.time_entry_source_link(linked_role); CREATE INDEX IF NOT EXISTS idx_time_entry_link_role ON "time".time_entry_source_link(linked_role);
CREATE INDEX IF NOT EXISTS idx_time_sync_run_source_system ON TIME.time_sync_run(source_system); CREATE INDEX IF NOT EXISTS idx_time_sync_run_source_system ON "time".time_sync_run(source_system);
CREATE INDEX IF NOT EXISTS idx_time_sync_run_scope_key ON TIME.time_sync_run(scope_key); CREATE INDEX IF NOT EXISTS idx_time_sync_run_scope_key ON "time".time_sync_run(scope_key);
CREATE INDEX IF NOT EXISTS idx_time_sync_run_status ON TIME.time_sync_run(status); CREATE INDEX IF NOT EXISTS idx_time_sync_run_status ON "time".time_sync_run(status);
CREATE INDEX IF NOT EXISTS idx_time_sync_run_started_at ON TIME.time_sync_run(started_at DESC); CREATE INDEX IF NOT EXISTS idx_time_sync_run_started_at ON "time".time_sync_run(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_time_sync_state_source_system ON TIME.time_sync_state(source_system); CREATE INDEX IF NOT EXISTS idx_time_sync_state_source_system ON "time".time_sync_state(source_system);
CREATE INDEX IF NOT EXISTS idx_time_sync_state_scope_key ON TIME.time_sync_state(scope_key); CREATE INDEX IF NOT EXISTS idx_time_sync_state_scope_key ON "time".time_sync_state(scope_key);

View File

@ -0,0 +1,99 @@
-- TIME Phase T2: Leitstand source import tables and canonical time-entry import scaffolding.
CREATE TABLE IF NOT EXISTS TIME.ls_activity_type (
id INTEGER PRIMARY KEY,
l_code VARCHAR(32),
bez VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS TIME.ls_person (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
pers_nr INTEGER, last_name VARCHAR(35), first_name VARCHAR(25), category_dbk VARCHAR(24),
salutation VARCHAR(15), title VARCHAR(30), street VARCHAR(36), postal_code VARCHAR(6), city VARCHAR(30),
country_code VARCHAR(4), phone VARCHAR(30), fax VARCHAR(30), email VARCHAR(50), cost_per_hour NUMERIC(8,3), organization_dbk VARCHAR(24),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_person_upd ON TIME.ls_person(upd);
CREATE TABLE IF NOT EXISTS TIME.ls_organization (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
betreu VARCHAR(2), name VARCHAR(255), street VARCHAR(50), postal_code VARCHAR(6), city VARCHAR(50), phone VARCHAR(30), fax VARCHAR(30), email VARCHAR(50), org_nr INTEGER, short_name VARCHAR(30),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_organization_upd ON TIME.ls_organization(upd);
CREATE TABLE IF NOT EXISTS TIME.ls_contract (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
organization_dbk VARCHAR(24), lfdnr INTEGER, name VARCHAR(255), iref VARCHAR(255), eref VARCHAR(255), description VARCHAR(255), valid_from TIMESTAMP WITH TIME ZONE, valid_to TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_contract_upd ON TIME.ls_contract(upd);
CREATE INDEX IF NOT EXISTS idx_ls_contract_org_dbk ON TIME.ls_contract(organization_dbk);
CREATE TABLE IF NOT EXISTS TIME.ls_contract_position (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
contract_dbk VARCHAR(24), lfdnr INTEGER, project_name VARCHAR(255), sales_price NUMERIC(18,4), purchase_price NUMERIC(18,4), description VARCHAR(255), valid_from TIMESTAMP WITH TIME ZONE, valid_to TIMESTAMP WITH TIME ZONE,
name VARCHAR(255), iref VARCHAR(255), eref VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_contract_position_upd ON TIME.ls_contract_position(upd);
CREATE INDEX IF NOT EXISTS idx_ls_contract_position_contract_dbk ON TIME.ls_contract_position(contract_dbk);
CREATE TABLE IF NOT EXISTS TIME.ls_cost_unit (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
person_dbk VARCHAR(24), organization_dbk VARCHAR(24), contract_dbk VARCHAR(24), legacy_relation_4_dbk VARCHAR(24), legacy_relation_5_dbk VARCHAR(24), contract_position_dbk VARCHAR(24),
project_name VARCHAR(255), project_id INTEGER, project_task INTEGER, mcl_id VARCHAR(255), mcl_name VARCHAR(255), mcl_desc VARCHAR(255), valid_from TIMESTAMP WITH TIME ZONE, valid_to TIMESTAMP WITH TIME ZONE, effort_plan NUMERIC(10,3),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_cost_unit_upd ON TIME.ls_cost_unit(upd);
CREATE INDEX IF NOT EXISTS idx_ls_cost_unit_contract_dbk ON TIME.ls_cost_unit(contract_dbk);
CREATE TABLE IF NOT EXISTS TIME.ls_task (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
primary_cost_unit_dbk VARCHAR(24), secondary_cost_unit_dbk VARCHAR(24), tertiary_cost_unit_dbk VARCHAR(24), parent_task_dbk VARCHAR(24),
lfdnr INTEGER, task_type VARCHAR(1), mcl_id VARCHAR(255), mcl_name VARCHAR(255), mcl_desc VARCHAR(8000), valid_from TIMESTAMP WITH TIME ZONE, valid_to TIMESTAMP WITH TIME ZONE, effort_plan NUMERIC(10,1),
last_work TIMESTAMP WITH TIME ZONE, completion_percent INTEGER, done_date TIMESTAMP WITH TIME ZONE, remark VARCHAR(255), n_rid INTEGER, legacy_90702_rid INTEGER, amount NUMERIC(18,4), created_source_at TIMESTAMP WITH TIME ZONE, legacy_90700_rid INTEGER, legacy_90699_rid INTEGER, quantity INTEGER,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_task_upd ON TIME.ls_task(upd);
CREATE INDEX IF NOT EXISTS idx_ls_task_primary_cost_unit_dbk ON TIME.ls_task(primary_cost_unit_dbk);
CREATE TABLE IF NOT EXISTS TIME.ls_person_task_assignment (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
task_dbk VARCHAR(24), person_dbk VARCHAR(24), cost_unit_dbk VARCHAR(24), rtype VARCHAR(1), last_work TIMESTAMP WITH TIME ZONE, effort_plan NUMERIC(19,3),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_person_task_assignment_upd ON TIME.ls_person_task_assignment(upd);
CREATE TABLE IF NOT EXISTS TIME.ls_time_recording (
dbk VARCHAR(24) PRIMARY KEY,
time_entry_id UUID UNIQUE REFERENCES TIME.time_entry(id) ON DELETE SET NULL,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
person_dbk VARCHAR(24), lfdnr INTEGER, record_type VARCHAR(32), mcl_id VARCHAR(255), mcl_desc VARCHAR(8000), recorded_from TIMESTAMP WITH TIME ZONE, recorded_to TIMESTAMP WITH TIME ZONE, effort NUMERIC(19,3), remark VARCHAR(8000), url VARCHAR(1000), activity_type_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_time_recording_upd ON TIME.ls_time_recording(upd);
CREATE TABLE IF NOT EXISTS TIME.ls_time_recording_assignment (
dbk VARCHAR(24) PRIMARY KEY,
dbk_uid VARCHAR(7), upd VARCHAR(24), upd_anz INTEGER, upd_uid VARCHAR(7), status INTEGER,
person_task_assignment_dbk VARCHAR(24), time_recording_dbk VARCHAR(24), mcl_desc VARCHAR(8000), effort NUMERIC(19,3), remark VARCHAR(8000),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ls_time_recording_assignment_upd ON TIME.ls_time_recording_assignment(upd);

View File

@ -34,7 +34,6 @@ $$;
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
SELECT 1
FROM pg_constraint c FROM pg_constraint c
JOIN pg_class r ON r.oid = c.conrelid JOIN pg_class r ON r.oid = c.conrelid
JOIN pg_namespace n ON n.oid = r.relnamespace JOIN pg_namespace n ON n.oid = r.relnamespace
@ -48,7 +47,7 @@ BEGIN
CHECK ( CHECK (
document_type IN ( document_type IN (
'TED_PACKAGE', 'TED_NOTICE', 'EMAIL', 'MIME_MESSAGE', 'PDF', 'DOCX', 'HTML', 'TED_PACKAGE', 'TED_NOTICE', 'EMAIL', 'MIME_MESSAGE', 'PDF', 'DOCX', 'HTML',
'XML_GENERIC', 'TEXT', 'MARKDOWN', 'ZIP_ARCHIVE', 'GENERIC_BINARY', 'UNKNOWN' 'XML_GENERIC', 'TEXT', 'MARKDOWN', 'ZIP_ARCHIVE', 'GENERIC_BINARY', 'TIME_ENTRY', 'UNKNOWN'
) )
); );
END IF; END IF;