Files
ApiCore/ApiCore-Backend/src/phenology/phenology.service.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 21:13:23 +02:00

341 lines
9.9 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
PhaseSource,
PhenologyPhase,
PhenologyReport,
Prisma,
SeasonSegment,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
import type { SeasonNote } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
const FUTURE_MESSAGE = 'Das Datum liegt in der Zukunft.';
const NOT_FOUND_MESSAGE = 'Meldung nicht gefunden.';
const REVOKED_MESSAGE = 'Die Meldung wurde schon zurückgezogen.';
export interface PhenologyReportView {
id: string;
apiaryId: string | null;
/** null = the report counts for all apiaries. */
apiaryName: string | null;
phase: PhenologyPhase;
/** YYYY-MM-DD */
date: string;
notes: string | null;
userId: string;
userName: string;
revokedAt: string | null;
createdAt: string;
}
/** The season state of an apiary as the API shows it: never coordinates. */
export interface PhenologyApiaryState {
apiaryId: string;
apiaryName: string;
phase: PhenologyPhase | null;
segment: SeasonSegment;
phaseSource: PhaseSource | null;
reportedOn: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}
/** A report day must exist in the calendar and must not lie after today. */
function assertReportDay(day: string): Date {
let date: Date;
try {
date = dayToDate(day);
} catch {
throw new BadRequestException('Das Datum ist ungültig.');
}
if (day > berlinToday()) {
throw new BadRequestException(FUTURE_MESSAGE);
}
return date;
}
function yearRange(year: number): { gte: Date; lt: Date } {
return {
gte: dayToDate(`${year}-01-01`),
lt: dayToDate(`${year + 1}-01-01`),
};
}
/**
* The plant development reports of the beekeeper (first bloom of Salweide,
* Kirsche, ...). A report changes the season state of its apiary (or of all);
* the recomputation runs from the events (PhenologyEventHandlers).
*/
@Injectable()
export class PhenologyService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly season: SeasonService,
) {}
async create(
dto: CreatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = assertReportDay(dto.date);
const apiaryId = dto.apiaryId ?? null;
return this.prisma.$transaction(async (tx) => {
await this.assertApiaryIn(tx, apiaryId);
const created = await tx.phenologyReport.create({
data: {
phase: dto.phase,
date,
apiaryId,
notes: dto.notes ?? null,
userId,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: created.id,
action: 'CREATE',
userId,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: created.id,
actorUserId: userId,
payload: {
apiaryId: created.apiaryId,
phase: created.phase,
reportedOn: utcDay(created.date),
},
});
return created;
});
}
async update(
id: string,
dto: UpdatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = dto.date === undefined ? undefined : assertReportDay(dto.date);
return this.prisma.$transaction(async (tx) => {
// Lock order: Apiary before PhenologyReport (LOCKABLE_TABLES).
await this.assertApiaryIn(tx, dto.apiaryId ?? null);
const before = await this.lockActive(tx, id);
const next = {
phase: dto.phase ?? before.phase,
date: date ?? before.date,
apiaryId: dto.apiaryId === undefined ? before.apiaryId : dto.apiaryId,
notes: dto.notes === undefined ? before.notes : dto.notes,
};
if (
next.phase === before.phase &&
next.date.getTime() === before.date.getTime() &&
next.apiaryId === before.apiaryId &&
next.notes === before.notes
) {
return before;
}
const updated = await tx.phenologyReport.update({
where: { id },
data: {
phase: dto.phase,
date,
apiaryId: dto.apiaryId,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: updated.apiaryId,
previousApiaryId: before.apiaryId,
phase: updated.phase,
reportedOn: utcDay(updated.date),
},
});
return updated;
});
}
/** Withdraws a report; the apiaries it counted for are recomputed. */
async revoke(id: string, userId: string): Promise<PhenologyReport> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockActive(tx, id);
const revoked = await tx.phenologyReport.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: revoked,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: revoked.apiaryId,
previousApiaryId: revoked.apiaryId,
phase: revoked.phase,
reportedOn: utcDay(revoked.date),
},
});
return revoked;
});
}
/** All reports of a year, withdrawn ones included (with revokedAt). */
async list(year: number): Promise<PhenologyReportView[]> {
const rows = await this.prisma.phenologyReport.findMany({
where: { date: yearRange(year) },
include: {
apiary: { select: { name: true } },
user: { select: { name: true } },
},
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
});
return rows.map((row) => ({
id: row.id,
apiaryId: row.apiaryId,
apiaryName: row.apiary?.name ?? null,
phase: row.phase,
date: utcDay(row.date),
notes: row.notes,
userId: row.userId,
userName: row.user.name,
revokedAt: row.revokedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
}));
}
/** The season state of every active apiary. Never coordinates. */
async state(): Promise<PhenologyApiaryState[]> {
const today = berlinToday();
const apiaries = await this.prisma.apiary.findMany({
where: { status: 'ACTIVE' },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
const states = await this.season.statesFor(
this.prisma,
apiaries.map((apiary) => apiary.id),
today,
);
const reports = await this.prisma.phenologyReport.findMany({
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
select: { apiaryId: true, phase: true, date: true },
});
return apiaries.flatMap((apiary) => {
const state = states.get(apiary.id);
if (state === undefined) {
return [];
}
const reportedOn =
state.phaseSource === 'REPORT'
? (reports
.filter(
(report) =>
report.phase === state.phase &&
(report.apiaryId === null || report.apiaryId === apiary.id),
)
.map((report) => utcDay(report.date))
.sort()[0] ?? null)
: null;
return [
{
apiaryId: apiary.id,
apiaryName: apiary.name,
phase: state.phase,
segment: state.segment,
phaseSource: state.phaseSource,
reportedOn,
gts: state.gts,
gtsThroughDay: state.gtsThroughDay,
note: state.note,
},
];
});
}
/** Locks the report and reads it afterwards: 404 if gone, 409 if withdrawn. */
private async lockActive(
tx: Prisma.TransactionClient,
id: string,
): Promise<PhenologyReport> {
const exists = await lockRowForUpdate(tx, 'PhenologyReport', id);
const before = exists
? await tx.phenologyReport.findUnique({ where: { id } })
: null;
if (before === null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
}
if (before.revokedAt !== null) {
throw new ConflictException(REVOKED_MESSAGE);
}
return before;
}
/**
* 404 for an unknown apiary; the share lock keeps it from being deleted
* before the report that points at it is written.
*/
private async assertApiaryIn(
tx: Prisma.TransactionClient,
apiaryId: string | null,
): Promise<void> {
if (apiaryId === null) {
return;
}
await lockRowsForShare(tx, 'Apiary', [apiaryId]);
const apiary = await tx.apiary.findUnique({
where: { id: apiaryId },
select: { id: true },
});
if (apiary === null) {
throw new NotFoundException('Stand nicht gefunden.');
}
}
}