Files
ApiCore/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts
T
Nick SzaunigandClaude Opus 5.5 ef33e5549b 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]>
2026-10-14 16:28:07 +02:00

163 lines
5.4 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { formatDayDe } from '../suggestions/texts/format-day';
import {
isInWindow,
withdrawalWindowOf,
} from '../treatments/withdrawal-window';
import { WithdrawalCheckService } from './withdrawal-check.service';
const READY_RECIPIENT_PERMISSIONS = [
'harvests:create',
'treatments:read',
] as const;
/**
* The withdrawal chain: a harvest or a bottling inside a withdrawal window
* leaves one notification (the key is the same at both stages), and the end
* of a window tells that the colony may be harvested again. Pure DB writers
* through the delivery transaction; errors propagate so a delivery is retried.
*/
@Injectable()
export class WithdrawalEventHandlers {
constructor(
private readonly check: WithdrawalCheckService,
private readonly notifications: NotificationsService,
) {}
@OnDomainEvent(
['HarvestRecorded', 'HarvestCorrected'],
'withdrawal.harvest-check',
)
async harvestCheck(
event: AnyDomainEventEnvelope<'HarvestRecorded' | 'HarvestCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const conflicts = await this.check.conflictsForHarvests(tx, [
event.aggregateId,
]);
await this.check.notifyConflictsIn(tx, conflicts);
}
// The other direction: a treatment entered after the harvest (backdated),
// or corrected to longer days, covers harvests that already exist. The key
// is the same as on the harvest path, so nothing is notified twice.
@OnDomainEvent(
['TreatmentRecorded', 'TreatmentCorrected'],
'withdrawal.treatment-check',
)
async treatmentCheck(
event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const conflicts = await this.check.conflictsForTreatment(
tx,
event.aggregateId,
);
await this.check.notifyConflictsIn(tx, conflicts);
}
// The extraction (Schleuderung) joins the check with T4b; until then the
// bottling is the second stage. A removed bottling has nothing to check.
@OnDomainEvent(
['BatchBottled', 'BottlingCorrected'],
'withdrawal.bottling-check',
)
async bottlingCheck(
event: AnyDomainEventEnvelope<'BatchBottled' | 'BottlingCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
if (event.type === 'BottlingCorrected' && event.payload.removed) {
return;
}
const conflicts = await this.check.conflictsForHoneyBatch(
tx,
event.payload.honeyBatchId,
);
await this.check.notifyConflictsIn(tx, conflicts);
}
// The aggregate is the treatment. Quiet when the rule does not notify, when
// the colony is gone, when the treatment changed since the event (the job
// records a new event for its new end day) and while another window of the
// colony still runs: only then is the colony harvestable again.
@OnDomainEvent('WithdrawalEnded', 'notifications.withdrawal-ended')
async withdrawalEnded(
event: AnyDomainEventEnvelope<'WithdrawalEnded'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const rule = await readRule(tx, 'WITHDRAWAL_ENDED');
if (!rule.active || !rule.config.notify) {
return;
}
const treatment = await tx.treatment.findUnique({
where: { id: event.aggregateId },
select: {
id: true,
colonyId: true,
product: true,
date: true,
startDate: true,
endDate: true,
withdrawalPeriodDays: true,
colony: { select: { code: true, status: true } },
},
});
if (treatment === null || !isLivingColonyStatus(treatment.colony.status)) {
return;
}
const endedOn = event.payload.endedOn;
if (withdrawalWindowOf(treatment)?.untilDay !== endedOn) {
return;
}
if (await this.hasOtherRunningWindow(tx, treatment, endedOn)) {
return;
}
await this.notifications.recordForPermission(
[...READY_RECIPIENT_PERMISSIONS],
{
priority: 'INFO',
category: 'WITHDRAWAL',
message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`,
relatedType: 'Colony',
relatedId: treatment.colonyId,
link: `/colonies/${treatment.colonyId}`,
dedupeKeyPrefix: `withdrawal-ended:${treatment.id}:${endedOn}`,
},
tx,
);
}
private async hasOtherRunningWindow(
tx: Prisma.TransactionClient,
treatment: { id: string; colonyId: string },
day: string,
): Promise<boolean> {
const others = await tx.treatment.findMany({
where: {
colonyId: treatment.colonyId,
id: { not: treatment.id },
withdrawalPeriodDays: { not: null },
},
select: {
id: true,
colonyId: true,
product: true,
date: true,
startDate: true,
endDate: true,
withdrawalPeriodDays: true,
},
});
return others.some((other) => {
const window = withdrawalWindowOf(other);
return window !== null && isInWindow(window, day);
});
}
}