@holoscript/plugin-travel-hospitality 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/revenuemanagement.test.ts +15 -10
- package/src/__tests__/runtime-integration.test.ts +137 -0
- package/src/index.ts +48 -5
- package/src/revenuemanagement.ts +70 -44
- package/src/runtime.ts +162 -0
- package/src/traits/ItineraryTrait.ts +58 -10
- package/src/traits/RateManagementTrait.ts +53 -10
- package/src/traits/ReservationTrait.ts +70 -13
- package/src/traits/types.ts +22 -4
- package/tsconfig.json +5 -1
- package/vitest.config.ts +12 -0
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holoscript/plugin-travel-hospitality",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"peerDependencies": {
|
|
6
|
-
"@holoscript/core": "8.0.
|
|
6
|
+
"@holoscript/core": ">=8.0.0"
|
|
7
7
|
},
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "vitest run --passWithNoTests",
|
|
11
11
|
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
12
|
}
|
|
13
|
-
}
|
|
13
|
+
}
|
|
@@ -66,9 +66,9 @@ describe('emsrYieldManagement', () => {
|
|
|
66
66
|
|
|
67
67
|
it('3-class EMSR returns correct array lengths', () => {
|
|
68
68
|
const threeClass: RateClass[] = [
|
|
69
|
-
{ id: 'Suite',
|
|
69
|
+
{ id: 'Suite', adr: 400, expectedDemand: 20, demandStdDev: 8 },
|
|
70
70
|
{ id: 'Standard', adr: 200, expectedDemand: 60, demandStdDev: 15 },
|
|
71
|
-
{ id: 'Budget',
|
|
71
|
+
{ id: 'Budget', adr: 100, expectedDemand: 100, demandStdDev: 25 },
|
|
72
72
|
];
|
|
73
73
|
const r = emsrYieldManagement(200, threeClass);
|
|
74
74
|
expect(r.protectionLevels).toHaveLength(3);
|
|
@@ -93,7 +93,7 @@ describe('revparAnalysis', () => {
|
|
|
93
93
|
*/
|
|
94
94
|
it('occupancy = occupied / available', () => {
|
|
95
95
|
const r = revparAnalysis(200, 160, 24_000);
|
|
96
|
-
expect(r.occupancyRate).toBeCloseTo(0.
|
|
96
|
+
expect(r.occupancyRate).toBeCloseTo(0.8, 5);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('ADR = revenue / occupied rooms', () => {
|
|
@@ -134,23 +134,23 @@ describe('overbookingOptimization', () => {
|
|
|
134
134
|
* Critical ratio = 150/(150+300) = 1/3 — expect some overbooking to be optimal
|
|
135
135
|
*/
|
|
136
136
|
it('overbooking level ≥ 0', () => {
|
|
137
|
-
const r = overbookingOptimization(100, 0.
|
|
137
|
+
const r = overbookingOptimization(100, 0.1, 150, 300, 95);
|
|
138
138
|
expect(r.overbook).toBeGreaterThanOrEqual(0);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
it('authorized bookings = capacity + overbook', () => {
|
|
142
|
-
const r = overbookingOptimization(100, 0.
|
|
142
|
+
const r = overbookingOptimization(100, 0.1, 150, 300, 95);
|
|
143
143
|
expect(r.authorizedBookings).toBe(r.capacity + r.overbook);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
146
|
it('higher walk cost → lower overbooking', () => {
|
|
147
|
-
const r1 = overbookingOptimization(100, 0.
|
|
148
|
-
const r2 = overbookingOptimization(100, 0.
|
|
147
|
+
const r1 = overbookingOptimization(100, 0.1, 150, 100, 90); // low walk cost
|
|
148
|
+
const r2 = overbookingOptimization(100, 0.1, 150, 1000, 90); // high walk cost
|
|
149
149
|
expect(r2.overbook).toBeLessThanOrEqual(r1.overbook);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
it('expectedWalkCost ≥ 0', () => {
|
|
153
|
-
const r = overbookingOptimization(100, 0.
|
|
153
|
+
const r = overbookingOptimization(100, 0.1, 150, 300, 95);
|
|
154
154
|
expect(r.expectedWalkCost).toBeGreaterThanOrEqual(0);
|
|
155
155
|
});
|
|
156
156
|
|
|
@@ -231,8 +231,13 @@ describe('hotelDemandForecast', () => {
|
|
|
231
231
|
|
|
232
232
|
it('weekend indices > weekday indices (reflects weekend demand spike)', () => {
|
|
233
233
|
const r = hotelDemandForecast(historical, 7);
|
|
234
|
-
const weekdayAvg =
|
|
235
|
-
|
|
234
|
+
const weekdayAvg =
|
|
235
|
+
(r.seasonalIndices[0] +
|
|
236
|
+
r.seasonalIndices[1] +
|
|
237
|
+
r.seasonalIndices[2] +
|
|
238
|
+
r.seasonalIndices[3] +
|
|
239
|
+
r.seasonalIndices[4]) /
|
|
240
|
+
5;
|
|
236
241
|
const weekendAvg = (r.seasonalIndices[5] + r.seasonalIndices[6]) / 2;
|
|
237
242
|
expect(weekendAvg).toBeGreaterThan(weekdayAvg);
|
|
238
243
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration proof: the travel-hospitality `revpar` trait, once registered via
|
|
3
|
+
* the runtime's real `registerTrait` seam, is dispatched BY THE RUNTIME and runs
|
|
4
|
+
* the deterministic RevPAR analytic solver — NOT called directly as a handler
|
|
5
|
+
* object.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors government-civic-plugin's runtime-integration reference
|
|
8
|
+
* (civic_decision). Drives the real path: executeNode(orb) -> orb-executor ->
|
|
9
|
+
* applyDirectives -> traitHandlers.get('revpar').onAttach -> revparAnalysis.
|
|
10
|
+
* The negative control proves the registration is load-bearing (without it, the
|
|
11
|
+
* trait is a dead no-op — which is exactly the tier's status quo).
|
|
12
|
+
*
|
|
13
|
+
* HAND-DERIVED RevPAR (real formula, NOT solver output):
|
|
14
|
+
* availableRooms=200, occupiedRooms=150, totalRevenue=$30,000
|
|
15
|
+
* occupancyRate = occupied/available = 150/200 = 0.75
|
|
16
|
+
* adr = revenue/occupied = 30000/150 = 200
|
|
17
|
+
* RevPAR = ADR × occupancy = 200 × 0.75 = 150.00
|
|
18
|
+
* cross-check = revenue/available = 30000/200 = 150.00 (agrees)
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { HoloScriptRuntime } from '@holoscript/core/runtime';
|
|
22
|
+
import { registerTravelHospitalityTraitHandlers } from '../runtime';
|
|
23
|
+
|
|
24
|
+
// Hand-derived expectations (see header arithmetic).
|
|
25
|
+
const AVAILABLE_ROOMS = 200;
|
|
26
|
+
const OCCUPIED_ROOMS = 150;
|
|
27
|
+
const TOTAL_REVENUE = 30_000;
|
|
28
|
+
const EXPECTED_OCCUPANCY = 0.75; // 150 / 200
|
|
29
|
+
const EXPECTED_ADR = 200; // 30000 / 150
|
|
30
|
+
const EXPECTED_REVPAR = 150; // 200 × 0.75 (=== 30000 / 200)
|
|
31
|
+
|
|
32
|
+
function revparOrb(config: Record<string, unknown>): unknown {
|
|
33
|
+
return {
|
|
34
|
+
type: 'orb',
|
|
35
|
+
name: 'revpar-orb',
|
|
36
|
+
properties: {},
|
|
37
|
+
methods: [],
|
|
38
|
+
position: [0, 0, 0],
|
|
39
|
+
hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
|
|
40
|
+
directives: [{ type: 'trait', name: 'revpar', config }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Flush the runtime's async emit dispatch so `on` listeners have fired. */
|
|
45
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
46
|
+
|
|
47
|
+
describe('travel-hospitality -> HoloScript runtime integration (revpar)', () => {
|
|
48
|
+
it('runtime dispatch runs the RevPAR solver for a registered @revpar orb', async () => {
|
|
49
|
+
const runtime = new HoloScriptRuntime();
|
|
50
|
+
registerTravelHospitalityTraitHandlers(runtime);
|
|
51
|
+
|
|
52
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
53
|
+
runtime.on('revpar_solved', (e: unknown) => {
|
|
54
|
+
solved.push(e as Record<string, unknown>);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await runtime.executeNode(
|
|
58
|
+
revparOrb({
|
|
59
|
+
availableRooms: AVAILABLE_ROOMS,
|
|
60
|
+
occupiedRooms: OCCUPIED_ROOMS,
|
|
61
|
+
totalRevenue: TOTAL_REVENUE,
|
|
62
|
+
}) as never
|
|
63
|
+
);
|
|
64
|
+
await flush();
|
|
65
|
+
|
|
66
|
+
expect(solved).toHaveLength(1);
|
|
67
|
+
const summary = solved[0];
|
|
68
|
+
// Asserted against the HAND derivation above, NOT against solver output:
|
|
69
|
+
// RevPAR = ADR × occupancy = 200 × 0.75 = 150.00
|
|
70
|
+
expect(summary.revpar as number).toBeCloseTo(EXPECTED_REVPAR, 6);
|
|
71
|
+
expect(summary.adr as number).toBeCloseTo(EXPECTED_ADR, 6);
|
|
72
|
+
expect(summary.occupancyRate as number).toBeCloseTo(EXPECTED_OCCUPANCY, 6);
|
|
73
|
+
expect(summary.availableRooms).toBe(AVAILABLE_ROOMS);
|
|
74
|
+
expect(summary.occupiedRooms).toBe(OCCUPIED_ROOMS);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('NEGATIVE CONTROL: without registration the @revpar trait is a dead no-op', async () => {
|
|
78
|
+
const runtime = new HoloScriptRuntime(); // intentionally NOT registered
|
|
79
|
+
const solved: unknown[] = [];
|
|
80
|
+
runtime.on('revpar_solved', (e: unknown) => solved.push(e));
|
|
81
|
+
|
|
82
|
+
await runtime.executeNode(
|
|
83
|
+
revparOrb({
|
|
84
|
+
availableRooms: AVAILABLE_ROOMS,
|
|
85
|
+
occupiedRooms: OCCUPIED_ROOMS,
|
|
86
|
+
totalRevenue: TOTAL_REVENUE,
|
|
87
|
+
}) as never
|
|
88
|
+
);
|
|
89
|
+
await flush();
|
|
90
|
+
|
|
91
|
+
expect(solved).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('persists the solver result into durable runtime state on ATTACH', async () => {
|
|
95
|
+
const runtime = new HoloScriptRuntime();
|
|
96
|
+
registerTravelHospitalityTraitHandlers(runtime);
|
|
97
|
+
|
|
98
|
+
await runtime.executeNode(
|
|
99
|
+
revparOrb({
|
|
100
|
+
availableRooms: AVAILABLE_ROOMS,
|
|
101
|
+
occupiedRooms: OCCUPIED_ROOMS,
|
|
102
|
+
totalRevenue: TOTAL_REVENUE,
|
|
103
|
+
}) as never
|
|
104
|
+
);
|
|
105
|
+
await flush();
|
|
106
|
+
|
|
107
|
+
const state = runtime.getState() as Record<string, unknown>;
|
|
108
|
+
const persisted = state['revpar:revpar-orb'] as
|
|
109
|
+
| { revpar?: number; occupancyRate?: number }
|
|
110
|
+
| undefined;
|
|
111
|
+
expect(persisted).toBeDefined();
|
|
112
|
+
// Same hand-derived RevPAR = 150.00.
|
|
113
|
+
expect(persisted?.revpar).toBeCloseTo(EXPECTED_REVPAR, 6);
|
|
114
|
+
expect(persisted?.occupancyRate).toBeCloseTo(EXPECTED_OCCUPANCY, 6);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('emits revpar_error (does not throw through the runtime) for invalid config', async () => {
|
|
118
|
+
const runtime = new HoloScriptRuntime();
|
|
119
|
+
registerTravelHospitalityTraitHandlers(runtime);
|
|
120
|
+
|
|
121
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
122
|
+
runtime.on('revpar_error', (e: unknown) => {
|
|
123
|
+
errors.push(e as Record<string, unknown>);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Zero available rooms + negative revenue: the real solver validates
|
|
127
|
+
// `availableRooms <= 0` first and throws "availableRooms must be positive",
|
|
128
|
+
// which the handler's try/catch turns into a revpar_error rather than a throw.
|
|
129
|
+
await runtime.executeNode(
|
|
130
|
+
revparOrb({ availableRooms: 0, occupiedRooms: 0, totalRevenue: -5_000 }) as never
|
|
131
|
+
);
|
|
132
|
+
await flush();
|
|
133
|
+
|
|
134
|
+
expect(errors).toHaveLength(1);
|
|
135
|
+
expect(String(errors[0].error)).toContain('availableRooms');
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export {
|
|
2
|
+
createReservationHandler,
|
|
3
|
+
type ReservationConfig,
|
|
4
|
+
type ReservationStatus,
|
|
5
|
+
} from './traits/ReservationTrait';
|
|
6
|
+
export {
|
|
7
|
+
createItineraryHandler,
|
|
8
|
+
type ItineraryConfig,
|
|
9
|
+
type ItineraryItem,
|
|
10
|
+
} from './traits/ItineraryTrait';
|
|
11
|
+
export {
|
|
12
|
+
createRateManagementHandler,
|
|
13
|
+
type RateManagementConfig,
|
|
14
|
+
type RatePeriod,
|
|
15
|
+
} from './traits/RateManagementTrait';
|
|
4
16
|
export * from './traits/types';
|
|
5
17
|
|
|
6
18
|
import { createReservationHandler } from './traits/ReservationTrait';
|
|
@@ -9,5 +21,36 @@ import { createRateManagementHandler } from './traits/RateManagementTrait';
|
|
|
9
21
|
|
|
10
22
|
export * from './revenuemanagement';
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
// Runtime integration — behavioral trait handler + registrar that wire the
|
|
25
|
+
// deterministic RevPAR analytic solver into HoloScriptRuntime's dispatch. Closes
|
|
26
|
+
// the built-but-dead-wired gap for `revpar`, mirroring government-civic's
|
|
27
|
+
// `civic_decision` reference integration.
|
|
28
|
+
export {
|
|
29
|
+
TRAVEL_HOSPITALITY_PLUGIN_ID,
|
|
30
|
+
revparHandler,
|
|
31
|
+
registerTravelHospitalityTraitHandlers,
|
|
32
|
+
type RevparTraitConfig,
|
|
33
|
+
type RevparSolvedEvent,
|
|
34
|
+
type RuntimeTraitHandler,
|
|
35
|
+
type TraitRegistrar,
|
|
36
|
+
} from './runtime';
|
|
37
|
+
|
|
38
|
+
export const pluginMeta = {
|
|
39
|
+
name: '@holoscript/plugin-travel-hospitality',
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
traits: [
|
|
42
|
+
'reservation',
|
|
43
|
+
'itinerary',
|
|
44
|
+
'rate_management',
|
|
45
|
+
'emsr_yield',
|
|
46
|
+
'revpar',
|
|
47
|
+
'overbooking',
|
|
48
|
+
'group_displacement',
|
|
49
|
+
'demand_forecast',
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
export const traitHandlers = [
|
|
53
|
+
createReservationHandler(),
|
|
54
|
+
createItineraryHandler(),
|
|
55
|
+
createRateManagementHandler(),
|
|
56
|
+
];
|
package/src/revenuemanagement.ts
CHANGED
|
@@ -106,23 +106,22 @@ export interface RevenueReceiptOptions {
|
|
|
106
106
|
function normalCDF(x: number): number {
|
|
107
107
|
if (x < -6) return 0;
|
|
108
108
|
if (x > 6) return 1;
|
|
109
|
-
const a1 = 0.254829592,
|
|
110
|
-
|
|
109
|
+
const a1 = 0.254829592,
|
|
110
|
+
a2 = -0.284496736,
|
|
111
|
+
a3 = 1.421413741;
|
|
112
|
+
const a4 = -1.453152027,
|
|
113
|
+
a5 = 1.061405429,
|
|
114
|
+
p = 0.3275911;
|
|
111
115
|
const sign = x >= 0 ? 1 : -1;
|
|
112
116
|
const absX = Math.abs(x);
|
|
113
117
|
const t = 1 / (1 + p * absX);
|
|
114
|
-
const poly = (((
|
|
118
|
+
const poly = (((a5 * t + a4) * t + a3) * t + a2) * t + a1;
|
|
115
119
|
const erfVal = 1 - poly * t * Math.exp(-absX * absX);
|
|
116
120
|
return 0.5 * (1 + sign * erfVal);
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
/** Littlewood's rule: P(demand ≥ x) ≥ r_low/r_high → protect x seats for high class */
|
|
120
|
-
function littlewoodProtection(
|
|
121
|
-
rLow: number,
|
|
122
|
-
rHigh: number,
|
|
123
|
-
mu: number,
|
|
124
|
-
sigma: number,
|
|
125
|
-
): number {
|
|
124
|
+
function littlewoodProtection(rLow: number, rHigh: number, mu: number, sigma: number): number {
|
|
126
125
|
// Find x such that P(D_high ≥ x) = r_low / r_high
|
|
127
126
|
const threshold = rLow / rHigh;
|
|
128
127
|
// P(D ≥ x) = 1 - Φ((x - μ)/σ)
|
|
@@ -137,28 +136,42 @@ function littlewoodProtection(
|
|
|
137
136
|
function normInvApprox(p: number): number {
|
|
138
137
|
if (p <= 0) return -6;
|
|
139
138
|
if (p >= 1) return 6;
|
|
140
|
-
const a = [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
const a = [
|
|
140
|
+
-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2,
|
|
141
|
+
-3.066479806614716e1, 2.506628277459239,
|
|
142
|
+
];
|
|
143
|
+
const b = [
|
|
144
|
+
-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1,
|
|
145
|
+
-1.328068155288572e1,
|
|
146
|
+
];
|
|
147
|
+
const c = [
|
|
148
|
+
-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734,
|
|
149
|
+
4.374664141464968, 2.938163982698783,
|
|
150
|
+
];
|
|
151
|
+
const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
|
|
152
|
+
const pLow = 0.02425,
|
|
153
|
+
pHigh = 1 - pLow;
|
|
148
154
|
|
|
149
155
|
let q: number, r: number;
|
|
150
156
|
if (p < pLow) {
|
|
151
157
|
q = Math.sqrt(-2 * Math.log(p));
|
|
152
|
-
return (
|
|
153
|
-
|
|
158
|
+
return (
|
|
159
|
+
(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
160
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
|
|
161
|
+
);
|
|
154
162
|
} else if (p <= pHigh) {
|
|
155
|
-
q = p - 0.5;
|
|
156
|
-
|
|
157
|
-
|
|
163
|
+
q = p - 0.5;
|
|
164
|
+
r = q * q;
|
|
165
|
+
return (
|
|
166
|
+
((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) /
|
|
167
|
+
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
|
|
168
|
+
);
|
|
158
169
|
} else {
|
|
159
170
|
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
160
|
-
return
|
|
161
|
-
|
|
171
|
+
return (
|
|
172
|
+
-(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
173
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
|
|
174
|
+
);
|
|
162
175
|
}
|
|
163
176
|
}
|
|
164
177
|
|
|
@@ -167,10 +180,7 @@ function normInvApprox(p: number): number {
|
|
|
167
180
|
* Rate classes must be provided in any order; sorted high→low ADR internally.
|
|
168
181
|
* capacity: total rooms available.
|
|
169
182
|
*/
|
|
170
|
-
export function emsrYieldManagement(
|
|
171
|
-
capacity: number,
|
|
172
|
-
rateClasses: RateClass[],
|
|
173
|
-
): EMSRResult {
|
|
183
|
+
export function emsrYieldManagement(capacity: number, rateClasses: RateClass[]): EMSRResult {
|
|
174
184
|
if (capacity <= 0) throw new Error('capacity must be positive');
|
|
175
185
|
if (rateClasses.length === 0) throw new Error('At least one rate class required');
|
|
176
186
|
|
|
@@ -182,13 +192,14 @@ export function emsrYieldManagement(
|
|
|
182
192
|
|
|
183
193
|
// EMSR-b: aggregate demand from classes j+1..n and find protection for class j
|
|
184
194
|
for (let j = 0; j < sorted.length - 1; j++) {
|
|
185
|
-
const r_j
|
|
186
|
-
const r_j1
|
|
195
|
+
const r_j = sorted[j].adr; // higher class fare
|
|
196
|
+
const r_j1 = sorted[j + 1].adr; // lower class fare
|
|
187
197
|
|
|
188
198
|
// Aggregate demand for classes 0..j (high-value classes)
|
|
189
|
-
let aggMu = 0,
|
|
199
|
+
let aggMu = 0,
|
|
200
|
+
aggVar = 0;
|
|
190
201
|
for (let k = 0; k <= j; k++) {
|
|
191
|
-
aggMu
|
|
202
|
+
aggMu += sorted[k].expectedDemand;
|
|
192
203
|
aggVar += sorted[k].demandStdDev ** 2;
|
|
193
204
|
}
|
|
194
205
|
const aggSigma = Math.sqrt(aggVar);
|
|
@@ -222,7 +233,7 @@ export function emsrYieldManagement(
|
|
|
222
233
|
export function revparAnalysis(
|
|
223
234
|
availableRooms: number,
|
|
224
235
|
occupiedRooms: number,
|
|
225
|
-
totalRevenue: number
|
|
236
|
+
totalRevenue: number
|
|
226
237
|
): RevPARResult {
|
|
227
238
|
if (availableRooms <= 0) throw new Error('availableRooms must be positive');
|
|
228
239
|
if (occupiedRooms < 0) throw new Error('occupiedRooms must be non-negative');
|
|
@@ -249,7 +260,7 @@ export function overbookingOptimization(
|
|
|
249
260
|
noShowRate: number,
|
|
250
261
|
adr: number,
|
|
251
262
|
walkCostPerGuest: number,
|
|
252
|
-
expectedBookings: number
|
|
263
|
+
expectedBookings: number
|
|
253
264
|
): OverbookingResult {
|
|
254
265
|
if (capacity <= 0) throw new Error('capacity must be positive');
|
|
255
266
|
if (noShowRate < 0 || noShowRate > 1) throw new Error('noShowRate must be in [0, 1]');
|
|
@@ -282,7 +293,8 @@ export function overbookingOptimization(
|
|
|
282
293
|
// Expected walks = E[max(0, shows - capacity)] where shows = bookings - noShows
|
|
283
294
|
let expectedWalks = 0;
|
|
284
295
|
for (let w = 1; w <= optOverbook + 5; w++) {
|
|
285
|
-
const pWalk =
|
|
296
|
+
const pWalk =
|
|
297
|
+
1 - normalCDF((w - 0.5 - (authorizedBookings - mu - capacity)) / Math.max(sigma, 0.1));
|
|
286
298
|
expectedWalks += pWalk;
|
|
287
299
|
if (pWalk < 1e-6) break;
|
|
288
300
|
}
|
|
@@ -292,7 +304,15 @@ export function overbookingOptimization(
|
|
|
292
304
|
// Revenue gain vs no-overbooking: each additional booking accepted at ADR, less expected walk cost
|
|
293
305
|
const expectedRevenueGain = Math.max(0, optOverbook * adr * noShowRate - expectedWalkCost);
|
|
294
306
|
|
|
295
|
-
return {
|
|
307
|
+
return {
|
|
308
|
+
capacity,
|
|
309
|
+
overbook: optOverbook,
|
|
310
|
+
authorizedBookings,
|
|
311
|
+
noShowRate,
|
|
312
|
+
walkCostPerGuest,
|
|
313
|
+
expectedWalkCost,
|
|
314
|
+
expectedRevenueGain,
|
|
315
|
+
};
|
|
296
316
|
}
|
|
297
317
|
|
|
298
318
|
// ─── Group displacement ───────────────────────────────────────────────────────
|
|
@@ -304,14 +324,15 @@ export function groupDisplacementAnalysis(
|
|
|
304
324
|
groupAdr: number,
|
|
305
325
|
groupRoomNights: number,
|
|
306
326
|
transientAdr: number,
|
|
307
|
-
transientDemandProbability = 1.0
|
|
327
|
+
transientDemandProbability = 1.0
|
|
308
328
|
): GroupDisplacementResult {
|
|
309
329
|
if (groupAdr <= 0) throw new Error('groupAdr must be positive');
|
|
310
330
|
if (groupRoomNights <= 0) throw new Error('groupRoomNights must be positive');
|
|
311
331
|
if (transientAdr <= 0) throw new Error('transientAdr must be positive');
|
|
312
332
|
|
|
313
333
|
const displacementCost = (transientAdr - groupAdr) * groupRoomNights * transientDemandProbability;
|
|
314
|
-
const netValue =
|
|
334
|
+
const netValue =
|
|
335
|
+
groupAdr * groupRoomNights - transientAdr * groupRoomNights * transientDemandProbability;
|
|
315
336
|
const profitable = netValue > 0;
|
|
316
337
|
|
|
317
338
|
return { groupAdr, groupRoomNights, transientAdr, displacementCost, netValue, profitable };
|
|
@@ -325,9 +346,10 @@ export function groupDisplacementAnalysis(
|
|
|
325
346
|
*/
|
|
326
347
|
export function hotelDemandForecast(
|
|
327
348
|
historical: number[],
|
|
328
|
-
forecastDays: number
|
|
349
|
+
forecastDays: number
|
|
329
350
|
): HotelForecastResult {
|
|
330
|
-
if (historical.length < 14)
|
|
351
|
+
if (historical.length < 14)
|
|
352
|
+
throw new Error('At least 14 days of history required for seasonal estimation');
|
|
331
353
|
if (forecastDays < 1) throw new Error('forecastDays must be ≥ 1');
|
|
332
354
|
|
|
333
355
|
// Estimate weekly seasonal indices (7 days)
|
|
@@ -341,7 +363,7 @@ export function hotelDemandForecast(
|
|
|
341
363
|
dayCounts[dow]++;
|
|
342
364
|
});
|
|
343
365
|
seasonalIndices.forEach((s, i) => {
|
|
344
|
-
seasonalIndices[i] = overallMean > 0 ?
|
|
366
|
+
seasonalIndices[i] = overallMean > 0 ? s / dayCounts[i] / overallMean : 1;
|
|
345
367
|
});
|
|
346
368
|
|
|
347
369
|
// Deseasonalise
|
|
@@ -385,11 +407,11 @@ export interface TravelAnalysisResult {
|
|
|
385
407
|
|
|
386
408
|
export function buildTravelReceipt(
|
|
387
409
|
result: TravelAnalysisResult,
|
|
388
|
-
options?: RevenueReceiptOptions
|
|
410
|
+
options?: RevenueReceiptOptions
|
|
389
411
|
): DomainSimulationReceipt {
|
|
390
412
|
const violations: Array<{ criterion: string; message: string }> = [];
|
|
391
413
|
|
|
392
|
-
if (result.revpar && result.revpar.occupancyRate < 0.
|
|
414
|
+
if (result.revpar && result.revpar.occupancyRate < 0.4) {
|
|
393
415
|
violations.push({
|
|
394
416
|
criterion: 'occupancy',
|
|
395
417
|
message: `Occupancy ${(result.revpar.occupancyRate * 100).toFixed(1)}% is critically low (<40%)`,
|
|
@@ -413,7 +435,11 @@ export function buildTravelReceipt(
|
|
|
413
435
|
emsrExpectedRevenue: result.emsr?.expectedRevenue ?? null,
|
|
414
436
|
overbookLevel: result.overbooking?.overbook ?? null,
|
|
415
437
|
},
|
|
416
|
-
cael: {
|
|
438
|
+
cael: {
|
|
439
|
+
version: 'cael.v1',
|
|
440
|
+
event: 'travel_hospitality.revenue_management',
|
|
441
|
+
solverType: 'travel-hospitality.emsr-b',
|
|
442
|
+
},
|
|
417
443
|
acceptance: { accepted: violations.length === 0, violations },
|
|
418
444
|
});
|
|
419
445
|
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime integration for @holoscript/plugin-travel-hospitality.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the previously dead-wired `revpar` trait into a behavioral
|
|
5
|
+
* TraitHandler that the HoloScript runtime actually dispatches
|
|
6
|
+
* (HoloScriptRuntime.registerTrait -> applyDirectives / updateTraits).
|
|
7
|
+
*
|
|
8
|
+
* Before this module the plugin declared trait NAMES only (pluginMeta.traits)
|
|
9
|
+
* and exported the revenue-management solvers (revparAnalysis,
|
|
10
|
+
* emsrYieldManagement, …), but nothing invoked a solver THROUGH the runtime —
|
|
11
|
+
* the whole domain-plugin tier was built-but-dead-wired. This mirrors
|
|
12
|
+
* government-civic-plugin's reference integration (civic_decision): it wires the
|
|
13
|
+
* deterministic RevPAR analytic solver (`revparAnalysis`) behind the `revpar`
|
|
14
|
+
* trait so the runtime's directive dispatch can run it. The remaining
|
|
15
|
+
* travel-hospitality traits follow the same registrar shape.
|
|
16
|
+
*
|
|
17
|
+
* RevPAR (Revenue Per Available Room) = Room Revenue / Available Room-nights,
|
|
18
|
+
* equivalently ADR × Occupancy (revparAnalysis computes both and they agree).
|
|
19
|
+
*/
|
|
20
|
+
import { registerPluginTraits } from '@holoscript/core/runtime';
|
|
21
|
+
import { revparAnalysis, type RevPARResult } from './revenuemanagement';
|
|
22
|
+
|
|
23
|
+
/** Stable id for this plugin's trait ownership tagging. */
|
|
24
|
+
export const TRAVEL_HOSPITALITY_PLUGIN_ID = 'travel-hospitality' as const;
|
|
25
|
+
|
|
26
|
+
/** Config carried by an orb's `@revpar` trait directive. */
|
|
27
|
+
export interface RevparTraitConfig {
|
|
28
|
+
/** Rooms available for the period. Required; absence/invalid emits `revpar_error`. */
|
|
29
|
+
availableRooms?: number;
|
|
30
|
+
/** Rooms occupied for the period. Required. */
|
|
31
|
+
occupiedRooms?: number;
|
|
32
|
+
/** Total room revenue for the period ($). Required. */
|
|
33
|
+
totalRevenue?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Summary payload emitted on `revpar_solved`. */
|
|
37
|
+
export interface RevparSolvedEvent {
|
|
38
|
+
nodeId: string;
|
|
39
|
+
/** Revenue Per Available Room = ADR × occupancy ($). */
|
|
40
|
+
revpar: number;
|
|
41
|
+
/** Average Daily Rate ($). */
|
|
42
|
+
adr: number;
|
|
43
|
+
/** Occupancy rate [0,1]. */
|
|
44
|
+
occupancyRate: number;
|
|
45
|
+
/** Rooms available evaluated. */
|
|
46
|
+
availableRooms: number;
|
|
47
|
+
/** Rooms occupied evaluated. */
|
|
48
|
+
occupiedRooms: number;
|
|
49
|
+
/** Total room revenue evaluated ($). */
|
|
50
|
+
totalRevenue: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Structural view of the runtime trait-handler contract. Matches
|
|
55
|
+
* `@holoscript/core` TraitTypes.TraitHandler at the call sites the runtime
|
|
56
|
+
* actually uses (onAttach / onUpdate receive the node, the directive config,
|
|
57
|
+
* and a context exposing `emit`). Declared locally so the plugin stays
|
|
58
|
+
* decoupled from core's full trait surface.
|
|
59
|
+
*/
|
|
60
|
+
export interface TraitDispatchContext {
|
|
61
|
+
emit: (event: string, payload?: unknown) => void;
|
|
62
|
+
setState?: (updates: Record<string, unknown>) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RuntimeTraitHandler {
|
|
66
|
+
name: string;
|
|
67
|
+
onAttach?: (node: unknown, config: RevparTraitConfig, context: TraitDispatchContext) => void;
|
|
68
|
+
onUpdate?: (
|
|
69
|
+
node: unknown,
|
|
70
|
+
config: RevparTraitConfig,
|
|
71
|
+
context: TraitDispatchContext,
|
|
72
|
+
delta: number
|
|
73
|
+
) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface RevparNode {
|
|
77
|
+
id?: string;
|
|
78
|
+
name?: string;
|
|
79
|
+
properties?: Record<string, unknown>;
|
|
80
|
+
__revparResult?: RevPARResult;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run the RevPAR solver on the directive config, write the result onto the node, and emit. */
|
|
84
|
+
function solveOntoNode(
|
|
85
|
+
node: unknown,
|
|
86
|
+
config: RevparTraitConfig | undefined,
|
|
87
|
+
context: TraitDispatchContext
|
|
88
|
+
): void {
|
|
89
|
+
const carrier = node as RevparNode;
|
|
90
|
+
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
91
|
+
const availableRooms = config?.availableRooms;
|
|
92
|
+
const occupiedRooms = config?.occupiedRooms;
|
|
93
|
+
const totalRevenue = config?.totalRevenue;
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
typeof availableRooms !== 'number' ||
|
|
97
|
+
typeof occupiedRooms !== 'number' ||
|
|
98
|
+
typeof totalRevenue !== 'number'
|
|
99
|
+
) {
|
|
100
|
+
context.emit('revpar_error', {
|
|
101
|
+
nodeId,
|
|
102
|
+
error:
|
|
103
|
+
'revpar trait requires numeric config.availableRooms, config.occupiedRooms, and config.totalRevenue',
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = revparAnalysis(availableRooms, occupiedRooms, totalRevenue);
|
|
110
|
+
carrier.__revparResult = result;
|
|
111
|
+
carrier.properties = {
|
|
112
|
+
...(carrier.properties ?? {}),
|
|
113
|
+
revpar: result.revpar,
|
|
114
|
+
revparAdr: result.adr,
|
|
115
|
+
revparOccupancyRate: result.occupancyRate,
|
|
116
|
+
};
|
|
117
|
+
const summary: RevparSolvedEvent = {
|
|
118
|
+
nodeId,
|
|
119
|
+
revpar: result.revpar,
|
|
120
|
+
adr: result.adr,
|
|
121
|
+
occupancyRate: result.occupancyRate,
|
|
122
|
+
availableRooms: result.availableRooms,
|
|
123
|
+
occupiedRooms: result.occupiedRooms,
|
|
124
|
+
totalRevenue: result.totalRevenue,
|
|
125
|
+
};
|
|
126
|
+
context.setState?.({ [`revpar:${nodeId}`]: summary });
|
|
127
|
+
context.emit('revpar_solved', summary);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
context.emit('revpar_error', {
|
|
130
|
+
nodeId,
|
|
131
|
+
error: error instanceof Error ? error.message : String(error),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Behavioral handler for the travel-hospitality `revpar` trait. Runs the
|
|
138
|
+
* deterministic RevPAR analytic solver whenever an orb carrying the trait is
|
|
139
|
+
* attached (and on each per-frame update), writing the result onto the node and
|
|
140
|
+
* emitting `revpar_solved` / `revpar_error`.
|
|
141
|
+
*/
|
|
142
|
+
export const revparHandler: RuntimeTraitHandler = {
|
|
143
|
+
name: 'revpar',
|
|
144
|
+
onAttach: (node, config, context) => solveOntoNode(node, config, context),
|
|
145
|
+
onUpdate: (node, config, context) => solveOntoNode(node, config, context),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** A runtime that can register behavioral trait handlers. */
|
|
149
|
+
export interface TraitRegistrar {
|
|
150
|
+
registerTrait(name: string, handler: unknown): void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Register travel-hospitality behavioral trait handlers into a runtime that
|
|
155
|
+
* exposes `registerTrait(name, handler)` — e.g. `@holoscript/core`
|
|
156
|
+
* HoloScriptRuntime. This is the consumption path the dead-wired tier was
|
|
157
|
+
* missing: after this call the runtime's directive dispatch (applyDirectives /
|
|
158
|
+
* updateTraits) will invoke the RevPAR solver for `@revpar` orbs.
|
|
159
|
+
*/
|
|
160
|
+
export function registerTravelHospitalityTraitHandlers(registrar: TraitRegistrar): void {
|
|
161
|
+
registerPluginTraits(registrar, TRAVEL_HOSPITALITY_PLUGIN_ID, [revparHandler]);
|
|
162
|
+
}
|
|
@@ -1,25 +1,73 @@
|
|
|
1
1
|
/** @itinerary Trait — Trip itinerary planning. @trait itinerary */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface ItineraryItem {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export interface ItineraryItem {
|
|
5
|
+
id: string;
|
|
6
|
+
day: number;
|
|
7
|
+
time: string;
|
|
8
|
+
activity: string;
|
|
9
|
+
location: string;
|
|
10
|
+
durationMin: number;
|
|
11
|
+
bookingRef?: string;
|
|
12
|
+
notes?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ItineraryConfig {
|
|
15
|
+
tripName: string;
|
|
16
|
+
startDate: string;
|
|
17
|
+
endDate: string;
|
|
18
|
+
travelers: number;
|
|
19
|
+
items: ItineraryItem[];
|
|
20
|
+
}
|
|
21
|
+
export interface ItineraryState {
|
|
22
|
+
currentDay: number;
|
|
23
|
+
completedItems: string[];
|
|
24
|
+
totalDays: number;
|
|
25
|
+
}
|
|
7
26
|
|
|
8
|
-
const defaultConfig: ItineraryConfig = {
|
|
27
|
+
const defaultConfig: ItineraryConfig = {
|
|
28
|
+
tripName: '',
|
|
29
|
+
startDate: '',
|
|
30
|
+
endDate: '',
|
|
31
|
+
travelers: 1,
|
|
32
|
+
items: [],
|
|
33
|
+
};
|
|
9
34
|
|
|
10
35
|
export function createItineraryHandler(): TraitHandler<ItineraryConfig> {
|
|
11
|
-
return {
|
|
36
|
+
return {
|
|
37
|
+
name: 'itinerary',
|
|
38
|
+
defaultConfig,
|
|
12
39
|
onAttach(n: HSPlusNode, c: ItineraryConfig, ctx: TraitContext) {
|
|
13
|
-
const days =
|
|
40
|
+
const days =
|
|
41
|
+
c.startDate && c.endDate
|
|
42
|
+
? Math.max(
|
|
43
|
+
1,
|
|
44
|
+
Math.ceil(
|
|
45
|
+
(new Date(c.endDate).getTime() - new Date(c.startDate).getTime()) / 86400000
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
: 1;
|
|
14
49
|
n.__itinState = { currentDay: 1, completedItems: [], totalDays: days };
|
|
15
50
|
ctx.emit?.('itinerary:planned', { days, items: c.items.length });
|
|
16
51
|
},
|
|
17
|
-
onDetach(n: HSPlusNode, _c: ItineraryConfig, ctx: TraitContext) {
|
|
52
|
+
onDetach(n: HSPlusNode, _c: ItineraryConfig, ctx: TraitContext) {
|
|
53
|
+
delete n.__itinState;
|
|
54
|
+
ctx.emit?.('itinerary:removed');
|
|
55
|
+
},
|
|
18
56
|
onUpdate() {},
|
|
19
57
|
onEvent(n: HSPlusNode, _c: ItineraryConfig, ctx: TraitContext, e: TraitEvent) {
|
|
20
|
-
const s = n.__itinState as ItineraryState | undefined;
|
|
21
|
-
if (
|
|
22
|
-
if (e.type === 'itinerary:
|
|
58
|
+
const s = n.__itinState as ItineraryState | undefined;
|
|
59
|
+
if (!s) return;
|
|
60
|
+
if (e.type === 'itinerary:complete_item') {
|
|
61
|
+
const id = e.payload?.itemId as string;
|
|
62
|
+
if (!s.completedItems.includes(id)) {
|
|
63
|
+
s.completedItems.push(id);
|
|
64
|
+
ctx.emit?.('itinerary:item_done', { id, completed: s.completedItems.length });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (e.type === 'itinerary:next_day') {
|
|
68
|
+
s.currentDay = Math.min(s.totalDays, s.currentDay + 1);
|
|
69
|
+
ctx.emit?.('itinerary:day_changed', { day: s.currentDay });
|
|
70
|
+
}
|
|
23
71
|
},
|
|
24
72
|
};
|
|
25
73
|
}
|
|
@@ -1,24 +1,67 @@
|
|
|
1
1
|
/** @rate_management Trait — Dynamic pricing and rate control. @trait rate_management */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface RatePeriod {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export interface RatePeriod {
|
|
5
|
+
startDate: string;
|
|
6
|
+
endDate: string;
|
|
7
|
+
rate: number;
|
|
8
|
+
minStay: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RateManagementConfig {
|
|
11
|
+
baseRate: number;
|
|
12
|
+
currency: string;
|
|
13
|
+
seasons: RatePeriod[];
|
|
14
|
+
weekendMultiplier: number;
|
|
15
|
+
dynamicPricingEnabled: boolean;
|
|
16
|
+
minRate: number;
|
|
17
|
+
maxRate: number;
|
|
18
|
+
}
|
|
19
|
+
export interface RateManagementState {
|
|
20
|
+
currentRate: number;
|
|
21
|
+
occupancyPercent: number;
|
|
22
|
+
revenueToday: number;
|
|
23
|
+
}
|
|
7
24
|
|
|
8
|
-
const defaultConfig: RateManagementConfig = {
|
|
25
|
+
const defaultConfig: RateManagementConfig = {
|
|
26
|
+
baseRate: 100,
|
|
27
|
+
currency: 'USD',
|
|
28
|
+
seasons: [],
|
|
29
|
+
weekendMultiplier: 1.2,
|
|
30
|
+
dynamicPricingEnabled: false,
|
|
31
|
+
minRate: 50,
|
|
32
|
+
maxRate: 500,
|
|
33
|
+
};
|
|
9
34
|
|
|
10
35
|
export function createRateManagementHandler(): TraitHandler<RateManagementConfig> {
|
|
11
|
-
return {
|
|
12
|
-
|
|
13
|
-
|
|
36
|
+
return {
|
|
37
|
+
name: 'rate_management',
|
|
38
|
+
defaultConfig,
|
|
39
|
+
onAttach(n: HSPlusNode, c: RateManagementConfig, ctx: TraitContext) {
|
|
40
|
+
n.__rateState = { currentRate: c.baseRate, occupancyPercent: 0, revenueToday: 0 };
|
|
41
|
+
ctx.emit?.('rate:configured', { base: c.baseRate });
|
|
42
|
+
},
|
|
43
|
+
onDetach(n: HSPlusNode, _c: RateManagementConfig, ctx: TraitContext) {
|
|
44
|
+
delete n.__rateState;
|
|
45
|
+
ctx.emit?.('rate:removed');
|
|
46
|
+
},
|
|
14
47
|
onUpdate() {},
|
|
15
48
|
onEvent(n: HSPlusNode, c: RateManagementConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
-
const s = n.__rateState as RateManagementState | undefined;
|
|
49
|
+
const s = n.__rateState as RateManagementState | undefined;
|
|
50
|
+
if (!s) return;
|
|
17
51
|
if (e.type === 'rate:update_occupancy') {
|
|
18
52
|
s.occupancyPercent = (e.payload?.occupancy as number) ?? 0;
|
|
19
|
-
if (c.dynamicPricingEnabled) {
|
|
53
|
+
if (c.dynamicPricingEnabled) {
|
|
54
|
+
s.currentRate = Math.min(
|
|
55
|
+
c.maxRate,
|
|
56
|
+
Math.max(c.minRate, c.baseRate * (1 + s.occupancyPercent / 100))
|
|
57
|
+
);
|
|
58
|
+
ctx.emit?.('rate:adjusted', { rate: s.currentRate, occupancy: s.occupancyPercent });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (e.type === 'rate:record_sale') {
|
|
62
|
+
s.revenueToday += s.currentRate;
|
|
63
|
+
ctx.emit?.('rate:sale_recorded', { revenue: s.revenueToday });
|
|
20
64
|
}
|
|
21
|
-
if (e.type === 'rate:record_sale') { s.revenueToday += s.currentRate; ctx.emit?.('rate:sale_recorded', { revenue: s.revenueToday }); }
|
|
22
65
|
},
|
|
23
66
|
};
|
|
24
67
|
}
|
|
@@ -1,27 +1,84 @@
|
|
|
1
1
|
/** @reservation Trait — Booking and reservation management. @trait reservation */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export type ReservationStatus =
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export type ReservationStatus =
|
|
5
|
+
| 'pending'
|
|
6
|
+
| 'confirmed'
|
|
7
|
+
| 'checked_in'
|
|
8
|
+
| 'checked_out'
|
|
9
|
+
| 'cancelled'
|
|
10
|
+
| 'no_show';
|
|
11
|
+
export interface ReservationConfig {
|
|
12
|
+
confirmationNumber: string;
|
|
13
|
+
guestName: string;
|
|
14
|
+
roomType: string;
|
|
15
|
+
checkIn: string;
|
|
16
|
+
checkOut: string;
|
|
17
|
+
guests: number;
|
|
18
|
+
ratePerNight: number;
|
|
19
|
+
currency: string;
|
|
20
|
+
specialRequests?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ReservationState {
|
|
23
|
+
status: ReservationStatus;
|
|
24
|
+
totalNights: number;
|
|
25
|
+
totalCost: number;
|
|
26
|
+
}
|
|
7
27
|
|
|
8
|
-
const defaultConfig: ReservationConfig = {
|
|
28
|
+
const defaultConfig: ReservationConfig = {
|
|
29
|
+
confirmationNumber: '',
|
|
30
|
+
guestName: '',
|
|
31
|
+
roomType: 'standard',
|
|
32
|
+
checkIn: '',
|
|
33
|
+
checkOut: '',
|
|
34
|
+
guests: 1,
|
|
35
|
+
ratePerNight: 0,
|
|
36
|
+
currency: 'USD',
|
|
37
|
+
};
|
|
9
38
|
|
|
10
39
|
export function createReservationHandler(): TraitHandler<ReservationConfig> {
|
|
11
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
name: 'reservation',
|
|
42
|
+
defaultConfig,
|
|
12
43
|
onAttach(n: HSPlusNode, c: ReservationConfig, ctx: TraitContext) {
|
|
13
|
-
const nights =
|
|
14
|
-
|
|
44
|
+
const nights =
|
|
45
|
+
c.checkIn && c.checkOut
|
|
46
|
+
? Math.max(
|
|
47
|
+
1,
|
|
48
|
+
Math.ceil((new Date(c.checkOut).getTime() - new Date(c.checkIn).getTime()) / 86400000)
|
|
49
|
+
)
|
|
50
|
+
: 1;
|
|
51
|
+
n.__resState = {
|
|
52
|
+
status: 'pending' as ReservationStatus,
|
|
53
|
+
totalNights: nights,
|
|
54
|
+
totalCost: nights * c.ratePerNight,
|
|
55
|
+
};
|
|
15
56
|
ctx.emit?.('reservation:created', { confirmation: c.confirmationNumber, nights });
|
|
16
57
|
},
|
|
17
|
-
onDetach(n: HSPlusNode, _c: ReservationConfig, ctx: TraitContext) {
|
|
58
|
+
onDetach(n: HSPlusNode, _c: ReservationConfig, ctx: TraitContext) {
|
|
59
|
+
delete n.__resState;
|
|
60
|
+
ctx.emit?.('reservation:removed');
|
|
61
|
+
},
|
|
18
62
|
onUpdate() {},
|
|
19
63
|
onEvent(n: HSPlusNode, _c: ReservationConfig, ctx: TraitContext, e: TraitEvent) {
|
|
20
|
-
const s = n.__resState as ReservationState | undefined;
|
|
21
|
-
if (
|
|
22
|
-
if (e.type === 'reservation:
|
|
23
|
-
|
|
24
|
-
|
|
64
|
+
const s = n.__resState as ReservationState | undefined;
|
|
65
|
+
if (!s) return;
|
|
66
|
+
if (e.type === 'reservation:confirm') {
|
|
67
|
+
s.status = 'confirmed';
|
|
68
|
+
ctx.emit?.('reservation:confirmed');
|
|
69
|
+
}
|
|
70
|
+
if (e.type === 'reservation:check_in') {
|
|
71
|
+
s.status = 'checked_in';
|
|
72
|
+
ctx.emit?.('reservation:checked_in');
|
|
73
|
+
}
|
|
74
|
+
if (e.type === 'reservation:check_out') {
|
|
75
|
+
s.status = 'checked_out';
|
|
76
|
+
ctx.emit?.('reservation:checked_out', { total: s.totalCost });
|
|
77
|
+
}
|
|
78
|
+
if (e.type === 'reservation:cancel') {
|
|
79
|
+
s.status = 'cancelled';
|
|
80
|
+
ctx.emit?.('reservation:cancelled');
|
|
81
|
+
}
|
|
25
82
|
},
|
|
26
83
|
};
|
|
27
84
|
}
|
package/src/traits/types.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
export interface HSPlusNode {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export interface HSPlusNode {
|
|
2
|
+
id?: string;
|
|
3
|
+
properties?: Record<string, unknown>;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface TraitContext {
|
|
7
|
+
emit?: (event: string, payload?: unknown) => void;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface TraitEvent {
|
|
11
|
+
type: string;
|
|
12
|
+
payload?: Record<string, unknown>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface TraitHandler<T = unknown> {
|
|
16
|
+
name: string;
|
|
17
|
+
defaultConfig: T;
|
|
18
|
+
onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
19
|
+
onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
20
|
+
onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void;
|
|
21
|
+
onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void;
|
|
22
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.json",
|
|
3
|
+
"compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true },
|
|
4
|
+
"include": ["src"]
|
|
5
|
+
}
|
package/vitest.config.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
2
3
|
|
|
4
|
+
// resolve.alias is REQUIRED: without it, `@holoscript/core/runtime` resolves to
|
|
5
|
+
// the STALE built dist and registerPluginTraits is missing ("not a function").
|
|
6
|
+
// Point the subpath imports at core/engine source so the runtime barrel
|
|
7
|
+
// (core/src/runtime.ts) and shared registrar resolve from source. Mirrors
|
|
8
|
+
// government-civic-plugin/vitest.config.ts.
|
|
3
9
|
export default defineConfig({
|
|
10
|
+
resolve: {
|
|
11
|
+
alias: {
|
|
12
|
+
'@holoscript/engine': resolve(__dirname, '../../engine/src'),
|
|
13
|
+
'@holoscript/core': resolve(__dirname, '../../core/src'),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
4
16
|
test: {
|
|
5
17
|
globals: true,
|
|
6
18
|
environment: 'node',
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|