feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -14,7 +14,11 @@ import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
import { releaseQueenIn } from './colony-lifecycle';
import {
isLivingColonyStatus,
TERMINAL_COLONY_STATUSES,
} from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
@@ -129,6 +133,16 @@ export class ColoniesService {
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
// An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony =
dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
isLivingColonyStatus(before.status);
const releasedQueenId = endsColony
? await releaseQueenIn(tx, id, dayToDate(today))
: null;
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
@@ -148,7 +162,11 @@ export class ColoniesService {
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'EDIT',
}),
);
await this.auditLog.record(
{
@@ -279,6 +297,7 @@ export class ColoniesService {
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
);
}
const today = berlinToday();
await this.prisma.$transaction(async (tx) => {
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
@@ -299,28 +318,23 @@ export class ColoniesService {
// The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
dayToDate(today),
);
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
data: {
status: 'MERGED',
deactivatedAt: new Date(),
currentQueenId: null,
},
data: { status: 'MERGED', deactivatedAt: new Date() },
});
await this.events.recordAll(
tx,
colonyTransitionEvents(merged, mergedAway, actingUserId),
colonyTransitionEvents(merged, mergedAway, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'MERGE',
}),
);
if (merged.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: merged.currentQueenId,
colonyId: mergedColonyId,
endDate: null,
},
data: { endDate: new Date() },
});
}
await tx.colonyLineage.create({
data: {
type: 'MERGE',
@@ -353,27 +367,19 @@ export class ColoniesService {
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
const today = berlinToday();
const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today));
const updated = await tx.colony.update({
where: { id },
data: {
status: 'DISSOLVED',
deactivatedAt: new Date(),
currentQueenId: null,
},
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
});
if (before.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: before.currentQueenId,
colonyId: id,
endDate: null,
},
data: { endDate: new Date() },
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'DISSOLVE',
}),
);
await this.auditLog.record(
{
@@ -404,9 +410,15 @@ export class ColoniesService {
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
// The clean-up of the end is moot now (colony row locked first, then
// the suggestions: the order every writer uses).
await tx.suggestion.updateMany({
where: { colonyId: id, intent: 'CLEANUP', status: 'OPEN' },
data: { status: 'OBSOLETE', decidedAt: new Date() },
});
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
colonyTransitionEvents(before, updated, actingUserId, null),
);
await this.auditLog.record(
{