1
import { Injectable } from '@nestjs/common';
2
import { createHash } from 'node:crypto';
3
4
export interface PlaceEventScenario {
5
scenarioId: string;
6
placeType: string;
7
eventType?: string;
8
attendance: number;
9
stockUnits: number;
10
staffingUnits: number;
11
prepUnits: number;
12
/** Optional contribution margin in minor currency units from canonical event economics. */
13
contributionMinor?: number;
14
}
15
16
export interface PlaceEventLearningInput {
17
current: Omit<PlaceEventScenario, 'scenarioId'>;
18
historical: PlaceEventScenario[];
19
}
20
21
export interface PlaceEventLearningRecommendation {
22
/** Stable, non-secret fingerprint for correlating a recommendation with its evidence. */
23
decisionFingerprint: string;
24
comparableScenarioIds: string[];
25
forecast: {attendance: number; stockUnits: number; staffingUnits: number; prepUnits: number; contributionMinor?: number};
26
evidence: {scenarioCount: number; match: 'exact_event' | 'place_fallback' | 'broad_fallback'; confidence: 'high' | 'medium' | 'low'};
27
explanation: string;
28
requiresHumanConfirmation: true;
29
}
30
31
@Injectable()
32
export class PlaceEventLearningService {
33
prepare(input: PlaceEventLearningInput): PlaceEventLearningRecommendation {
34
if (!input || typeof input !== 'object' || !input.current || !Array.isArray(input.historical)) throw new Error('place_event_learning.invalid');
35
const current = this.validate(input.current, false);
36
const historical = input.historical.map((scenario) => this.validate(scenario, true));
37
const scenarioIds = historical.map((scenario) => scenario.scenarioId);
38
const normalizedScenarioIds = scenarioIds.map((scenarioId) => scenarioId.toLowerCase());
39
if (new Set(normalizedScenarioIds).size !== normalizedScenarioIds.length) throw new Error('place_event_learning.duplicate_scenario');
40
const comparable = historical.filter((scenario) => scenario.placeType === current.placeType && (!current.eventType || scenario.eventType === current.eventType));
41
const source = comparable.length ? comparable : historical.filter((scenario) => scenario.placeType === current.placeType);
42
const baseline = source.length ? source : historical;
43
if (!baseline.length) throw new Error('place_event_learning.no_comparable_scenarios');
44
const match = comparable.length ? 'exact_event' : source.length ? 'place_fallback' : 'broad_fallback';
45
const confidence = match === 'exact_event' && baseline.length >= 3 ? 'high' : match === 'exact_event' || match === 'place_fallback' ? 'medium' : 'low';
46
const average = (key: keyof Pick<PlaceEventScenario, 'attendance'|'stockUnits'|'staffingUnits'|'prepUnits'>): number => Math.ceil(baseline.reduce((sum, scenario) => sum + scenario[key], 0) / baseline.length);
47
const contributionMinor = baseline.every((scenario) => scenario.contributionMinor !== undefined)
48
? Math.ceil(baseline.reduce((sum, scenario) => sum + (scenario.contributionMinor ?? 0), 0) / baseline.length)
49
: undefined;
50
const decisionFingerprint = createHash('sha256').update(JSON.stringify({
51
current,
52
historical: [...historical].sort((left, right) => left.scenarioId.localeCompare(right.scenarioId)),
53
match,
54
})).digest('hex').slice(0, 24);
55
return {
56
decisionFingerprint,
57
comparableScenarioIds: baseline.map((scenario) => scenario.scenarioId).sort((left, right) => left.localeCompare(right)),
58
forecast: {attendance: average('attendance'), stockUnits: average('stockUnits'), staffingUnits: average('staffingUnits'), prepUnits: average('prepUnits'), ...(contributionMinor === undefined ? {} : {contributionMinor})},
59
evidence: {scenarioCount: baseline.length, match, confidence},
60
explanation: comparable.length ? `Based on ${comparable.length} comparable Place/event scenario${comparable.length === 1 ? '' : 's'}.` : 'No exact event match was found; the Place history is used as a fallback.',
61
requiresHumanConfirmation: true,
62
};
63
}
64
65
private validate(input: Omit<PlaceEventScenario, 'scenarioId'> | PlaceEventScenario, historical: boolean): PlaceEventScenario {
66
if (!input || typeof input !== 'object' || typeof input.placeType !== 'string' || (historical && (!('scenarioId' in input) || typeof input.scenarioId !== 'string')) || (input.eventType !== undefined && typeof input.eventType !== 'string')) throw new Error('place_event_learning.invalid');
67
const scenarioId = historical && 'scenarioId' in input ? input.scenarioId.trim() : 'current';
68
const placeType = input.placeType.trim();
69
const eventType = input.eventType?.trim() || undefined;
70
const values = [input.attendance, input.stockUnits, input.staffingUnits, input.prepUnits];
71
if (!scenarioId || !placeType || values.some((value) => !Number.isSafeInteger(value) || value < 0) || (input.contributionMinor !== undefined && !Number.isSafeInteger(input.contributionMinor))) throw new Error('place_event_learning.invalid');
72
return {scenarioId, placeType, eventType, attendance: input.attendance, stockUnits: input.stockUnits, staffingUnits: input.staffingUnits, prepUnits: input.prepUnits, ...(input.contributionMinor === undefined ? {} : {contributionMinor: input.contributionMinor})};
73
}
74
}
75