feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
edc193f128
commit
b912d29166
54 files changed
+5300
-14
No files matched your search
@@ -43,6 +43,11 @@ interface FakeRows {
|
||||
varroa?: { id: string; colonyId: string; date: Date }[];
|
||||
/** The latest inspection per colony. */
|
||||
inspections?: { colonyId: string; date: Date }[];
|
||||
/**
|
||||
* The ComplianceSettings reference day (MM-TT); null: no settings row.
|
||||
* Without a value a day far from the other tests' days is used.
|
||||
*/
|
||||
tskReferenceDay?: string | null;
|
||||
/** Stored rule configs by trigger type; others read as the built-in setting. */
|
||||
rules?: Record<string, object>;
|
||||
}
|
||||
@@ -87,6 +92,20 @@ function fakeDb(rows: FakeRows): FakeDb {
|
||||
})),
|
||||
),
|
||||
},
|
||||
complianceSettings: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
rows.tskReferenceDay === null
|
||||
? null
|
||||
: {
|
||||
id: 'singleton',
|
||||
tskReferenceDay: rows.tskReferenceDay ?? '10-10',
|
||||
},
|
||||
),
|
||||
// A missing row is created with the schema default.
|
||||
upsert: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'singleton', tskReferenceDay: '01-01' }),
|
||||
},
|
||||
// No stored phenology: every apiary gets the calendar state.
|
||||
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: {
|
||||
@@ -154,9 +173,60 @@ describe('daily evaluation rules', () => {
|
||||
'QueenAged',
|
||||
'VarroaMeasurementDue',
|
||||
'InspectionOverdue',
|
||||
'TskReferenceDayReached',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('TskReferenceDayReached', () => {
|
||||
const only = (events: DailyEvent[]) =>
|
||||
events.filter((event) => event.type === 'TskReferenceDayReached');
|
||||
|
||||
it('emits TskReferenceDayReached on the configured day', async () => {
|
||||
const { db } = fakeDb({ tskReferenceDay: '03-15' });
|
||||
|
||||
expect(only(await eventsOn(db, '2027-03-15'))).toEqual([
|
||||
{
|
||||
type: 'TskReferenceDayReached',
|
||||
aggregateType: 'Compliance',
|
||||
aggregateId: 'tsk:2027',
|
||||
payload: { year: 2027, referenceDay: '2027-03-15' },
|
||||
dedupeKey: 'TskReferenceDayReached:tsk:2027:2027-03-15',
|
||||
},
|
||||
]);
|
||||
expect(only(await eventsOn(db, '2027-03-14'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('catches up within 7 days exactly once', async () => {
|
||||
const { db } = fakeDb({ tskReferenceDay: '03-15' });
|
||||
|
||||
const store = await runDaily(db, daysBetween('2027-03-15', '2027-03-22'));
|
||||
expect(keysOf(store, 'TskReferenceDayReached')).toEqual([
|
||||
'TskReferenceDayReached:tsk:2027:2027-03-15',
|
||||
]);
|
||||
expect(only(await eventsOn(db, '2027-03-23'))).toEqual([]);
|
||||
});
|
||||
|
||||
it("covers the previous year's day in early January", async () => {
|
||||
const { db } = fakeDb({ tskReferenceDay: '12-30' });
|
||||
|
||||
const events = only(await eventsOn(db, '2027-01-03'));
|
||||
|
||||
expect(events.map((event) => event.payload)).toEqual([
|
||||
{ year: 2026, referenceDay: '2026-12-30' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates the settings row with its default 01-01 when it is missing', async () => {
|
||||
const { db } = fakeDb({ tskReferenceDay: null });
|
||||
|
||||
const events = only(await eventsOn(db, '2027-01-04'));
|
||||
|
||||
expect(events.map((event) => event.payload)).toEqual([
|
||||
{ year: 2027, referenceDay: '2027-01-01' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawalEndDay', () => {
|
||||
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
|
||||
expect(
|
||||
@@ -387,7 +457,8 @@ describe('daily evaluation rules', () => {
|
||||
});
|
||||
|
||||
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
|
||||
const { db, colonyFindMany } = fakeDb({});
|
||||
// The TSK day lies outside every window this test runs.
|
||||
const { db, colonyFindMany } = fakeDb({ tskReferenceDay: '03-03' });
|
||||
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
|
||||
let assigned = false;
|
||||
colonyFindMany.mockImplementation(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { TaskStatus, Treatment } from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { complianceSettingsIn } from '../compliance/herd-status';
|
||||
import { tskReferenceDayOf } from '../compliance/tsk-day';
|
||||
import { addDays, berlinDayOf } from '../common/dates';
|
||||
import {
|
||||
AnyNewDomainEvent,
|
||||
@@ -26,7 +28,8 @@ export type DailyEventType =
|
||||
| 'TaskDue'
|
||||
| 'QueenAged'
|
||||
| 'VarroaMeasurementDue'
|
||||
| 'InspectionOverdue';
|
||||
| 'InspectionOverdue'
|
||||
| 'TskReferenceDayReached';
|
||||
|
||||
/** An event as the job records it: always with its daily dedupe key. */
|
||||
export type DailyEvent<T extends DailyEventType = DailyEventType> =
|
||||
@@ -381,6 +384,32 @@ const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The TSK reference day (ComplianceSettings.tskReferenceDay, MM-TT) of the
|
||||
* run's year and of the year before: in early January the previous year's
|
||||
* day can still lie inside the catch-up window. One event per year.
|
||||
*/
|
||||
const tskReferenceDay: DailyRule<'TskReferenceDayReached'> = {
|
||||
type: 'TskReferenceDayReached',
|
||||
async occurrences(db, _today, { day }) {
|
||||
// The seeded singleton; a missing row is created with the schema default.
|
||||
const { tskReferenceDay: monthDay } = await complianceSettingsIn(db);
|
||||
const runYear = Number(day.slice(0, 4));
|
||||
return [runYear - 1, runYear].map((year) => {
|
||||
const referenceDay = tskReferenceDayOf(year, monthDay);
|
||||
return {
|
||||
day: referenceDay,
|
||||
event: {
|
||||
type: 'TskReferenceDayReached',
|
||||
aggregateType: 'Compliance',
|
||||
aggregateId: `tsk:${year}`,
|
||||
payload: { year, referenceDay },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** The rules of the daily evaluation, one row per time-based event type. */
|
||||
export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
|
||||
withdrawalEnded,
|
||||
@@ -388,4 +417,5 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
|
||||
queenAged,
|
||||
varroaDue,
|
||||
inspectionOverdue,
|
||||
tskReferenceDay,
|
||||
];
|
||||
@@ -50,6 +50,12 @@ describe('TimeEventsStep', () => {
|
||||
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
// A reference day far from the days of these tests.
|
||||
complianceSettings: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'singleton', tskReferenceDay: '10-10' }),
|
||||
},
|
||||
} as unknown as PrismaService;
|
||||
step = new TimeEventsStep(
|
||||
prisma,
|
||||
@@ -73,6 +79,7 @@ describe('TimeEventsStep', () => {
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
TskReferenceDayReached: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +119,7 @@ describe('TimeEventsStep', () => {
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
TskReferenceDayReached: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,6 +159,7 @@ describe('TimeEventsStep', () => {
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
TskReferenceDayReached: 0,
|
||||
},
|
||||
});
|
||||
expect(recordAll).toHaveBeenCalledWith(tx, [
|
||||
|
||||
Reference in new issue
Block a user