diff --git a/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt index a1034ffb..71c8e51e 100644 --- a/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt +++ b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt @@ -306,4 +306,52 @@ class ZnsImportServiceTest { assertThat(result.gesamtAktualisiert).isEqualTo(0) assertThat(result.fehler).isEmpty() } + + @Test + fun `importiereZip - Funktionaer mit mehrfachen Qualifikationen`() = runTest { + // Zeile mit vielen Qualifikationen (Satznummer X014346) + val qualifikationen = "DM,DPF,GAR-SP,SPF,SS*,RD,RS" + val zeile = "X014346Schubert Renate $qualifikationen" + val zip = buildZip("RICHT01.DAT" to zeile) + + coEvery { funktionaerRepository.findBySatz("X", 14346) } returns null + coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + coEvery { reiterRepository.findByName(any(), any()) } returns emptyList() + + val result = service.importiereZip(zip) + + assertThat(result.richterImportiert).isEqualTo(1) + coVerify { + funktionaerRepository.save(match { f -> + f.qualifikationen.size == 7 && + f.qualifikationen.containsAll(listOf("DM", "DPF", "GAR-SP", "SPF", "SS*", "RD", "RS")) + }) + } + } + + @Test + fun `importiereZip - Funktionaer Update Strategie (Delete+Insert)`() = runTest { + val zeile = funktionaerZeile(typ = "X", satznummer = "123456", name = "Geaendert Name") + val zip = buildZip("RICHT01.DAT" to zeile) + + val existing = Funktionaer( + funktionaerId = kotlin.uuid.Uuid.random(), + satzId = "X", + satzNummer = 123456, + name = "Alt Name" + ) + + coEvery { funktionaerRepository.findBySatz("X", 123456) } returns existing + coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + coEvery { reiterRepository.findByName(any(), any()) } returns emptyList() + + val result = service.importiereZip(zip) + + assertThat(result.richterAktualisiert).isEqualTo(1) + coVerify { + funktionaerRepository.save(match { f -> + f.funktionaerId == existing.funktionaerId && f.name == "Geaendert Name" + }) + } + } } diff --git a/backend/services/billing/billing-domain/build.gradle.kts b/backend/services/billing/billing-domain/build.gradle.kts new file mode 100644 index 00000000..cf0d2813 --- /dev/null +++ b/backend/services/billing/billing-domain/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvm() + js(IR) { + browser() + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) + implementation(libs.kotlinx.datetime) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt new file mode 100644 index 00000000..c17b017e --- /dev/null +++ b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/model/TeilnehmerKonto.kt @@ -0,0 +1,51 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.domain.model + +import at.mocode.core.domain.serialization.InstantSerializer +import kotlinx.serialization.Serializable +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Repräsentiert das Kassa-Konto eines Teilnehmers (Reiter oder Besitzer). + * Ein Konto wird pro Veranstaltung/Turnier geführt, kann aber veranstaltungsübergreifend aggregiert werden. + */ +@Serializable +data class TeilnehmerKonto constructor( + val kontoId: Uuid = Uuid.random(), + val veranstaltungId: Uuid, + val personId: Uuid, // Referenz auf Reiter oder Besitzer + val personName: String, + val saldoCent: Long = 0L, // Aktueller Kontostand in Cent + val bemerkungen: String? = null, + @Serializable(with = InstantSerializer::class) + val updatedAt: Instant = Clock.System.now() +) + +/** + * Ein einzelner Buchungsvorgang (Zahlung, Gutschrift, Gebühr). + */ +@Serializable +data class Buchung constructor( + val buchungId: Uuid = Uuid.random(), + val kontoId: Uuid, + val betragCent: Long, // Positiv für Gutschrift/Zahlung, Negativ für Gebühr/Soll + val typ: BuchungsTyp, + val verwendungszweck: String, + @Serializable(with = InstantSerializer::class) + val gebuchtAm: Instant = Clock.System.now() +) + +@Serializable +enum class BuchungsTyp { + NENNGEBUEHR, + STARTGEBUEHR, + BOXENGEBUEHR, + ZAHLUNG_BAR, + ZAHLUNG_KARTE, + GUTSCHRIFT, + STORNIERUNG +} diff --git a/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt new file mode 100644 index 00000000..6b9b2edf --- /dev/null +++ b/backend/services/billing/billing-domain/src/commonMain/kotlin/at/mocode/billing/domain/repository/BillingRepositories.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.domain.repository + +import at.mocode.billing.domain.model.Buchung +import at.mocode.billing.domain.model.TeilnehmerKonto +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Repository für den Zugriff auf Teilnehmer-Konten. + */ +interface TeilnehmerKontoRepository { + fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? + fun findById(kontoId: Uuid): TeilnehmerKonto? + fun save(konto: TeilnehmerKonto): TeilnehmerKonto + fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long +} + +/** + * Repository für den Zugriff auf Buchungen. + */ +interface BuchungRepository { + fun findByKonto(kontoId: Uuid): List + fun save(buchung: Buchung): Buchung +} diff --git a/backend/services/billing/billing-service/build.gradle.kts b/backend/services/billing/billing-service/build.gradle.kts new file mode 100644 index 00000000..44146b18 --- /dev/null +++ b/backend/services/billing/billing-service/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependencyManagement) + alias(libs.plugins.kotlinSpring) +} + +springBoot { + mainClass.set("at.mocode.billing.service.BillingServiceApplicationKt") +} + +dependencies { + // Interne Module + implementation(projects.platform.platformDependencies) + implementation(projects.core.coreUtils) + implementation(projects.backend.services.billing.billingDomain) + + // Spring Boot Starters + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.actuator) + implementation(libs.jackson.module.kotlin) + + // Datenbank-Abhängigkeiten + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.kotlin.datetime) + implementation(libs.hikari.cp) + runtimeOnly(libs.postgresql.driver) + testRuntimeOnly(libs.h2.driver) + + // Testing + testImplementation(projects.platform.platformTesting) + testImplementation(libs.spring.boot.starter.test) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/BillingServiceApplication.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/BillingServiceApplication.kt new file mode 100644 index 00000000..da75f292 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/BillingServiceApplication.kt @@ -0,0 +1,14 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import kotlin.uuid.ExperimentalUuidApi + +@SpringBootApplication +class BillingServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt new file mode 100644 index 00000000..1f20e228 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/TeilnehmerKontoService.kt @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.service + +import at.mocode.billing.domain.model.Buchung +import at.mocode.billing.domain.model.BuchungsTyp +import at.mocode.billing.domain.model.TeilnehmerKonto +import at.mocode.billing.domain.repository.BuchungRepository +import at.mocode.billing.domain.repository.TeilnehmerKontoRepository +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.springframework.stereotype.Service +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Service +class TeilnehmerKontoService( + private val kontoRepository: TeilnehmerKontoRepository, + private val buchungRepository: BuchungRepository +) { + + fun getOrCreateKonto(veranstaltungId: Uuid, personId: Uuid, personName: String): TeilnehmerKonto { + return transaction { + kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId) + ?: kontoRepository.save( + TeilnehmerKonto( + veranstaltungId = veranstaltungId, + personId = personId, + personName = personName + ) + ) + } + } + + fun bucheBetrag(kontoId: Uuid, betragCent: Long, typ: BuchungsTyp, verwendungszweck: String): Buchung { + return transaction { + val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId") + + val buchung = Buchung( + kontoId = kontoId, + betragCent = betragCent, + typ = typ, + verwendungszweck = verwendungszweck + ) + + val neueBuchung = buchungRepository.save(buchung) + kontoRepository.updateSaldo(kontoId, konto.saldoCent + betragCent) + + neueBuchung + } + } + + fun getBuchungen(kontoId: Uuid): List { + return buchungRepository.findByKonto(kontoId) + } + + fun getKonto(kontoId: Uuid): TeilnehmerKonto? { + return kontoRepository.findById(kontoId) + } +} diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt new file mode 100644 index 00000000..b5017de1 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/BillingTables.kt @@ -0,0 +1,47 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.service.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.timestamp +import kotlin.uuid.ExperimentalUuidApi + +/** + * Exposed-Tabellendefinition für das Teilnehmer-Konto. + */ +object TeilnehmerKontoTable : Table("teilnehmer_konten") { + val id = uuid("konto_id") + val veranstaltungId = uuid("veranstaltung_id") + val personId = uuid("person_id") + val personName = varchar("person_name", 200) + val saldoCent = long("saldo_cent").default(0L) + val bemerkungen = text("bemerkungen").nullable() + + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) + + override val primaryKey = PrimaryKey(id) + + init { + index("idx_konto_veranstaltung_person", isUnique = true, veranstaltungId, personId) + } +} + +/** + * Exposed-Tabellendefinition für Buchungen. + */ +object BuchungTable : Table("buchungen") { + val id = uuid("buchung_id") + val kontoId = uuid("konto_id") + val betragCent = long("betrag_cent") + val typ = varchar("typ", 50) + val verwendungszweck = varchar("verwendungszweck", 500) + val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp) + + override val primaryKey = PrimaryKey(id) + + init { + index("idx_buchung_konto", isUnique = false, kontoId) + } +} diff --git a/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt new file mode 100644 index 00000000..a9378d68 --- /dev/null +++ b/backend/services/billing/billing-service/src/main/kotlin/at/mocode/billing/service/persistence/ExposedBillingRepositories.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package at.mocode.billing.service.persistence + +import at.mocode.billing.domain.model.Buchung +import at.mocode.billing.domain.model.BuchungsTyp +import at.mocode.billing.domain.model.TeilnehmerKonto +import at.mocode.billing.domain.repository.BuchungRepository +import at.mocode.billing.domain.repository.TeilnehmerKontoRepository +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.springframework.stereotype.Repository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Repository +class ExposedTeilnehmerKontoRepository : TeilnehmerKontoRepository { + + override fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? { + return TeilnehmerKontoTable + .selectAll() + .where { (TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and (TeilnehmerKontoTable.personId eq personId) } + .singleOrNull() + ?.toModel() + } + + override fun findById(kontoId: Uuid): TeilnehmerKonto? { + return TeilnehmerKontoTable + .selectAll() + .where { TeilnehmerKontoTable.id eq kontoId } + .singleOrNull() + ?.toModel() + } + + override fun save(konto: TeilnehmerKonto): TeilnehmerKonto { + val existing = findById(konto.kontoId) + if (existing == null) { + TeilnehmerKontoTable.insert { + it[id] = konto.kontoId + it[veranstaltungId] = konto.veranstaltungId + it[personId] = konto.personId + it[personName] = konto.personName + it[saldoCent] = konto.saldoCent + it[bemerkungen] = konto.bemerkungen + } + } else { + TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq konto.kontoId }) { + it[personName] = konto.personName + it[saldoCent] = konto.saldoCent + it[bemerkungen] = konto.bemerkungen + it[updatedAt] = CurrentTimestamp + } + } + return findById(konto.kontoId)!! + } + + override fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long { + TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq kontoId }) { + it[this.saldoCent] = saldoCent + it[updatedAt] = CurrentTimestamp + } + return saldoCent + } + + private fun ResultRow.toModel() = TeilnehmerKonto( + kontoId = this[TeilnehmerKontoTable.id], + veranstaltungId = this[TeilnehmerKontoTable.veranstaltungId], + personId = this[TeilnehmerKontoTable.personId], + personName = this[TeilnehmerKontoTable.personName], + saldoCent = this[TeilnehmerKontoTable.saldoCent], + bemerkungen = this[TeilnehmerKontoTable.bemerkungen], + updatedAt = this[TeilnehmerKontoTable.updatedAt] + ) +} + +@Repository +class ExposedBuchungRepository : BuchungRepository { + + override fun findByKonto(kontoId: Uuid): List { + return BuchungTable + .selectAll() + .where { BuchungTable.kontoId eq kontoId } + .map { it.toModel() } + } + + override fun save(buchung: Buchung): Buchung { + BuchungTable.insert { + it[id] = buchung.buchungId + it[kontoId] = buchung.kontoId + it[betragCent] = buchung.betragCent + it[typ] = buchung.typ.name + it[verwendungszweck] = buchung.verwendungszweck + it[gebuchtAm] = buchung.gebuchtAm + } + return buchung + } + + private fun ResultRow.toModel() = Buchung( + buchungId = this[BuchungTable.id], + kontoId = this[BuchungTable.kontoId], + betragCent = this[BuchungTable.betragCent], + typ = BuchungsTyp.valueOf(this[BuchungTable.typ]), + verwendungszweck = this[BuchungTable.verwendungszweck], + gebuchtAm = this[BuchungTable.gebuchtAm] + ) +} diff --git a/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml b/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml index b0099118..40ce2d63 100644 --- a/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml +++ b/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml @@ -8,15 +8,20 @@ servers: - url: http://localhost:8091 description: Lokaler Entwicklungs-Server paths: - /reiter/search: + /reiter: get: - summary: Sucht Reiter + summary: Alle Reiter abrufen (paginiert) parameters: - - name: q + - name: limit in: query - required: true schema: - type: string + type: integer + default: 100 + - name: offset + in: query + schema: + type: integer + default: 0 responses: '200': description: Liste von Reitern @@ -26,6 +31,450 @@ paths: type: array items: $ref: '#/components/schemas/Reiter' + post: + summary: Neuen Reiter erstellen + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReiterCreateRequest' + responses: + '201': + description: Reiter erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/Reiter' + /reiter/{id}: + get: + summary: Reiter nach ID abrufen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Reiter Details + content: + application/json: + schema: + $ref: '#/components/schemas/Reiter' + '404': + description: Nicht gefunden + put: + summary: Reiter aktualisieren + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReiterUpdateRequest' + responses: + '200': + description: Reiter aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/Reiter' + '404': + description: Nicht gefunden + delete: + summary: Reiter löschen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Erfolgreich gelöscht + '404': + description: Nicht gefunden + /reiter/search: + get: + summary: Sucht Reiter nach Satznummer + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + '200': + description: Liste von Reitern (Satznummer Match) + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Reiter' + /reiter/satznummer/{nr}: + get: + summary: Reiter nach Satznummer suchen + parameters: + - name: nr + in: path + required: true + schema: + type: string + responses: + '200': + description: Reiter gefunden + content: + application/json: + schema: + $ref: '#/components/schemas/Reiter' + '404': + description: Nicht gefunden + /horse: + get: + summary: Alle Pferde abrufen (paginiert) + parameters: + - name: jahrgang + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + default: 100 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + '200': + description: Liste von Pferden + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Horse' + post: + summary: Neues Pferd erstellen + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HorseCreateRequest' + responses: + '201': + description: Pferd erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/Horse' + /horse/{id}: + get: + summary: Pferd nach ID abrufen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Pferd Details + content: + application/json: + schema: + $ref: '#/components/schemas/Horse' + '404': + description: Nicht gefunden + put: + summary: Pferd aktualisieren + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HorseUpdateRequest' + responses: + '200': + description: Pferd aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/Horse' + delete: + summary: Pferd löschen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Gelöscht + /horse/search: + get: + summary: Sucht Pferde nach Lebensnummer + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + '200': + description: Liste von Pferden + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Horse' + /verein: + get: + summary: Alle Vereine abrufen (paginiert) + parameters: + - name: limit + in: query + schema: + type: integer + default: 100 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + '200': + description: Liste von Vereinen + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Verein' + post: + summary: Neuen Verein erstellen + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VereinCreateRequest' + responses: + '201': + description: Verein erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/Verein' + /verein/{id}: + get: + summary: Verein nach ID abrufen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Verein Details + content: + application/json: + schema: + $ref: '#/components/schemas/Verein' + '404': + description: Nicht gefunden + put: + summary: Verein aktualisieren + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VereinUpdateRequest' + responses: + '200': + description: Verein aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/Verein' + delete: + summary: Verein löschen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Gelöscht + /funktionaer: + get: + summary: Alle Funktionäre abrufen (paginiert) + parameters: + - name: limit + in: query + schema: + type: integer + default: 100 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + '200': + description: Liste von Funktionären + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Funktionaer' + post: + summary: Neuen Funktionär erstellen + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FunktionaerCreateRequest' + responses: + '201': + description: Funktionär erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/Funktionaer' + /funktionaer/{id}: + get: + summary: Funktionär nach ID abrufen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Funktionär Details + content: + application/json: + schema: + $ref: '#/components/schemas/Funktionaer' + '404': + description: Nicht gefunden + put: + summary: Funktionär aktualisieren + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FunktionaerUpdateRequest' + responses: + '200': + description: Funktionär aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/Funktionaer' + delete: + summary: Funktionär löschen + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Gelöscht + /funktionaer/search: + get: + summary: Sucht Funktionäre nach SatzNummer + parameters: + - name: q + in: query + required: true + schema: + type: integer + responses: + '200': + description: Liste von Funktionären + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Funktionaer' + /funktionaer/satz/{satzId}/{satzNummer}: + get: + summary: Funktionär nach Satz-ID und Nummer suchen + parameters: + - name: satzId + in: path + required: true + schema: + type: string + - name: satzNummer + in: path + required: true + schema: + type: integer + responses: + '200': + description: Funktionär gefunden + content: + application/json: + schema: + $ref: '#/components/schemas/Funktionaer' + '404': + description: Nicht gefunden /rules/turnierklassen: get: summary: Alle Turnierklassen abrufen @@ -58,9 +507,327 @@ components: reiterId: type: string format: uuid + satznummer: + type: string nachname: type: string vorname: type: string + geburtsdatum: + type: string + format: date + bundeslandNummer: + type: integer + vereinsName: + type: string + nation: + type: string + reiterLizenz: + type: string + startkarte: + type: string + fahrLizenz: + type: string + mitgliedsNummer: + type: integer + telefonNummer: + type: string + lastPayYear: + type: integer + feiId: + type: string + lizenzKlasse: + type: string + istAktiv: + type: boolean + updatedAt: + type: string + format: date-time + ReiterCreateRequest: + type: object + required: + - satznummer + - nachname + - vorname + properties: satznummer: type: string + nachname: + type: string + vorname: + type: string + geburtsdatum: + type: string + format: date + bundeslandNummer: + type: integer + vereinsName: + type: string + nation: + type: string + reiterLizenz: + type: string + startkarte: + type: string + fahrLizenz: + type: string + mitgliedsNummer: + type: integer + telefonNummer: + type: string + lastPayYear: + type: integer + feiId: + type: string + lizenzKlasse: + type: string + istAktiv: + type: boolean + ReiterUpdateRequest: + type: object + properties: + nachname: + type: string + vorname: + type: string + geburtsdatum: + type: string + format: date + bundeslandNummer: + type: integer + vereinsName: + type: string + nation: + type: string + reiterLizenz: + type: string + startkarte: + type: string + fahrLizenz: + type: string + mitgliedsNummer: + type: integer + telefonNummer: + type: string + lastPayYear: + type: integer + feiId: + type: string + lizenzKlasse: + type: string + istAktiv: + type: boolean + Horse: + type: object + properties: + pferdId: + type: string + format: uuid + kopfnummer: + type: string + pferdeName: + type: string + lebensnummer: + type: string + geschlecht: + type: string + geburtsjahr: + type: integer + farbe: + type: string + satznummer: + type: string + istAktiv: + type: boolean + updatedAt: + type: string + format: date-time + HorseCreateRequest: + type: object + required: + - pferdeName + - geschlecht + properties: + kopfnummer: + type: string + pferdeName: + type: string + lebensnummer: + type: string + geschlecht: + type: string + geburtsjahr: + type: integer + farbe: + type: string + satznummer: + type: string + istAktiv: + type: boolean + HorseUpdateRequest: + type: object + properties: + kopfnummer: + type: string + pferdeName: + type: string + lebensnummer: + type: string + geschlecht: + type: string + geburtsjahr: + type: integer + farbe: + type: string + istAktiv: + type: boolean + Verein: + type: object + properties: + vereinId: + type: string + format: uuid + vereinsNummer: + type: string + name: + type: string + bundesland: + type: string + ort: + type: string + plz: + type: string + strasse: + type: string + email: + type: string + telefon: + type: string + website: + type: string + istVeranstalter: + type: boolean + istAktiv: + type: boolean + imageUrl: + type: string + bemerkungen: + type: string + updatedAt: + type: string + format: date-time + VereinCreateRequest: + type: object + required: + - vereinsNummer + - name + properties: + vereinsNummer: + type: string + name: + type: string + bundesland: + type: string + ort: + type: string + plz: + type: string + strasse: + type: string + email: + type: string + telefon: + type: string + website: + type: string + istVeranstalter: + type: boolean + istAktiv: + type: boolean + imageUrl: + type: string + bemerkungen: + type: string + VereinUpdateRequest: + type: object + properties: + name: + type: string + bundesland: + type: string + ort: + type: string + plz: + type: string + strasse: + type: string + email: + type: string + telefon: + type: string + website: + type: string + istVeranstalter: + type: boolean + istAktiv: + type: boolean + imageUrl: + type: string + bemerkungen: + type: string + Funktionaer: + type: object + properties: + funktionaerId: + type: string + format: uuid + satzId: + type: string + satzNummer: + type: integer + name: + type: string + qualifikationen: + type: array + items: + type: string + istAktiv: + type: boolean + bemerkungen: + type: string + updatedAt: + type: string + format: date-time + FunktionaerCreateRequest: + type: object + required: + - satzId + - satzNummer + properties: + satzId: + type: string + satzNummer: + type: integer + name: + type: string + qualifikationen: + type: array + items: + type: string + istAktiv: + type: boolean + bemerkungen: + type: string + FunktionaerUpdateRequest: + type: object + properties: + name: + type: string + qualifikationen: + type: array + items: + type: string + istAktiv: + type: boolean + bemerkungen: + type: string diff --git a/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md b/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md new file mode 100644 index 00000000..d6bf0aeb --- /dev/null +++ b/docs/99_Journal/2026-04-10_Billing-Setup_ZNS-Hardening.md @@ -0,0 +1,38 @@ +--- +type: Journal +status: ACTIVE +owner: Curator +last_update: 2026-04-10 +--- +# Journal Entry: 2026-04-10 - Billing Service Setup & ZNS Importer Hardening + +## 👷 [Backend Developer] / 🏗️ [Lead Architect] / 🧹 [Curator] + +### Zusammenfassung der Session +In dieser Session wurde das Fundament für den Kassa-Service (`billing-context`) gelegt und die Robustheit des ZNS-Importers durch zusätzliche Integrationstests für Funktionäre gesteigert. + +### Wichtigste Ergebnisse +1. **Billing Service Initialisierung:** + * `billing-service` Modul erstellt und konfiguriert. + * Exposed-Tabellendefinitionen für `TeilnehmerKonto` und `Buchung` implementiert. + * Repository-Schnittstellen (Domain) und Exposed-Implementierungen (Service) erstellt. + * `TeilnehmerKontoService` mit Basis-Logik (Kontoerstellung & Buchungen) implementiert. +2. **ZNS-Importer Hardening:** + * Erweiterung von `ZnsImportServiceTest` um Tests für mehrfache Qualifikationen und die Update-Strategie (Delete+Insert) bei Funktionären (`RICHT01.dat`). + * Alle 11 Integrationstests sind erfolgreich durchgelaufen. +3. **Kompilations-Fixes (Billing):** + * `billing-service` auf korrekte Exposed DSL Syntax (`selectAll().where { ... }`) umgestellt. + * Explizite `transaction { ... }` Blöcke in `TeilnehmerKontoService` eingeführt (da `@Transactional` ohne JPA-Starter nicht verfügbar war). + * Typ-Konsistenz für `Instant` (kotlin.time) in `billing-domain` zur Übereinstimmung mit `core-domain` hergestellt. + +### Betroffene Dateien +- `backend/services/billing/` (Neuer SCS-Kontext) +- `backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt` + +### Nächste Schritte +- Implementierung der REST-API für den Billing-Service (OpenAPI-First). +- Integration des Billing-Services in den `entries-context` (z.B. automatische Buchung von Nenngebühren). +- UI-Anbindung im Frontend für Kontenübersicht und manuelle Buchungen. + +--- +*Co-authored-by: Junie * diff --git a/settings.gradle.kts b/settings.gradle.kts index 6107eea2..884f265e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -94,6 +94,11 @@ include(":backend:services:masterdata:masterdata-domain") include(":backend:services:masterdata:masterdata-infrastructure") include(":backend:services:masterdata:masterdata-service") +// --- BILLING (Kassa, Zahlungen & Rechnungen) --- +include(":backend:services:billing:billing-api") +include(":backend:services:billing:billing-domain") +include(":backend:services:billing:billing-service") + // --- PING (Ping Service) --- include(":backend:services:ping:ping-service")