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
@@ -3,6 +3,8 @@ import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||
import { NextStepsService } from '../src/autopilot/next-steps.service';
|
||||
import { berlinToday } from '../src/common/dates';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
@@ -28,6 +30,8 @@ describe('Autopilot (e2e)', () => {
|
||||
const colonyIds: string[] = [];
|
||||
const roleIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
const seasonApiaryIds: string[] = [];
|
||||
const winterCheckIds: string[] = [];
|
||||
|
||||
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
|
||||
async function colonyWithLateInspection(name: string): Promise<string> {
|
||||
@@ -88,11 +92,25 @@ describe('Autopilot (e2e)', () => {
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: inspections.map((i) => i.id) } },
|
||||
where: {
|
||||
aggregateId: {
|
||||
in: [
|
||||
...inspections.map((i) => i.id),
|
||||
...seasonApiaryIds,
|
||||
...winterCheckIds,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.winterCheck.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.feeding.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.inspection.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
@@ -103,7 +121,9 @@ describe('Autopilot (e2e)', () => {
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||
await prisma.apiary.deleteMany({
|
||||
where: { id: { in: [apiaryId, ...seasonApiaryIds] } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
@@ -406,4 +426,329 @@ describe('Autopilot (e2e)', () => {
|
||||
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
|
||||
expect(hidden.body).toEqual([]);
|
||||
});
|
||||
|
||||
/** The stored season of the e2e apiary in 2001: KIRSCHE by report. */
|
||||
async function reportKirsche(forApiaryId = apiaryId): Promise<void> {
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId: forApiaryId,
|
||||
year: 2001,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
reportedPhase: 'KIRSCHE',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2001-04-20'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dropSeason = () =>
|
||||
prisma.apiaryPhenology.deleteMany({ where: { apiaryId, year: 2001 } });
|
||||
|
||||
it('swarm season by report, last inspection 9 days ago -> step 1 INSPECT overdue', async () => {
|
||||
await reportKirsche();
|
||||
try {
|
||||
const colonyId = await colonyWithLateInspection('report');
|
||||
|
||||
await sync(colonyId);
|
||||
|
||||
const view = await app
|
||||
.get(NextStepsService)
|
||||
.forColony(colonyId, userId, TODAY);
|
||||
expect(view).toMatchObject({
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'REPORT',
|
||||
});
|
||||
expect(view.steps[0]).toMatchObject({
|
||||
intent: 'INSPECT',
|
||||
urgency: 'OVERDUE',
|
||||
dueOn: '2001-05-10',
|
||||
});
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('A7: KIRSCHE and 9 occupied frames -> a HONEY_SUPER suggestion; 7 frames -> none', async () => {
|
||||
await reportKirsche();
|
||||
try {
|
||||
const strong = await colonyWithLateInspection('strong');
|
||||
const weak = await colonyWithLateInspection('weakframes');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId: strong },
|
||||
data: { colonyStrength: 9 },
|
||||
});
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId: weak },
|
||||
data: { colonyStrength: 7 },
|
||||
});
|
||||
|
||||
await sync(strong);
|
||||
await sync(weak);
|
||||
|
||||
const [superSuggestion] = await suggestionsOf(strong, 'HONEY_SUPER');
|
||||
expect(superSuggestion).toMatchObject({
|
||||
status: 'OPEN',
|
||||
mode: 'DO',
|
||||
reasonCode: 'HONEY_SUPER_CHECK',
|
||||
requiredPermission: 'inspections:read',
|
||||
dedupeKey: `SEASON_AUTOPILOT:${strong}:2001`,
|
||||
});
|
||||
expect(await suggestionsOf(weak, 'HONEY_SUPER')).toHaveLength(0);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('never more than 3 steps per colony', async () => {
|
||||
await reportKirsche();
|
||||
try {
|
||||
const colonyId = await colonyWithLateInspection('fourway');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId },
|
||||
data: { colonyStrength: 9, feedStatus: 'WEAK' },
|
||||
});
|
||||
await prisma.varroaMeasurement.updateMany({
|
||||
where: { colonyId },
|
||||
data: { date: d('2001-04-01') },
|
||||
});
|
||||
|
||||
await sync(colonyId);
|
||||
|
||||
const open = await prisma.suggestion.findMany({
|
||||
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
|
||||
select: { intent: true },
|
||||
});
|
||||
expect(open.map((s) => s.intent).sort()).toEqual([
|
||||
'FEED',
|
||||
'HONEY_SUPER',
|
||||
'INSPECT',
|
||||
'VARROA_MEASURE',
|
||||
]);
|
||||
const view = await app
|
||||
.get(NextStepsService)
|
||||
.forColony(colonyId, userId, TODAY);
|
||||
expect(view.steps).toHaveLength(3);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('a SeasonPhaseChanged syncs every living colony of the apiary', async () => {
|
||||
const year = Number(berlinToday().slice(0, 4));
|
||||
const own = await prisma.apiary.create({
|
||||
data: { name: `Autopilot e2e season ${suffix}` },
|
||||
});
|
||||
const other = await prisma.apiary.create({
|
||||
data: { name: `Autopilot e2e other ${suffix}` },
|
||||
});
|
||||
seasonApiaryIds.push(own.id, other.id);
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId: own.id,
|
||||
year,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d(`${year}-01-01`),
|
||||
},
|
||||
});
|
||||
const colonyIn = async (name: string, apiary: string, status = 'ACTIVE') =>
|
||||
(
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `AP-E2E-${suffix}-${name}`,
|
||||
apiaryId: apiary,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status: status as 'ACTIVE',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
const first = await colonyIn('season-1', own.id);
|
||||
const second = await colonyIn('season-2', own.id);
|
||||
const ended = await colonyIn('season-ended', own.id, 'DECEASED');
|
||||
const elsewhere = await colonyIn('season-other', other.id);
|
||||
colonyIds.push(first, second, ended, elsewhere);
|
||||
await prisma.$transaction((tx) =>
|
||||
app.get(DomainEventsService).record(tx, {
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: own.id,
|
||||
payload: {
|
||||
apiaryId: own.id,
|
||||
phase: 'HERBST',
|
||||
segment: 'HERBST',
|
||||
previousPhase: 'TRACHTENDE',
|
||||
previousSegment: 'SPAETSOMMER',
|
||||
source: 'REPORT',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await drainDomainEvents(app, [own.id]);
|
||||
|
||||
for (const colonyId of [first, second]) {
|
||||
const [winter] = await suggestionsOf(colonyId, 'WINTER_CHECK');
|
||||
expect(winter).toMatchObject({
|
||||
status: 'OPEN',
|
||||
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}-AUTUMN`,
|
||||
});
|
||||
}
|
||||
expect(await suggestionsOf(ended, 'WINTER_CHECK')).toHaveLength(0);
|
||||
expect(await suggestionsOf(elsewhere, 'WINTER_CHECK')).toHaveLength(0);
|
||||
for (const colonyId of [first, second]) {
|
||||
const [feed] = await suggestionsOf(colonyId, 'FEED');
|
||||
expect(feed).toMatchObject({
|
||||
status: 'OPEN',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}`,
|
||||
});
|
||||
}
|
||||
|
||||
// The wintering-in entry resolves A9 right after it is recorded.
|
||||
const winterCheck = await prisma.winterCheck.create({
|
||||
data: {
|
||||
colonyId: first,
|
||||
userId,
|
||||
winterYear: year,
|
||||
season: 'AUTUMN',
|
||||
date: new Date(),
|
||||
},
|
||||
});
|
||||
winterCheckIds.push(winterCheck.id);
|
||||
await prisma.$transaction((tx) =>
|
||||
app.get(DomainEventsService).record(tx, {
|
||||
type: 'WinterCheckRecorded',
|
||||
aggregateType: 'WinterCheck',
|
||||
aggregateId: winterCheck.id,
|
||||
payload: { colonyId: first },
|
||||
}),
|
||||
);
|
||||
await drainDomainEvents(app, [winterCheck.id]);
|
||||
|
||||
const [resolved] = await suggestionsOf(first, 'WINTER_CHECK');
|
||||
expect(resolved.status).toBe('RESOLVED');
|
||||
const [stillOpen] = await suggestionsOf(second, 'WINTER_CHECK');
|
||||
expect(stillOpen.status).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('an apiary-wide sync and single-colony syncs running together finish without a deadlock', async () => {
|
||||
const year = Number(berlinToday().slice(0, 4));
|
||||
const crowded = await prisma.apiary.create({
|
||||
data: { name: `Autopilot e2e crowd ${suffix}` },
|
||||
});
|
||||
seasonApiaryIds.push(crowded.id);
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId: crowded.id,
|
||||
year,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d(`${year}-01-01`),
|
||||
},
|
||||
});
|
||||
const ids: string[] = [];
|
||||
for (let n = 0; n < 5; n += 1) {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `AP-E2E-${suffix}-crowd-${n}`,
|
||||
apiaryId: crowded.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
ids.push(colony.id);
|
||||
colonyIds.push(colony.id);
|
||||
}
|
||||
const today = berlinToday();
|
||||
|
||||
await Promise.all([
|
||||
prisma.$transaction((tx) =>
|
||||
autopilot.syncApiaryIn(tx, crowded.id, today),
|
||||
),
|
||||
...[...ids]
|
||||
.reverse()
|
||||
.map((id) =>
|
||||
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, id, today)),
|
||||
),
|
||||
prisma.$transaction((tx) =>
|
||||
autopilot.syncApiaryIn(tx, crowded.id, today),
|
||||
),
|
||||
]);
|
||||
|
||||
for (const id of ids) {
|
||||
expect(await suggestionsOf(id, 'WINTER_CHECK')).toHaveLength(1);
|
||||
expect(await suggestionsOf(id, 'FEED')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('HERBST approximation: feedings count from the calendar day of TRACHTENDE (08-01), not from the real start of SPAETSOMMER', async () => {
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
year: 2001,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d('2001-09-15'),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const feedAt = async (name: string, day: string) => {
|
||||
const colonyId = await colonyWithLateInspection(name);
|
||||
await prisma.feeding.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId,
|
||||
date: d(day),
|
||||
feedType: 'SUGAR_WATER',
|
||||
amount: 5,
|
||||
unit: 'L',
|
||||
},
|
||||
});
|
||||
await sync(colonyId, '2001-09-20');
|
||||
return colonyId;
|
||||
};
|
||||
const before = await feedAt('fed-before', '2001-07-30');
|
||||
const after = await feedAt('fed-after', '2001-08-05');
|
||||
|
||||
expect(await suggestionsOf(before, 'FEED')).toHaveLength(1);
|
||||
expect(await suggestionsOf(after, 'FEED')).toHaveLength(0);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
|
||||
it('A5 open and an A6 candidate present -> still one FEED suggestion', async () => {
|
||||
await prisma.apiaryPhenology.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
year: 2001,
|
||||
effectivePhase: 'HERBST',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'HERBST',
|
||||
segmentSince: d('2001-09-15'),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const colonyId = await colonyWithLateInspection('feedboth');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId },
|
||||
data: { feedStatus: 'WEAK' },
|
||||
});
|
||||
|
||||
await sync(colonyId, '2001-09-20');
|
||||
await sync(colonyId, '2001-09-21');
|
||||
|
||||
const open = await prisma.suggestion.findMany({
|
||||
where: { colonyId, intent: 'FEED', status: 'OPEN' },
|
||||
});
|
||||
expect(open).toHaveLength(1);
|
||||
} finally {
|
||||
await dropSeason();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user