fix: T4a browser check findings
- Withdrawal wording from one backend helper everywhere ("bis
einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
crosses New Year); withdrawal-status returns lastBlockedDay and the
ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
colony codes; collapsible automation rule cards; varroa list newest
first with chart axes; text fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
32c61c8e14
commit
c2f71bcb03
101 files changed
+2189
-271
No files matched your search
@@ -699,7 +699,8 @@ describe('BottlingService', () => {
|
||||
const warning: PlausibilityWarning = {
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: 'B3: Wartezeit nach Oxuvar endet am 20.07.2026.',
|
||||
message:
|
||||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
|
||||
refs: [{ type: 'Colony', id: 'c1' }],
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
describe('CalendarService', () => {
|
||||
// The texts add the year outside the current one: pin 'today'.
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
let service: CalendarService;
|
||||
let prisma: {
|
||||
task: { findMany: jest.Mock };
|
||||
@@ -11,6 +19,8 @@ describe('CalendarService', () => {
|
||||
feeding: { findMany: jest.Mock };
|
||||
harvest: { findMany: jest.Mock };
|
||||
queenColonyHistory: { findMany: jest.Mock };
|
||||
colony: { findMany: jest.Mock };
|
||||
apiary: { findMany: jest.Mock };
|
||||
};
|
||||
|
||||
let resolver: { getPermissions: jest.Mock };
|
||||
@@ -24,6 +34,8 @@ describe('CalendarService', () => {
|
||||
feeding: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
harvest: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new CalendarService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -44,7 +56,12 @@ describe('CalendarService', () => {
|
||||
},
|
||||
]);
|
||||
prisma.harvest.findMany.mockResolvedValueOnce([
|
||||
{ id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' },
|
||||
{
|
||||
id: 'h1',
|
||||
date: new Date('2026-06-10'),
|
||||
apiaryId: 'a1',
|
||||
apiary: { name: 'Teststand Nord' },
|
||||
},
|
||||
]);
|
||||
|
||||
const events = await service.getEvents(
|
||||
@@ -62,6 +79,73 @@ describe('CalendarService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the colony or apiary of every event in context', async () => {
|
||||
prisma.task.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Füttern',
|
||||
dueDate: new Date('2026-06-05'),
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'c1',
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
title: 'Stand putzen',
|
||||
dueDate: new Date('2026-06-06'),
|
||||
relatedType: 'APIARY',
|
||||
relatedId: 'a1',
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
title: 'Allgemein',
|
||||
dueDate: new Date('2026-06-07'),
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: null,
|
||||
},
|
||||
]);
|
||||
prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-001' }]);
|
||||
prisma.apiary.findMany.mockResolvedValueOnce([
|
||||
{ id: 'a1', name: 'Teststand Nord' },
|
||||
]);
|
||||
prisma.inspection.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'i1',
|
||||
date: new Date('2026-06-01'),
|
||||
colonyId: 'c1',
|
||||
colony: { code: 'V-001' },
|
||||
},
|
||||
]);
|
||||
prisma.feeding.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'f1',
|
||||
date: new Date('2026-06-02'),
|
||||
colonyId: 'c1',
|
||||
colony: { code: 'V-001' },
|
||||
},
|
||||
]);
|
||||
prisma.harvest.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'h1',
|
||||
date: new Date('2026-06-10'),
|
||||
apiaryId: 'a1',
|
||||
apiary: { name: 'Teststand Nord' },
|
||||
},
|
||||
]);
|
||||
|
||||
const events = await service.getEvents(
|
||||
{ from: new Date('2026-06-01'), to: new Date('2026-06-30') },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const contextOf = (id: string) => events.find((e) => e.id === id)?.context;
|
||||
expect(contextOf('i1')).toBe('V-001');
|
||||
expect(contextOf('f1')).toBe('V-001');
|
||||
expect(contextOf('t1')).toBe('V-001');
|
||||
expect(contextOf('t2')).toBe('Teststand Nord');
|
||||
expect(contextOf('t3')).toBeUndefined();
|
||||
expect(contextOf('h1')).toBe('Teststand Nord');
|
||||
});
|
||||
|
||||
it('filters colony-linked events by apiary when apiaryId is given', async () => {
|
||||
await service.getEvents(
|
||||
{
|
||||
@@ -135,6 +219,9 @@ describe('CalendarService', () => {
|
||||
title: 'Wartezeit V-001 · Oxuvar',
|
||||
relatedType: 'Colony',
|
||||
relatedId: 'c1',
|
||||
context: 'V-001',
|
||||
product: 'Oxuvar',
|
||||
detail: 'bis einschließlich 29.06. – ab 30.06. wieder erntefähig',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
|
||||
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
|
||||
import {
|
||||
lastBlockedDay,
|
||||
withdrawalRangeText,
|
||||
} from '../withdrawal/withdrawal-text';
|
||||
|
||||
type TreatmentWithColony = Prisma.TreatmentGetPayload<{
|
||||
include: { colony: true };
|
||||
@@ -25,6 +29,12 @@ export interface CalendarEvent {
|
||||
/** WITHDRAWAL only: the last day the span covers (inclusive). */
|
||||
endDate?: Date;
|
||||
title: string;
|
||||
/** Colony code or apiary name the event belongs to; absent for general tasks. */
|
||||
context?: string;
|
||||
/** WITHDRAWAL only: the treatment product. */
|
||||
product?: string;
|
||||
/** WITHDRAWAL only: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
|
||||
detail?: string;
|
||||
relatedType: string;
|
||||
relatedId: string;
|
||||
}
|
||||
@@ -71,7 +81,7 @@ export class CalendarService {
|
||||
if (span === null) {
|
||||
return [];
|
||||
}
|
||||
const lastDay = addDays(span.untilDay, -1);
|
||||
const lastDay = lastBlockedDay(span.untilDay);
|
||||
if (span.fromDay > toDay || lastDay < fromDay) {
|
||||
return [];
|
||||
}
|
||||
@@ -82,6 +92,9 @@ export class CalendarService {
|
||||
date: dayToDate(span.fromDay),
|
||||
endDate: dayToDate(lastDay),
|
||||
title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`,
|
||||
context: treatment.colony.code,
|
||||
product: treatment.product,
|
||||
detail: withdrawalRangeText(span.untilDay),
|
||||
relatedType: 'Colony',
|
||||
relatedId: treatment.colonyId,
|
||||
},
|
||||
@@ -89,6 +102,60 @@ export class CalendarService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Colony code or apiary name of the tasks that point at one. By ruling only
|
||||
* COLONY and APIARY tasks get a context; other tasks (queen, honey batch,
|
||||
* storage, general) have none.
|
||||
*/
|
||||
private async taskContexts(
|
||||
tasks: readonly {
|
||||
id: string;
|
||||
relatedType: string;
|
||||
relatedId: string | null;
|
||||
}[],
|
||||
): Promise<Map<string, string>> {
|
||||
const idsOf = (type: string) => [
|
||||
...new Set(
|
||||
tasks.flatMap((task) =>
|
||||
task.relatedType === type && task.relatedId !== null
|
||||
? [task.relatedId]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
];
|
||||
const colonyIds = idsOf('COLONY');
|
||||
const apiaryIds = idsOf('APIARY');
|
||||
const noColonies: { id: string; code: string }[] = [];
|
||||
const noApiaries: { id: string; name: string }[] = [];
|
||||
const [colonies, apiaries] = await Promise.all([
|
||||
colonyIds.length === 0
|
||||
? Promise.resolve(noColonies)
|
||||
: this.prisma.colony.findMany({
|
||||
where: { id: { in: colonyIds } },
|
||||
select: { id: true, code: true },
|
||||
}),
|
||||
apiaryIds.length === 0
|
||||
? Promise.resolve(noApiaries)
|
||||
: this.prisma.apiary.findMany({
|
||||
where: { id: { in: apiaryIds } },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
]);
|
||||
const names = new Map<string, string>([
|
||||
...colonies.map((c) => [c.id, c.code] as const),
|
||||
...apiaries.map((a) => [a.id, a.name] as const),
|
||||
]);
|
||||
const contexts = new Map<string, string>();
|
||||
for (const task of tasks) {
|
||||
const name =
|
||||
task.relatedId === null ? undefined : names.get(task.relatedId);
|
||||
if (name !== undefined) {
|
||||
contexts.set(task.id, name);
|
||||
}
|
||||
}
|
||||
return contexts;
|
||||
}
|
||||
|
||||
async getEvents(
|
||||
query: CalendarQuery,
|
||||
userId: string,
|
||||
@@ -132,6 +199,7 @@ export class CalendarService {
|
||||
date: { gte: from, lte: to },
|
||||
...(apiaryId ? { apiaryId } : {}),
|
||||
},
|
||||
include: { apiary: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.queenColonyHistory.findMany({
|
||||
where: { startDate: { gte: from, lte: to }, ...colonyFilter },
|
||||
@@ -140,6 +208,8 @@ export class CalendarService {
|
||||
this.withdrawalEvents(query, canReadTreatments),
|
||||
]);
|
||||
|
||||
const taskContexts = await this.taskContexts(tasks);
|
||||
|
||||
const events: CalendarEvent[] = [
|
||||
...tasks
|
||||
.filter((task) => task.dueDate !== null)
|
||||
@@ -148,6 +218,7 @@ export class CalendarService {
|
||||
type: 'TASK' as const,
|
||||
date: task.dueDate!,
|
||||
title: task.title,
|
||||
context: taskContexts.get(task.id),
|
||||
relatedType: 'Task',
|
||||
relatedId: task.id,
|
||||
})),
|
||||
@@ -156,6 +227,7 @@ export class CalendarService {
|
||||
type: 'INSPECTION' as const,
|
||||
date: inspection.date,
|
||||
title: `Durchsicht ${inspection.colony.code}`,
|
||||
context: inspection.colony.code,
|
||||
relatedType: 'Colony',
|
||||
relatedId: inspection.colonyId,
|
||||
})),
|
||||
@@ -164,6 +236,7 @@ export class CalendarService {
|
||||
type: 'TREATMENT' as const,
|
||||
date: treatment.date,
|
||||
title: `Behandlung ${treatment.colony.code} · ${treatment.product}`,
|
||||
context: treatment.colony.code,
|
||||
relatedType: 'Colony',
|
||||
relatedId: treatment.colonyId,
|
||||
})),
|
||||
@@ -172,6 +245,7 @@ export class CalendarService {
|
||||
type: 'FEEDING' as const,
|
||||
date: feeding.date,
|
||||
title: `Fütterung ${feeding.colony.code}`,
|
||||
context: feeding.colony.code,
|
||||
relatedType: 'Colony',
|
||||
relatedId: feeding.colonyId,
|
||||
})),
|
||||
@@ -180,6 +254,7 @@ export class CalendarService {
|
||||
type: 'HARVEST' as const,
|
||||
date: harvest.date,
|
||||
title: 'Ernte',
|
||||
context: harvest.apiary.name,
|
||||
relatedType: 'Apiary',
|
||||
relatedId: harvest.apiaryId,
|
||||
})),
|
||||
@@ -188,6 +263,7 @@ export class CalendarService {
|
||||
type: 'QUEEN_ASSIGNMENT' as const,
|
||||
date: assignment.startDate,
|
||||
title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`,
|
||||
context: assignment.colony.code,
|
||||
relatedType: 'Queen',
|
||||
relatedId: assignment.queenId,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { endSentence, formatDayDe } from './format-day';
|
||||
|
||||
describe('formatDayDe', () => {
|
||||
it('formats a day without and with the year', () => {
|
||||
expect(formatDayDe('2026-05-05')).toBe('05.05.');
|
||||
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('endSentence', () => {
|
||||
it('adds the period when the text has none', () => {
|
||||
expect(endSentence('Weiselzellen gefunden')).toBe('Weiselzellen gefunden.');
|
||||
});
|
||||
|
||||
it('does not double the period a formatted day already ends with', () => {
|
||||
expect(endSentence(`Ableger von V-1 am ${formatDayDe('2026-10-13')}`)).toBe(
|
||||
'Ableger von V-1 am 13.10.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a text that already ends with a period', () => {
|
||||
expect(endSentence('Fertig.')).toBe('Fertig.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
|
||||
export function formatDayDe(day: string, withYear = false): string {
|
||||
const [year, month, date] = day.split('-');
|
||||
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a sentence with one period. A day formatted by formatDayDe ends with
|
||||
* its own dot ('13.10.'), which then is the end of the sentence.
|
||||
*/
|
||||
export function endSentence(text: string): string {
|
||||
return text.endsWith('.') ? text : `${text}.`;
|
||||
}
|
||||
@@ -23,7 +23,8 @@ describe('plausibility warnings', () => {
|
||||
const warning: PlausibilityWarning = {
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
|
||||
message:
|
||||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||||
refs: [{ type: 'Colony', id: 'c1' }],
|
||||
};
|
||||
|
||||
|
||||
@@ -260,7 +260,8 @@ describe('HarvestsService', () => {
|
||||
const warning: PlausibilityWarning = {
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: 'V-1: Wartezeit nach Oxuvar endet am 20.07.2026.',
|
||||
message:
|
||||
'V-1: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
|
||||
refs: [{ type: 'Colony', id: 'colony-1' }],
|
||||
};
|
||||
|
||||
|
||||
@@ -106,5 +106,5 @@ export function renderSuggestion(
|
||||
return override ? { ...rendered, title: override } : rendered;
|
||||
}
|
||||
|
||||
export { formatDayDe } from './texts/format-day';
|
||||
export { formatDayDe } from '../common/format-day';
|
||||
export { formatNumberDe } from './texts/format-number';
|
||||
@@ -28,3 +28,35 @@ describe('chain texts', () => {
|
||||
expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chain texts: sentence ends', () => {
|
||||
const lookup = {
|
||||
colonyCode: () => 'V-1',
|
||||
queenCode: () => 'Q-1',
|
||||
treatmentProduct: () => null,
|
||||
apiaryName: () => null,
|
||||
stockItemName: () => null,
|
||||
};
|
||||
|
||||
it('never ends a reason with two periods when a day closes the sentence', () => {
|
||||
for (const code of Object.keys(CHAIN_TEXTS)) {
|
||||
const reasons = CHAIN_TEXTS[code].render(
|
||||
CHAIN_TEXTS[code].example,
|
||||
lookup,
|
||||
'2026-05-05',
|
||||
).reasons;
|
||||
for (const reason of reasons) {
|
||||
expect(reason).not.toMatch(/\.\./);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the split day with a single period', () => {
|
||||
const reasons = CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.render(
|
||||
CHAIN_TEXTS.SPLIT_SCHEDULE_QUEEN.example,
|
||||
lookup,
|
||||
'2026-05-05',
|
||||
).reasons;
|
||||
expect(reasons[0]).toBe('Ableger von V-1 am 01.05.');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
SuggestionTextRegister,
|
||||
SuggestionTextTemplate,
|
||||
} from '../suggestion-texts';
|
||||
import { formatDayDe } from './format-day';
|
||||
import { endSentence, formatDayDe } from '../../common/format-day';
|
||||
import type { EvidenceLookup } from '../evidence-lookup';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
|
||||
@@ -39,7 +39,9 @@ function splitTemplate(
|
||||
render: (evidence, lookup) => ({
|
||||
title,
|
||||
reasons: [
|
||||
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`,
|
||||
endSentence(
|
||||
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}`,
|
||||
),
|
||||
reason,
|
||||
],
|
||||
}),
|
||||
@@ -59,7 +61,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
|
||||
render: (evidence, lookup) => ({
|
||||
title: 'Schwarmkontrolle',
|
||||
reasons: [
|
||||
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`,
|
||||
endSentence(
|
||||
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -80,7 +84,9 @@ export const CHAIN_TEXTS: SuggestionTextRegister = {
|
||||
return {
|
||||
title: 'Annahme der Königin prüfen',
|
||||
reasons: [
|
||||
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`,
|
||||
endSentence(
|
||||
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt`,
|
||||
),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||
import { formatDayDe } from './format-day';
|
||||
import { formatDayDe } from '../../common/format-day';
|
||||
|
||||
const ENDED_VERBS: Readonly<Record<string, string>> = {
|
||||
DECEASED: 'ist eingegangen',
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
|
||||
export function formatDayDe(day: string, withYear = false): string {
|
||||
const [year, month, date] = day.split('-');
|
||||
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDayDe } from './format-day';
|
||||
import { formatDayDe } from '../../common/format-day';
|
||||
import { formatNumberDe } from './format-number';
|
||||
import type {
|
||||
RenderedSuggestionText,
|
||||
|
||||
@@ -6,6 +6,14 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
|
||||
|
||||
describe('TreatmentsService', () => {
|
||||
// The texts add the year outside the current one: pin 'today'.
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
let service: TreatmentsService;
|
||||
let prisma: {
|
||||
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
@@ -499,6 +507,9 @@ describe('TreatmentsService', () => {
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
activeUntil: '2026-07-15T00:00:00.000Z',
|
||||
lastBlockedDay: '2026-07-14',
|
||||
withdrawalText:
|
||||
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
]);
|
||||
@@ -587,6 +598,9 @@ describe('TreatmentsService', () => {
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
activeUntil: '2026-07-27T00:00:00.000Z',
|
||||
lastBlockedDay: '2026-07-26',
|
||||
withdrawalText:
|
||||
'bis einschließlich 26.07. – ab 27.07. wieder erntefähig',
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
import { runBulk } from '../common/bulk/run-bulk';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||
import {
|
||||
lastBlockedDay,
|
||||
withdrawalRangeText,
|
||||
} from '../withdrawal/withdrawal-text';
|
||||
import {
|
||||
isInWindow,
|
||||
withdrawalWindowOf,
|
||||
@@ -27,6 +31,10 @@ import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||
export interface WithdrawalStatus {
|
||||
colonyId: string;
|
||||
activeUntil: string;
|
||||
/** Last blocked day (inclusive), 'YYYY-MM-DD'; activeUntil is the day after. */
|
||||
lastBlockedDay: string;
|
||||
/** Ready text: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
|
||||
withdrawalText: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
@@ -116,6 +124,8 @@ export class TreatmentsService {
|
||||
return [...activeByColony.values()].map((window) => ({
|
||||
colonyId: window.colonyId,
|
||||
activeUntil: dayToDate(window.untilDay).toISOString(),
|
||||
lastBlockedDay: lastBlockedDay(window.untilDay),
|
||||
withdrawalText: withdrawalRangeText(window.untilDay),
|
||||
product: window.product,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ const conflict = (
|
||||
});
|
||||
|
||||
describe('WithdrawalCheckService', () => {
|
||||
// The texts add the year outside the current one: pin 'today'.
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
let db: {
|
||||
harvest: { findMany: jest.Mock };
|
||||
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
@@ -269,7 +277,8 @@ describe('WithdrawalCheckService', () => {
|
||||
{
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
|
||||
message:
|
||||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||||
refs: [
|
||||
{ type: 'Colony', id: 'c1' },
|
||||
{ type: 'Treatment', id: 't1' },
|
||||
@@ -279,6 +288,25 @@ describe('WithdrawalCheckService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns once per colony and treatment when several harvests feed the batch', async () => {
|
||||
const warnings = await service.warningsFor(asDb(), [
|
||||
conflict({ harvestId: 'h1' }),
|
||||
conflict({ harvestId: 'h2' }),
|
||||
conflict({ harvestId: 'h3', treatmentId: 't2', product: 'Apivar' }),
|
||||
]);
|
||||
|
||||
expect(warnings.map((w) => w.message)).toEqual([
|
||||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||||
'B3: Wartezeit nach Apivar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||||
]);
|
||||
expect(warnings[0].refs).toEqual([
|
||||
{ type: 'Colony', id: 'c1' },
|
||||
{ type: 'Treatment', id: 't1' },
|
||||
{ type: 'Harvest', id: 'h1' },
|
||||
{ type: 'Harvest', id: 'h2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers nothing without reading when there is no conflict', async () => {
|
||||
expect(await service.warningsFor(asDb(), [])).toEqual([]);
|
||||
expect(db.colony.findMany).not.toHaveBeenCalled();
|
||||
@@ -300,7 +328,7 @@ describe('WithdrawalCheckService', () => {
|
||||
priority: 'WARNUNG',
|
||||
category: 'WITHDRAWAL',
|
||||
message:
|
||||
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026.',
|
||||
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||||
relatedType: 'Harvest',
|
||||
relatedId: 'h1',
|
||||
link: '/harvests/h1',
|
||||
@@ -337,7 +365,7 @@ describe('WithdrawalCheckService', () => {
|
||||
{ message: string; dedupeKeyPrefix: string },
|
||||
];
|
||||
expect(input.message).toBe(
|
||||
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026. Bereits abgefüllt: Los L-1, L-2.',
|
||||
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig. Bereits abgefüllt: Los L-1, L-2.',
|
||||
);
|
||||
// Same key as without the lots: one notification per harvest and colony.
|
||||
expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1');
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||
import { formatDayDe } from '../suggestions/texts/format-day';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
import {
|
||||
isInWindow,
|
||||
withdrawalWindowOf,
|
||||
type WithdrawalWindow,
|
||||
} from '../treatments/withdrawal-window';
|
||||
import { withdrawalSpanOf } from './withdrawal-span';
|
||||
import { withdrawalRangeText } from './withdrawal-text';
|
||||
|
||||
/** A harvest taken from a colony while a treatment's withdrawal period ran. */
|
||||
export interface WithdrawalConflict {
|
||||
@@ -49,6 +50,22 @@ function longestCovering(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One group per (colony, treatment): several harvests of a colony that feed
|
||||
* the same batch yield one warning, not one per harvest. Keeps the order of
|
||||
* first appearance.
|
||||
*/
|
||||
function perColonyAndTreatment(
|
||||
conflicts: readonly WithdrawalConflict[],
|
||||
): WithdrawalConflict[][] {
|
||||
const groups = new Map<string, WithdrawalConflict[]>();
|
||||
for (const conflict of conflicts) {
|
||||
const key = `${conflict.colonyId}:${conflict.treatmentId}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), conflict]);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks harvests against the withdrawal windows (Wartezeit) of their
|
||||
* colonies. Only the window helper decides what a window is
|
||||
@@ -190,16 +207,22 @@ export class WithdrawalCheckService {
|
||||
return [];
|
||||
}
|
||||
const codes = await this.colonyCodes(db, conflicts);
|
||||
return conflicts.map((conflict) => ({
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE,
|
||||
message: `${codes.get(conflict.colonyId) ?? conflict.colonyId}: Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.`,
|
||||
refs: [
|
||||
{ type: 'Colony', id: conflict.colonyId },
|
||||
{ type: 'Treatment', id: conflict.treatmentId },
|
||||
{ type: 'Harvest', id: conflict.harvestId },
|
||||
],
|
||||
}));
|
||||
return perColonyAndTreatment(conflicts).map((group) => {
|
||||
const [first] = group;
|
||||
return {
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE,
|
||||
message: `${codes.get(first.colonyId) ?? first.colonyId}: Wartezeit nach ${first.product} ${withdrawalRangeText(first.untilDay)}.`,
|
||||
refs: [
|
||||
{ type: 'Colony', id: first.colonyId },
|
||||
{ type: 'Treatment', id: first.treatmentId },
|
||||
...group.map((conflict) => ({
|
||||
type: 'Harvest',
|
||||
id: conflict.harvestId,
|
||||
})),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +257,7 @@ export class WithdrawalCheckService {
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.${bottledText}`,
|
||||
message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} ${withdrawalRangeText(conflict.untilDay)}.${bottledText}`,
|
||||
relatedType: 'Harvest',
|
||||
relatedId: conflict.harvestId,
|
||||
link: `/harvests/${conflict.harvestId}`,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { lastBlockedDay, withdrawalRangeText } from './withdrawal-text';
|
||||
|
||||
describe('lastBlockedDay', () => {
|
||||
it('is the day before the first free day', () => {
|
||||
expect(lastBlockedDay('2026-10-27')).toBe('2026-10-26');
|
||||
expect(lastBlockedDay('2027-01-01')).toBe('2026-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawalRangeText', () => {
|
||||
const today = '2026-10-04';
|
||||
|
||||
it('names the last blocked day (inclusive) and the first free day', () => {
|
||||
// The window is [fromDay, untilDay): untilDay is the first free day.
|
||||
expect(withdrawalRangeText('2026-10-27', today)).toBe(
|
||||
'bis einschließlich 26.10. – ab 27.10. wieder erntefähig',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts back over a month boundary', () => {
|
||||
expect(withdrawalRangeText('2026-11-01', today)).toBe(
|
||||
'bis einschließlich 31.10. – ab 01.11. wieder erntefähig',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the year when the first free day lies in another year than the last blocked day', () => {
|
||||
expect(withdrawalRangeText('2027-01-01', today)).toBe(
|
||||
'bis einschließlich 31.12.2026 – ab 01.01.2027 wieder erntefähig',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the year when the first free day lies in another year than today', () => {
|
||||
expect(withdrawalRangeText('2027-01-10', today)).toBe(
|
||||
'bis einschließlich 09.01.2027 – ab 10.01.2027 wieder erntefähig',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { addDays, berlinToday } from '../common/dates';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
|
||||
/**
|
||||
* The window is [fromDay, untilDay): untilDay is the first free day, the day
|
||||
* before it is the last blocked day (inclusive). The one place that subtracts
|
||||
* that day; calendar and texts both read it.
|
||||
*/
|
||||
export function lastBlockedDay(untilDay: string): string {
|
||||
return addDays(untilDay, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one wording of a withdrawal window's end: the last blocked day
|
||||
* (inclusive) and the first free day. Every user-facing text (warnings,
|
||||
* notifications, calendar, withdrawal status) builds on this; the days come
|
||||
* from the window helper in treatments/, nothing is calculated twice. The
|
||||
* year is added when the end is not in the current year or the window spans
|
||||
* a year change.
|
||||
*/
|
||||
export function withdrawalRangeText(
|
||||
untilDay: string,
|
||||
today: string = berlinToday(),
|
||||
): string {
|
||||
const last = lastBlockedDay(untilDay);
|
||||
const withYear =
|
||||
untilDay.slice(0, 4) !== last.slice(0, 4) ||
|
||||
untilDay.slice(0, 4) !== today.slice(0, 4);
|
||||
return `bis einschließlich ${formatDayDe(last, withYear)} – ab ${formatDayDe(untilDay, withYear)} wieder erntefähig`;
|
||||
}
|
||||
@@ -39,6 +39,14 @@ const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
describe('WithdrawalEventHandlers', () => {
|
||||
// The texts add the year outside the current one: pin 'today'.
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
let check: {
|
||||
conflictsForHarvests: jest.Mock;
|
||||
conflictsForHoneyBatch: jest.Mock;
|
||||
@@ -193,7 +201,7 @@ describe('WithdrawalEventHandlers', () => {
|
||||
priority: 'INFO',
|
||||
category: 'WITHDRAWAL',
|
||||
message:
|
||||
'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.',
|
||||
'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.',
|
||||
relatedType: 'Colony',
|
||||
relatedId: 'c1',
|
||||
link: '/colonies/c1',
|
||||
|
||||
@@ -5,12 +5,12 @@ import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { formatDayDe } from '../suggestions/texts/format-day';
|
||||
import {
|
||||
isInWindow,
|
||||
withdrawalWindowOf,
|
||||
} from '../treatments/withdrawal-window';
|
||||
import { WithdrawalCheckService } from './withdrawal-check.service';
|
||||
import { withdrawalRangeText } from './withdrawal-text';
|
||||
|
||||
const READY_RECIPIENT_PERMISSIONS = [
|
||||
'harvests:create',
|
||||
@@ -123,7 +123,7 @@ export class WithdrawalEventHandlers {
|
||||
{
|
||||
priority: 'INFO',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`,
|
||||
message: `${treatment.colony.code}: Wartezeit nach ${treatment.product} ${withdrawalRangeText(endedOn)}.`,
|
||||
relatedType: 'Colony',
|
||||
relatedId: treatment.colonyId,
|
||||
link: `/colonies/${treatment.colonyId}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
@@ -152,6 +153,9 @@ describe('Calendar (e2e)', () => {
|
||||
);
|
||||
expect(span).toMatchObject({
|
||||
title: `Wartezeit ${colonyCode} · Oxuvar`,
|
||||
context: colonyCode,
|
||||
product: 'Oxuvar',
|
||||
detail: withdrawalRangeText('2027-06-20'),
|
||||
relatedType: 'Colony',
|
||||
relatedId: colonyId,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ interface TreatmentBody {
|
||||
interface WithdrawalStatusBody {
|
||||
colonyId: string;
|
||||
activeUntil: string;
|
||||
lastBlockedDay: string;
|
||||
withdrawalText: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
@@ -254,6 +256,9 @@ describe('Treatments (e2e)', () => {
|
||||
{
|
||||
colonyId,
|
||||
activeUntil: '2026-07-15T00:00:00.000Z',
|
||||
lastBlockedDay: '2026-07-14',
|
||||
withdrawalText:
|
||||
'bis einschließlich 14.07. – ab 15.07. wieder erntefähig',
|
||||
product: 'Oxalsäure',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
@@ -264,7 +265,7 @@ describe('Withdrawal chain (e2e)', () => {
|
||||
{
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
|
||||
refs: [
|
||||
{ type: 'Colony', id: colony.id },
|
||||
{ type: 'Treatment', id: treatmentIds[0] },
|
||||
@@ -283,7 +284,7 @@ describe('Withdrawal chain (e2e)', () => {
|
||||
expect(rows[0]).toMatchObject({
|
||||
priority: 'WARNUNG',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
|
||||
relatedType: 'Harvest',
|
||||
relatedId: harvestId,
|
||||
link: `/harvests/${harvestId}`,
|
||||
@@ -332,7 +333,7 @@ describe('Withdrawal chain (e2e)', () => {
|
||||
expect(bottling.warnings).toHaveLength(1);
|
||||
expect(bottling.warnings[0]).toMatchObject({
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`,
|
||||
});
|
||||
|
||||
await drainDomainEvents(app, [bottling.id]);
|
||||
@@ -515,7 +516,7 @@ describe('Withdrawal chain (e2e)', () => {
|
||||
expect(rows[0]).toMatchObject({
|
||||
priority: 'INFO',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`,
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-06-25')}.`,
|
||||
relatedType: 'Colony',
|
||||
link: `/colonies/${colony.id}`,
|
||||
});
|
||||
@@ -558,7 +559,7 @@ describe('Withdrawal chain (e2e)', () => {
|
||||
where: { userId: readerId, relatedId: colony.id },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].message).toContain('endete am 03.06.2026');
|
||||
expect(rows[0].message).toContain(withdrawalRangeText('2026-06-03'));
|
||||
expect(rows[0].message).toContain(colony.code);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user