- 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]>
341 lines
9.9 KiB
TypeScript
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.');
|
|
}
|
|
}
|
|
}
|