import { Injectable } from '@nestjs/common';
import { AuditService } from '../../modules/audit/audit.service';
export interface PlaceEventMetricSet {
attendance: number;
stockUnits: number;
staffingUnits: number;
prepUnits: number;
/** Optional canonical contribution margin in minor currency units. */
contributionMinor?: number;
}
export type PlaceEventEconomicsSource = 'canonical_event_economics';
export interface PlaceEventOutcomeInput {
organizationId: string;
decisionFingerprint: string;
forecast: PlaceEventMetricSet;
actual: PlaceEventMetricSet;
economicsSource?: PlaceEventEconomicsSource;
userId?: string;
correlationId?: string;
}
export interface PlaceEventOutcomeResult {
decisionFingerprint: string;
status: 'aligned' | 'variance';
deviations: PlaceEventMetricSet;
dimensionsWithVariance: Array<keyof PlaceEventMetricSet>;
economicsSource?: PlaceEventEconomicsSource;
mutatesOperationalTruth: false;
appendOnlyFeedback: true;
}
@Injectable()
export class PlaceEventOutcomeService {
constructor(private readonly audit: AuditService) {}
async record(input: PlaceEventOutcomeInput): Promise<PlaceEventOutcomeResult> {
const fingerprint = typeof input?.decisionFingerprint === 'string'
? input.decisionFingerprint.trim().toLowerCase()
: '';
const forecastHasContribution = input?.forecast?.contributionMinor !== undefined;
const actualHasContribution = input?.actual?.contributionMinor !== undefined;
const hasEconomics = forecastHasContribution || actualHasContribution;
if (!input || typeof input !== 'object' || typeof input.organizationId !== 'string' || !input.organizationId.trim() || !/^[a-f0-9]{24}$/.test(fingerprint) || !this.validMetrics(input.forecast) || !this.validMetrics(input.actual) || (forecastHasContribution !== actualHasContribution) || (hasEconomics && input.economicsSource !== 'canonical_event_economics')) {
throw new Error('place_event_outcome.invalid');
}
const keys: Array<keyof PlaceEventMetricSet> = ['attendance', 'stockUnits', 'staffingUnits', 'prepUnits', ...(hasEconomics ? ['contributionMinor' as const] : [])];
const forecast = input.forecast;
const actual = input.actual;
const deviations = keys.reduce((result, key) => {
const actualValue = actual[key];
const forecastValue = forecast[key];
if (typeof actualValue !== 'number' || typeof forecastValue !== 'number') throw new Error('place_event_outcome.invalid');
result[key] = actualValue - forecastValue;
return result;
}, {} as PlaceEventMetricSet);
const dimensionsWithVariance = keys.filter((key) => deviations[key] !== 0);
const status = dimensionsWithVariance.length ? 'variance' : 'aligned';
const economicStatus = hasEconomics
? (deviations.contributionMinor === 0 ? 'aligned' : 'variance')
: undefined;
await this.audit.record({
organizationId: input.organizationId.trim(),
actorUserId: input.userId,
correlationId: input.correlationId,
eventType: 'place_event.recommendation.outcome_recorded',
entityType: 'PlaceEventRecommendationOutcome',
metadata: {
decisionFingerprint: fingerprint,
recommendationKind: 'place_event',
status,
dimensionsWithVariance,
...(economicStatus ? {economicStatus, economicsSource: input.economicsSource} : {}),
mutatesOperationalTruth: false,
appendOnlyFeedback: true,
},
});
return {decisionFingerprint: fingerprint, status, deviations, dimensionsWithVariance, ...(hasEconomics ? {economicsSource: input.economicsSource} : {}), mutatesOperationalTruth: false, appendOnlyFeedback: true};
}
private validMetrics(metrics: PlaceEventMetricSet): boolean {
return Boolean(metrics && typeof metrics === 'object'
&& Number.isSafeInteger(metrics.attendance) && metrics.attendance >= 0
&& Number.isSafeInteger(metrics.stockUnits) && metrics.stockUnits >= 0
&& Number.isSafeInteger(metrics.staffingUnits) && metrics.staffingUnits >= 0
&& Number.isSafeInteger(metrics.prepUnits) && metrics.prepUnits >= 0
&& (metrics.contributionMinor === undefined || Number.isSafeInteger(metrics.contributionMinor)));
}
}
