feat: Königinnen-Abstammungsbaum (Mutterlinie)

Neue Funktion jenseits der Lastenheft-Checkliste, explizit im Text als
spätere Erweiterung angedacht (§4.4: "ApiCore SOLL später
Abstammungsbäume darstellen können"). Königin.motherQueenId ist eine
neue optionale Selbstreferenz (additive Migration), zusätzlich zu den
bestehenden freien Textfeldern motherLine/fatherLine – letztere
bleiben für Fälle, in denen die Mutter nicht selbst in ApiCore erfasst
ist. Die Vaterlinie bleibt bewusst Freitext, da eine Königin mit vielen
Drohnen aus unterschiedlichen Völkern begattet wird und es keine
einzelne "Vater-Königin" gibt, die referenzierbar wäre.

Backend: GET /queens/:id/lineage baut die mütterliche Ahnenkette
(rekursiv über motherQueenId, Tiefe 8) sowie den Töchterbaum
(rekursiv über die neue daughterQueens-Relation) in einer Antwort;
create/update lehnen eine nicht existierende oder auf sich selbst
zeigende motherQueenId ab.

Frontend: neue "Abstammungsbaum"-Karte auf der Königin-Detailseite
zeigt die mütterliche Linie als Liste sowie die Töchter als
eingerückten Baum, beide mit Links zu den jeweiligen Königinnen.
Anlage- und Bearbeiten-Formular bekommen ein "Mutter"-Auswahlfeld
(Bearbeiten schließt die Königin selbst aus der Liste aus).

Live in Chrome verifiziert: dreistufige Kette (Großmutter → Mutter →
Tochter) korrekt in beide Richtungen dargestellt; dabei einen echten
Anzeigefehler gefunden und behoben – das Mutter-Auswahlfeld zeigte
nach dem Speichern die rohe Datenbank-ID statt des Königin-Codes an
(derselbe SelectValue-ohne-children-Fehler wie zuvor bei der
Import-Entitätsauswahl).
This commit is contained in:
Nick Szaunig committed 2026-08-25 05:13:45 +02:00
1 parent 68f459001a
commit e458a7cf07
14 files changed
+475 -10

No files matched your search

+103 -1
View File
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
@@ -12,6 +16,29 @@ export interface QueenFilter {
status?: QueenStatus;
}
export interface QueenLineageAncestor {
id: string;
code: string;
hatchYear: number | null;
status: QueenStatus;
mother: QueenLineageAncestor | null;
}
export interface QueenLineageDescendant {
id: string;
code: string;
hatchYear: number | null;
status: QueenStatus;
daughters: QueenLineageDescendant[];
}
export interface QueenLineage {
queen: QueenLineageAncestor;
descendants: QueenLineageDescendant[];
}
const MAX_LINEAGE_DEPTH = 8;
@Injectable()
export class QueensService {
constructor(
@@ -36,6 +63,9 @@ export class QueensService {
}
async create(dto: CreateQueenDto, actingUserId: string): Promise<Queen> {
if (dto.motherQueenId) {
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Queen',
@@ -54,6 +84,12 @@ export class QueensService {
actingUserId: string,
): Promise<Queen> {
const before = await this.findById(id);
if (dto.motherQueenId) {
if (dto.motherQueenId === id) {
throw new BadRequestException('A queen cannot be her own mother');
}
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.update({
where: { id },
data: { ...dto },
@@ -171,4 +207,70 @@ export class QueensService {
});
return evaluation;
}
async getLineage(id: string): Promise<QueenLineage> {
const queen = await this.findById(id);
const ancestorChain = await this.buildAncestorChain(
queen,
MAX_LINEAGE_DEPTH,
);
const descendants = await this.buildDescendantTree(id, MAX_LINEAGE_DEPTH);
return { queen: ancestorChain, descendants };
}
private async assertMotherQueenExists(motherQueenId: string): Promise<void> {
const mother = await this.prisma.queen.findUnique({
where: { id: motherQueenId },
});
if (!mother) {
throw new NotFoundException('Mother queen not found');
}
}
private async buildAncestorChain(
queen: Queen,
depthRemaining: number,
): Promise<QueenLineageAncestor> {
const node: QueenLineageAncestor = {
id: queen.id,
code: queen.code,
hatchYear: queen.hatchYear,
status: queen.status,
mother: null,
};
if (queen.motherQueenId && depthRemaining > 0) {
const mother = await this.prisma.queen.findUnique({
where: { id: queen.motherQueenId },
});
if (mother) {
node.mother = await this.buildAncestorChain(mother, depthRemaining - 1);
}
}
return node;
}
private async buildDescendantTree(
queenId: string,
depthRemaining: number,
): Promise<QueenLineageDescendant[]> {
if (depthRemaining <= 0) {
return [];
}
const daughters = await this.prisma.queen.findMany({
where: { motherQueenId: queenId },
orderBy: { code: 'asc' },
});
return Promise.all(
daughters.map(async (daughter) => ({
id: daughter.id,
code: daughter.code,
hatchYear: daughter.hatchYear,
status: daughter.status,
daughters: await this.buildDescendantTree(
daughter.id,
depthRemaining - 1,
),
})),
);
}
}