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]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -0,0 +1,340 @@
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user