fenne 1.0.0 → 1.0.1
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/README.md +97 -0
- package/index.js +684 -0
- package/package.json +16 -3
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# fenne
|
|
2
|
+
|
|
3
|
+
Zero-dependency CommonJS helpers for appointment-studio floor operations: station time blocks, walk-in queues, booking packs, operator skill matching, paced async lanes, cash-drawer ledgers, and overlapping booking scans.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install fenne
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const {
|
|
15
|
+
coalesceStationBlocks,
|
|
16
|
+
rankFloorQueue,
|
|
17
|
+
packStationBookings,
|
|
18
|
+
matchOperatorSkills,
|
|
19
|
+
runStationLanes,
|
|
20
|
+
foldDrawerEvents,
|
|
21
|
+
detectBookingCollisions,
|
|
22
|
+
} = require("fenne");
|
|
23
|
+
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
|
|
26
|
+
const blocks = coalesceStationBlocks([
|
|
27
|
+
{ stationId: "chair-a", operatorId: "op-1", start: now, end: now + 1_800_000, serviceId: "cut" },
|
|
28
|
+
{ stationId: "chair-a", operatorId: "op-1", start: now + 1_800_000, end: now + 3_600_000, serviceId: "style" },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const queue = rankFloorQueue(
|
|
32
|
+
[
|
|
33
|
+
{ id: "t-12", kind: "walkin", joinedAt: now - 20_000, slaMinutes: 10, serviceMin: 45, visits: 4 },
|
|
34
|
+
{ id: "t-13", kind: "consult", joinedAt: now, slaMinutes: 30, serviceMin: 15, visits: 0 },
|
|
35
|
+
],
|
|
36
|
+
now
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const packed = packStationBookings(
|
|
40
|
+
[{ id: "b-1", stationId: "chair-a", durationMin: 30, earliest: now, latest: now + 3_600_000, bufferMin: 5 }],
|
|
41
|
+
[{ id: "chair-a", open: now, close: now + 8 * 3_600_000 }]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const roster = matchOperatorSkills(
|
|
45
|
+
{ skills: ["cut"], languages: ["en"], durationMin: 30 },
|
|
46
|
+
[{ id: "op-1", skills: ["cut", "color"], languages: ["en"], open: true, load: 1 }]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
runStationLanes([() => Promise.resolve("notify-1"), () => Promise.resolve("notify-2")], { width: 2 }).then((lane) => {
|
|
50
|
+
const drawer = foldDrawerEvents([
|
|
51
|
+
{ type: "open", drawerId: "front", at: now, amount: 120, actorId: "op-1" },
|
|
52
|
+
{ type: "sale", drawerId: "front", at: now + 1, amount: 48 },
|
|
53
|
+
{ type: "tip", drawerId: "front", at: now + 2, amount: 8 },
|
|
54
|
+
]);
|
|
55
|
+
const clashes = detectBookingCollisions([
|
|
56
|
+
{ id: "x", stationId: "chair-a", operatorId: "op-1", start: now, end: now + 1_800_000 },
|
|
57
|
+
{ id: "y", stationId: "chair-a", operatorId: "op-2", start: now + 600_000, end: now + 2_400_000 },
|
|
58
|
+
]);
|
|
59
|
+
console.log(blocks, queue[0].id, packed.assigned, roster[0].operatorId, lane.ok, drawer, clashes);
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
All times are finite epoch milliseconds. Invalid input throws `TypeError` or `RangeError`. Objects and arrays passed in are not mutated.
|
|
66
|
+
|
|
67
|
+
### `coalesceStationBlocks(blocks)`
|
|
68
|
+
|
|
69
|
+
Collapse overlapping or adjacent station blocks that share `stationId` and `operatorId`. Optional `serviceId` values are collected without duplicates.
|
|
70
|
+
|
|
71
|
+
### `rankFloorQueue(tickets, now)`
|
|
72
|
+
|
|
73
|
+
Score floor tickets from `booked|walkin|waitlist|consult` kind, remaining wait SLA, service minutes, visit count, and overdue penalty. Returns a new array sorted by score descending.
|
|
74
|
+
|
|
75
|
+
### `packStationBookings(requests, stations)`
|
|
76
|
+
|
|
77
|
+
Greedy-pack bookings onto stations with an open/close window. Optional `bufferMin` keeps a gap after each assigned booking. Returns `{ assigned, rejected }`.
|
|
78
|
+
|
|
79
|
+
### `matchOperatorSkills(request, operators)`
|
|
80
|
+
|
|
81
|
+
Score operators against required `skills` and optional `languages`. Any missing required skill yields score `0`. Results are sorted by score descending.
|
|
82
|
+
|
|
83
|
+
### `runStationLanes(tasks, options?)`
|
|
84
|
+
|
|
85
|
+
Run promise factories with a bounded `width` (default `2`). Resolves `{ ok, failed }` in original index order. A rejected task does not abort the rest.
|
|
86
|
+
|
|
87
|
+
### `foldDrawerEvents(events)`
|
|
88
|
+
|
|
89
|
+
Fold `open|sale|tip|payout|close` events into per-drawer balances. A later `open` after `close` resets sales, tips, and payouts for that drawer.
|
|
90
|
+
|
|
91
|
+
### `detectBookingCollisions(bookings)`
|
|
92
|
+
|
|
93
|
+
Find pairs that overlap on the same station, the same operator, or both. Touching edges are not collisions.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
ISC
|
package/index.js
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Appointment studio helpers: station time blocks, floor queues,
|
|
5
|
+
* booking packs, operator skill matching, paced lanes, drawer ledgers,
|
|
6
|
+
* and overlapping booking scans.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} name
|
|
11
|
+
* @param {*} value
|
|
12
|
+
* @param {string} expected
|
|
13
|
+
* @returns {never}
|
|
14
|
+
*/
|
|
15
|
+
function failType(name, value, expected) {
|
|
16
|
+
const actual = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
17
|
+
throw new TypeError(name + " must be " + expected + ", got " + actual);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @param {*} value
|
|
23
|
+
*/
|
|
24
|
+
function assertArray(name, value) {
|
|
25
|
+
if (!Array.isArray(value)) {
|
|
26
|
+
failType(name, value, "an array");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {*} value
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isPlainObject(value) {
|
|
35
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @param {*} value
|
|
41
|
+
*/
|
|
42
|
+
function assertPlainObject(name, value) {
|
|
43
|
+
if (!isPlainObject(value)) {
|
|
44
|
+
failType(name, value, "a plain object");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} name
|
|
50
|
+
* @param {*} value
|
|
51
|
+
*/
|
|
52
|
+
function assertFunction(name, value) {
|
|
53
|
+
if (typeof value !== "function") {
|
|
54
|
+
failType(name, value, "a function");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} name
|
|
60
|
+
* @param {*} value
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
function assertEpoch(name, value) {
|
|
64
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
65
|
+
failType(name, value, "a finite epoch millisecond");
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} name
|
|
72
|
+
* @param {*} value
|
|
73
|
+
* @param {number} min
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
function assertFiniteNumber(name, value, min) {
|
|
77
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
78
|
+
failType(name, value, "a finite number");
|
|
79
|
+
}
|
|
80
|
+
if (value < min) {
|
|
81
|
+
throw new RangeError(name + " must be >= " + min + ", got " + value);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} name
|
|
88
|
+
* @param {*} value
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function assertNonEmptyString(name, value) {
|
|
92
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
93
|
+
failType(name, value, "a non-empty string");
|
|
94
|
+
}
|
|
95
|
+
return value.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} name
|
|
100
|
+
* @param {*} value
|
|
101
|
+
* @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
function optionalStringList(name, value) {
|
|
104
|
+
if (value == null) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
assertArray(name, value);
|
|
108
|
+
const out = [];
|
|
109
|
+
const seen = Object.create(null);
|
|
110
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
111
|
+
const item = assertNonEmptyString(name + "[" + i + "]", value[i]);
|
|
112
|
+
if (!seen[item]) {
|
|
113
|
+
seen[item] = true;
|
|
114
|
+
out.push(item);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Merge overlapping or touching station blocks that share station and operator.
|
|
122
|
+
* Adjacent edges (end === next.start) collapse. Input is copied, never mutated.
|
|
123
|
+
*
|
|
124
|
+
* @param {Array<{ stationId: string, operatorId: string, start: number, end: number, serviceId?: string }>} blocks
|
|
125
|
+
* @returns {Array<{ stationId: string, operatorId: string, start: number, end: number, serviceIds: string[] }>}
|
|
126
|
+
*/
|
|
127
|
+
function coalesceStationBlocks(blocks) {
|
|
128
|
+
assertArray("blocks", blocks);
|
|
129
|
+
|
|
130
|
+
/** @type {Record<string, Array<{ stationId: string, operatorId: string, start: number, end: number, serviceIds: string[] }>>} */
|
|
131
|
+
const groups = Object.create(null);
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
134
|
+
const row = blocks[i];
|
|
135
|
+
assertPlainObject("blocks[" + i + "]", row);
|
|
136
|
+
const stationId = assertNonEmptyString("blocks[" + i + "].stationId", row.stationId);
|
|
137
|
+
const operatorId = assertNonEmptyString("blocks[" + i + "].operatorId", row.operatorId);
|
|
138
|
+
const start = assertEpoch("blocks[" + i + "].start", row.start);
|
|
139
|
+
const end = assertEpoch("blocks[" + i + "].end", row.end);
|
|
140
|
+
if (end <= start) {
|
|
141
|
+
throw new RangeError("blocks[" + i + "].end must be greater than start");
|
|
142
|
+
}
|
|
143
|
+
const serviceIds = [];
|
|
144
|
+
if (row.serviceId != null) {
|
|
145
|
+
serviceIds.push(assertNonEmptyString("blocks[" + i + "].serviceId", row.serviceId));
|
|
146
|
+
}
|
|
147
|
+
const key = stationId + "\0" + operatorId;
|
|
148
|
+
if (!groups[key]) {
|
|
149
|
+
groups[key] = [];
|
|
150
|
+
}
|
|
151
|
+
groups[key].push({ stationId, operatorId, start, end, serviceIds });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const merged = [];
|
|
155
|
+
const keys = Object.keys(groups);
|
|
156
|
+
for (let g = 0; g < keys.length; g += 1) {
|
|
157
|
+
const list = groups[keys[g]];
|
|
158
|
+
list.sort(function (a, b) {
|
|
159
|
+
return a.start - b.start || a.end - b.end;
|
|
160
|
+
});
|
|
161
|
+
let current = null;
|
|
162
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
163
|
+
const next = list[i];
|
|
164
|
+
if (current === null || next.start > current.end) {
|
|
165
|
+
current = {
|
|
166
|
+
stationId: next.stationId,
|
|
167
|
+
operatorId: next.operatorId,
|
|
168
|
+
start: next.start,
|
|
169
|
+
end: next.end,
|
|
170
|
+
serviceIds: next.serviceIds.slice(),
|
|
171
|
+
};
|
|
172
|
+
merged.push(current);
|
|
173
|
+
} else {
|
|
174
|
+
if (next.end > current.end) {
|
|
175
|
+
current.end = next.end;
|
|
176
|
+
}
|
|
177
|
+
for (let s = 0; s < next.serviceIds.length; s += 1) {
|
|
178
|
+
if (current.serviceIds.indexOf(next.serviceIds[s]) === -1) {
|
|
179
|
+
current.serviceIds.push(next.serviceIds[s]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
merged.sort(function (a, b) {
|
|
187
|
+
return a.start - b.start || a.stationId.localeCompare(b.stationId) || a.operatorId.localeCompare(b.operatorId);
|
|
188
|
+
});
|
|
189
|
+
return merged;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const QUEUE_KIND_WEIGHT = {
|
|
193
|
+
booked: 80,
|
|
194
|
+
walkin: 45,
|
|
195
|
+
waitlist: 25,
|
|
196
|
+
consult: 15,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Rank floor tickets by remaining wait SLA, kind, service minutes, and visit count.
|
|
201
|
+
* Overdue tickets receive a growing penalty. Returns a new array sorted by score desc.
|
|
202
|
+
*
|
|
203
|
+
* @param {Array<{ id: string, kind: "booked"|"walkin"|"waitlist"|"consult", joinedAt: number, slaMinutes: number, serviceMin?: number, visits?: number, stationId?: string }>} tickets
|
|
204
|
+
* @param {number} now
|
|
205
|
+
* @returns {Array<{ id: string, score: number, overdueMs: number, remainingMs: number, kind: string, stationId: string|null }>}
|
|
206
|
+
*/
|
|
207
|
+
function rankFloorQueue(tickets, now) {
|
|
208
|
+
assertArray("tickets", tickets);
|
|
209
|
+
assertEpoch("now", now);
|
|
210
|
+
|
|
211
|
+
const ranked = [];
|
|
212
|
+
for (let i = 0; i < tickets.length; i += 1) {
|
|
213
|
+
const row = tickets[i];
|
|
214
|
+
assertPlainObject("tickets[" + i + "]", row);
|
|
215
|
+
const id = assertNonEmptyString("tickets[" + i + "].id", row.id);
|
|
216
|
+
const kind = assertNonEmptyString("tickets[" + i + "].kind", row.kind);
|
|
217
|
+
if (!Object.prototype.hasOwnProperty.call(QUEUE_KIND_WEIGHT, kind)) {
|
|
218
|
+
throw new RangeError("tickets[" + i + "].kind must be booked|walkin|waitlist|consult");
|
|
219
|
+
}
|
|
220
|
+
const joinedAt = assertEpoch("tickets[" + i + "].joinedAt", row.joinedAt);
|
|
221
|
+
const slaMinutes = assertFiniteNumber("tickets[" + i + "].slaMinutes", row.slaMinutes, 1);
|
|
222
|
+
const serviceMin =
|
|
223
|
+
row.serviceMin == null ? 30 : assertFiniteNumber("tickets[" + i + "].serviceMin", row.serviceMin, 1);
|
|
224
|
+
const visits = row.visits == null ? 0 : assertFiniteNumber("tickets[" + i + "].visits", row.visits, 0);
|
|
225
|
+
const slaMs = slaMinutes * 60 * 1000;
|
|
226
|
+
const dueAt = joinedAt + slaMs;
|
|
227
|
+
const remainingMs = dueAt - now;
|
|
228
|
+
const overdueMs = remainingMs < 0 ? -remainingMs : 0;
|
|
229
|
+
const waitPressure = (now - joinedAt) / slaMs;
|
|
230
|
+
const durationLoad = Math.log(serviceMin) / Math.LN10 + 1;
|
|
231
|
+
const loyalty = Math.min(20, visits * 2);
|
|
232
|
+
const overdueBonus = overdueMs > 0 ? 30 + Math.min(40, overdueMs / 60000) : 0;
|
|
233
|
+
const score = QUEUE_KIND_WEIGHT[kind] + waitPressure * 35 + durationLoad * 8 + loyalty + overdueBonus;
|
|
234
|
+
ranked.push({
|
|
235
|
+
id: id,
|
|
236
|
+
score: Math.round(score * 1000) / 1000,
|
|
237
|
+
overdueMs: overdueMs,
|
|
238
|
+
remainingMs: remainingMs,
|
|
239
|
+
kind: kind,
|
|
240
|
+
stationId: row.stationId == null ? null : assertNonEmptyString("tickets[" + i + "].stationId", row.stationId),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
ranked.sort(function (a, b) {
|
|
245
|
+
return b.score - a.score || a.remainingMs - b.remainingMs || a.id.localeCompare(b.id);
|
|
246
|
+
});
|
|
247
|
+
return ranked;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Greedy-pack service bookings onto stations with open/close windows and a buffer.
|
|
252
|
+
* Occupancy never exceeds one active booking per station after the buffer gap.
|
|
253
|
+
*
|
|
254
|
+
* @param {Array<{ id: string, stationId: string, durationMin: number, earliest: number, latest: number, bufferMin?: number }>} requests
|
|
255
|
+
* @param {Array<{ id: string, open: number, close: number }>} stations
|
|
256
|
+
* @returns {{ assigned: Array<{ requestId: string, stationId: string, start: number, end: number }>, rejected: Array<{ requestId: string, reason: string }> }}
|
|
257
|
+
*/
|
|
258
|
+
function packStationBookings(requests, stations) {
|
|
259
|
+
assertArray("requests", requests);
|
|
260
|
+
assertArray("stations", stations);
|
|
261
|
+
|
|
262
|
+
/** @type {Record<string, { id: string, open: number, close: number, occupied: Array<{ start: number, end: number }> }>} */
|
|
263
|
+
const floor = Object.create(null);
|
|
264
|
+
for (let i = 0; i < stations.length; i += 1) {
|
|
265
|
+
const row = stations[i];
|
|
266
|
+
assertPlainObject("stations[" + i + "]", row);
|
|
267
|
+
const id = assertNonEmptyString("stations[" + i + "].id", row.id);
|
|
268
|
+
if (floor[id]) {
|
|
269
|
+
throw new RangeError("duplicate station id " + id);
|
|
270
|
+
}
|
|
271
|
+
const open = assertEpoch("stations[" + i + "].open", row.open);
|
|
272
|
+
const close = assertEpoch("stations[" + i + "].close", row.close);
|
|
273
|
+
if (close <= open) {
|
|
274
|
+
throw new RangeError("stations[" + i + "].close must be greater than open");
|
|
275
|
+
}
|
|
276
|
+
floor[id] = { id: id, open: open, close: close, occupied: [] };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const assigned = [];
|
|
280
|
+
const rejected = [];
|
|
281
|
+
|
|
282
|
+
const ordered = requests.map(function (row, index) {
|
|
283
|
+
assertPlainObject("requests[" + index + "]", row);
|
|
284
|
+
const id = assertNonEmptyString("requests[" + index + "].id", row.id);
|
|
285
|
+
const stationId = assertNonEmptyString("requests[" + index + "].stationId", row.stationId);
|
|
286
|
+
const durationMin = assertFiniteNumber("requests[" + index + "].durationMin", row.durationMin, 1);
|
|
287
|
+
const earliest = assertEpoch("requests[" + index + "].earliest", row.earliest);
|
|
288
|
+
const latest = assertEpoch("requests[" + index + "].latest", row.latest);
|
|
289
|
+
if (latest <= earliest) {
|
|
290
|
+
throw new RangeError("requests[" + index + "].latest must be greater than earliest");
|
|
291
|
+
}
|
|
292
|
+
const bufferMin =
|
|
293
|
+
row.bufferMin == null ? 0 : assertFiniteNumber("requests[" + index + "].bufferMin", row.bufferMin, 0);
|
|
294
|
+
return {
|
|
295
|
+
id: id,
|
|
296
|
+
stationId: stationId,
|
|
297
|
+
durationMs: durationMin * 60 * 1000,
|
|
298
|
+
earliest: earliest,
|
|
299
|
+
latest: latest,
|
|
300
|
+
bufferMs: bufferMin * 60 * 1000,
|
|
301
|
+
index: index,
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
ordered.sort(function (a, b) {
|
|
306
|
+
return a.earliest - b.earliest || a.durationMs - b.durationMs || a.id.localeCompare(b.id);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
for (let i = 0; i < ordered.length; i += 1) {
|
|
310
|
+
const req = ordered[i];
|
|
311
|
+
const station = floor[req.stationId];
|
|
312
|
+
if (!station) {
|
|
313
|
+
rejected.push({ requestId: req.id, reason: "unknown-station" });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const durationEndLimit = req.latest;
|
|
317
|
+
let placed = false;
|
|
318
|
+
let cursor = Math.max(req.earliest, station.open);
|
|
319
|
+
station.occupied.sort(function (a, b) {
|
|
320
|
+
return a.start - b.start;
|
|
321
|
+
});
|
|
322
|
+
const gaps = [];
|
|
323
|
+
let probe = station.open;
|
|
324
|
+
for (let o = 0; o < station.occupied.length; o += 1) {
|
|
325
|
+
const occ = station.occupied[o];
|
|
326
|
+
if (occ.start > probe) {
|
|
327
|
+
gaps.push({ start: probe, end: occ.start });
|
|
328
|
+
}
|
|
329
|
+
probe = Math.max(probe, occ.end);
|
|
330
|
+
}
|
|
331
|
+
if (probe < station.close) {
|
|
332
|
+
gaps.push({ start: probe, end: station.close });
|
|
333
|
+
}
|
|
334
|
+
for (let g = 0; g < gaps.length && !placed; g += 1) {
|
|
335
|
+
const gap = gaps[g];
|
|
336
|
+
const start = Math.max(cursor, gap.start);
|
|
337
|
+
const end = start + req.durationMs;
|
|
338
|
+
const paddedEnd = end + req.bufferMs;
|
|
339
|
+
if (end <= durationEndLimit && paddedEnd <= gap.end && end <= station.close && start >= station.open) {
|
|
340
|
+
station.occupied.push({ start: start, end: paddedEnd });
|
|
341
|
+
assigned.push({
|
|
342
|
+
requestId: req.id,
|
|
343
|
+
stationId: station.id,
|
|
344
|
+
start: start,
|
|
345
|
+
end: end,
|
|
346
|
+
});
|
|
347
|
+
placed = true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!placed) {
|
|
351
|
+
rejected.push({ requestId: req.id, reason: "no-fit" });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
assigned.sort(function (a, b) {
|
|
356
|
+
return a.start - b.start || a.requestId.localeCompare(b.requestId);
|
|
357
|
+
});
|
|
358
|
+
return { assigned: assigned, rejected: rejected };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Score operators against a service request by required skills, languages, and open flag.
|
|
363
|
+
* Missing required skills zero the match. Returns a new array sorted by score desc.
|
|
364
|
+
*
|
|
365
|
+
* @param {{ skills: string[], languages?: string[], durationMin?: number }} request
|
|
366
|
+
* @param {Array<{ id: string, skills: string[], languages?: string[], open?: boolean, load?: number }>} operators
|
|
367
|
+
* @returns {Array<{ operatorId: string, score: number, missing: string[], open: boolean }>}
|
|
368
|
+
*/
|
|
369
|
+
function matchOperatorSkills(request, operators) {
|
|
370
|
+
assertPlainObject("request", request);
|
|
371
|
+
assertArray("operators", operators);
|
|
372
|
+
const required = optionalStringList("request.skills", request.skills);
|
|
373
|
+
if (required.length === 0) {
|
|
374
|
+
throw new RangeError("request.skills must contain at least one skill");
|
|
375
|
+
}
|
|
376
|
+
const wantLang = optionalStringList("request.languages", request.languages);
|
|
377
|
+
const durationMin =
|
|
378
|
+
request.durationMin == null ? 30 : assertFiniteNumber("request.durationMin", request.durationMin, 1);
|
|
379
|
+
|
|
380
|
+
const scored = [];
|
|
381
|
+
for (let i = 0; i < operators.length; i += 1) {
|
|
382
|
+
const row = operators[i];
|
|
383
|
+
assertPlainObject("operators[" + i + "]", row);
|
|
384
|
+
const id = assertNonEmptyString("operators[" + i + "].id", row.id);
|
|
385
|
+
const skills = optionalStringList("operators[" + i + "].skills", row.skills);
|
|
386
|
+
const languages = optionalStringList("operators[" + i + "].languages", row.languages);
|
|
387
|
+
const open = row.open == null ? true : row.open === true;
|
|
388
|
+
const load = row.load == null ? 0 : assertFiniteNumber("operators[" + i + "].load", row.load, 0);
|
|
389
|
+
const skillSet = Object.create(null);
|
|
390
|
+
for (let s = 0; s < skills.length; s += 1) {
|
|
391
|
+
skillSet[skills[s]] = true;
|
|
392
|
+
}
|
|
393
|
+
const missing = [];
|
|
394
|
+
let hit = 0;
|
|
395
|
+
for (let r = 0; r < required.length; r += 1) {
|
|
396
|
+
if (skillSet[required[r]]) {
|
|
397
|
+
hit += 1;
|
|
398
|
+
} else {
|
|
399
|
+
missing.push(required[r]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
let langHit = 0;
|
|
403
|
+
if (wantLang.length > 0) {
|
|
404
|
+
const langSet = Object.create(null);
|
|
405
|
+
for (let l = 0; l < languages.length; l += 1) {
|
|
406
|
+
langSet[languages[l]] = true;
|
|
407
|
+
}
|
|
408
|
+
for (let w = 0; w < wantLang.length; w += 1) {
|
|
409
|
+
if (langSet[wantLang[w]]) {
|
|
410
|
+
langHit += 1;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const coverage = hit / required.length;
|
|
415
|
+
const langBonus = wantLang.length === 0 ? 8 : (langHit / wantLang.length) * 20;
|
|
416
|
+
const loadPenalty = Math.min(25, load * 5);
|
|
417
|
+
const durationBias = Math.min(10, durationMin / 15);
|
|
418
|
+
const score =
|
|
419
|
+
missing.length > 0 ? 0 : Math.round((coverage * 70 + langBonus + (open ? 12 : 0) + durationBias - loadPenalty) * 1000) / 1000;
|
|
420
|
+
scored.push({
|
|
421
|
+
operatorId: id,
|
|
422
|
+
score: score,
|
|
423
|
+
missing: missing,
|
|
424
|
+
open: open,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
scored.sort(function (a, b) {
|
|
429
|
+
return b.score - a.score || a.operatorId.localeCompare(b.operatorId);
|
|
430
|
+
});
|
|
431
|
+
return scored;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run async station-side tasks with a bounded lane width.
|
|
436
|
+
* Settled results keep input order. Does not mutate the task list.
|
|
437
|
+
*
|
|
438
|
+
* @param {Array<() => Promise<*>>} tasks
|
|
439
|
+
* @param {{ width?: number }} [options]
|
|
440
|
+
* @returns {Promise<{ ok: Array<{ index: number, value: * }>, failed: Array<{ index: number, error: Error }> }>}
|
|
441
|
+
*/
|
|
442
|
+
function runStationLanes(tasks, options) {
|
|
443
|
+
assertArray("tasks", tasks);
|
|
444
|
+
const opts = options == null ? {} : options;
|
|
445
|
+
assertPlainObject("options", opts);
|
|
446
|
+
const width = opts.width == null ? 2 : assertFiniteNumber("options.width", opts.width, 1);
|
|
447
|
+
if (!Number.isInteger(width)) {
|
|
448
|
+
throw new RangeError("options.width must be an integer");
|
|
449
|
+
}
|
|
450
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
451
|
+
assertFunction("tasks[" + i + "]", tasks[i]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return new Promise(function (resolve) {
|
|
455
|
+
const ok = [];
|
|
456
|
+
const failed = [];
|
|
457
|
+
let next = 0;
|
|
458
|
+
let inflight = 0;
|
|
459
|
+
let settled = 0;
|
|
460
|
+
const total = tasks.length;
|
|
461
|
+
|
|
462
|
+
if (total === 0) {
|
|
463
|
+
resolve({ ok: ok, failed: failed });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function finishOne() {
|
|
468
|
+
settled += 1;
|
|
469
|
+
inflight -= 1;
|
|
470
|
+
if (settled === total) {
|
|
471
|
+
ok.sort(function (a, b) {
|
|
472
|
+
return a.index - b.index;
|
|
473
|
+
});
|
|
474
|
+
failed.sort(function (a, b) {
|
|
475
|
+
return a.index - b.index;
|
|
476
|
+
});
|
|
477
|
+
resolve({ ok: ok, failed: failed });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
pump();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function pump() {
|
|
484
|
+
while (inflight < width && next < total) {
|
|
485
|
+
const index = next;
|
|
486
|
+
next += 1;
|
|
487
|
+
inflight += 1;
|
|
488
|
+
Promise.resolve()
|
|
489
|
+
.then(function () {
|
|
490
|
+
return tasks[index]();
|
|
491
|
+
})
|
|
492
|
+
.then(
|
|
493
|
+
function (value) {
|
|
494
|
+
ok.push({ index: index, value: value });
|
|
495
|
+
finishOne();
|
|
496
|
+
},
|
|
497
|
+
function (error) {
|
|
498
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
499
|
+
failed.push({ index: index, error: err });
|
|
500
|
+
finishOne();
|
|
501
|
+
}
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
pump();
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const DRAWER_TYPES = {
|
|
511
|
+
open: true,
|
|
512
|
+
sale: true,
|
|
513
|
+
tip: true,
|
|
514
|
+
payout: true,
|
|
515
|
+
close: true,
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Fold cash-drawer events into per-drawer running balances and a close snapshot.
|
|
520
|
+
* Events are copied and applied in time order. Input is not mutated.
|
|
521
|
+
*
|
|
522
|
+
* @param {Array<{ type: "open"|"sale"|"tip"|"payout"|"close", drawerId: string, at: number, amount?: number, actorId?: string }>} events
|
|
523
|
+
* @returns {Record<string, { drawerId: string, openedAt: number|null, closedAt: number|null, sales: number, tips: number, payouts: number, balance: number, actors: string[] }>}
|
|
524
|
+
*/
|
|
525
|
+
function foldDrawerEvents(events) {
|
|
526
|
+
assertArray("events", events);
|
|
527
|
+
|
|
528
|
+
const rows = [];
|
|
529
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
530
|
+
const row = events[i];
|
|
531
|
+
assertPlainObject("events[" + i + "]", row);
|
|
532
|
+
const type = assertNonEmptyString("events[" + i + "].type", row.type);
|
|
533
|
+
if (!DRAWER_TYPES[type]) {
|
|
534
|
+
throw new RangeError("events[" + i + "].type must be open|sale|tip|payout|close");
|
|
535
|
+
}
|
|
536
|
+
const drawerId = assertNonEmptyString("events[" + i + "].drawerId", row.drawerId);
|
|
537
|
+
const at = assertEpoch("events[" + i + "].at", row.at);
|
|
538
|
+
let amount = 0;
|
|
539
|
+
if (type === "sale" || type === "tip" || type === "payout") {
|
|
540
|
+
amount = assertFiniteNumber("events[" + i + "].amount", row.amount, 0);
|
|
541
|
+
} else if (row.amount != null) {
|
|
542
|
+
amount = assertFiniteNumber("events[" + i + "].amount", row.amount, 0);
|
|
543
|
+
}
|
|
544
|
+
const actorId =
|
|
545
|
+
row.actorId == null ? null : assertNonEmptyString("events[" + i + "].actorId", row.actorId);
|
|
546
|
+
rows.push({ type: type, drawerId: drawerId, at: at, amount: amount, actorId: actorId });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
rows.sort(function (a, b) {
|
|
550
|
+
return a.at - b.at || a.drawerId.localeCompare(b.drawerId);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
/** @type {Record<string, { drawerId: string, openedAt: number|null, closedAt: number|null, sales: number, tips: number, payouts: number, balance: number, actors: string[] }>} */
|
|
554
|
+
const ledgers = Object.create(null);
|
|
555
|
+
|
|
556
|
+
function ensure(drawerId) {
|
|
557
|
+
if (!ledgers[drawerId]) {
|
|
558
|
+
ledgers[drawerId] = {
|
|
559
|
+
drawerId: drawerId,
|
|
560
|
+
openedAt: null,
|
|
561
|
+
closedAt: null,
|
|
562
|
+
sales: 0,
|
|
563
|
+
tips: 0,
|
|
564
|
+
payouts: 0,
|
|
565
|
+
balance: 0,
|
|
566
|
+
actors: [],
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return ledgers[drawerId];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
573
|
+
const ev = rows[i];
|
|
574
|
+
const ledger = ensure(ev.drawerId);
|
|
575
|
+
if (ev.actorId && ledger.actors.indexOf(ev.actorId) === -1) {
|
|
576
|
+
ledger.actors.push(ev.actorId);
|
|
577
|
+
}
|
|
578
|
+
if (ev.type === "open") {
|
|
579
|
+
if (ledger.closedAt != null && ev.at >= ledger.closedAt) {
|
|
580
|
+
ledger.openedAt = ev.at;
|
|
581
|
+
ledger.closedAt = null;
|
|
582
|
+
ledger.sales = 0;
|
|
583
|
+
ledger.tips = 0;
|
|
584
|
+
ledger.payouts = 0;
|
|
585
|
+
ledger.balance = ev.amount;
|
|
586
|
+
ledger.actors = ev.actorId ? [ev.actorId] : [];
|
|
587
|
+
} else if (ledger.openedAt == null) {
|
|
588
|
+
ledger.openedAt = ev.at;
|
|
589
|
+
ledger.balance += ev.amount;
|
|
590
|
+
}
|
|
591
|
+
} else if (ev.type === "sale") {
|
|
592
|
+
ledger.sales += ev.amount;
|
|
593
|
+
ledger.balance += ev.amount;
|
|
594
|
+
} else if (ev.type === "tip") {
|
|
595
|
+
ledger.tips += ev.amount;
|
|
596
|
+
ledger.balance += ev.amount;
|
|
597
|
+
} else if (ev.type === "payout") {
|
|
598
|
+
ledger.payouts += ev.amount;
|
|
599
|
+
ledger.balance -= ev.amount;
|
|
600
|
+
} else if (ev.type === "close") {
|
|
601
|
+
ledger.closedAt = ev.at;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return ledgers;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Detect overlapping bookings that share a station or an operator.
|
|
610
|
+
* Touching edges (end === next.start) are not collisions. Input is not mutated.
|
|
611
|
+
*
|
|
612
|
+
* @param {Array<{ id: string, stationId: string, operatorId: string, start: number, end: number }>} bookings
|
|
613
|
+
* @returns {Array<{ leftId: string, rightId: string, shared: "station"|"operator"|"both", overlapMs: number }>}
|
|
614
|
+
*/
|
|
615
|
+
function detectBookingCollisions(bookings) {
|
|
616
|
+
assertArray("bookings", bookings);
|
|
617
|
+
|
|
618
|
+
const rows = [];
|
|
619
|
+
const seenIds = Object.create(null);
|
|
620
|
+
for (let i = 0; i < bookings.length; i += 1) {
|
|
621
|
+
const row = bookings[i];
|
|
622
|
+
assertPlainObject("bookings[" + i + "]", row);
|
|
623
|
+
const id = assertNonEmptyString("bookings[" + i + "].id", row.id);
|
|
624
|
+
if (seenIds[id]) {
|
|
625
|
+
throw new RangeError("duplicate booking id " + id);
|
|
626
|
+
}
|
|
627
|
+
seenIds[id] = true;
|
|
628
|
+
const stationId = assertNonEmptyString("bookings[" + i + "].stationId", row.stationId);
|
|
629
|
+
const operatorId = assertNonEmptyString("bookings[" + i + "].operatorId", row.operatorId);
|
|
630
|
+
const start = assertEpoch("bookings[" + i + "].start", row.start);
|
|
631
|
+
const end = assertEpoch("bookings[" + i + "].end", row.end);
|
|
632
|
+
if (end <= start) {
|
|
633
|
+
throw new RangeError("bookings[" + i + "].end must be greater than start");
|
|
634
|
+
}
|
|
635
|
+
rows.push({ id: id, stationId: stationId, operatorId: operatorId, start: start, end: end });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
rows.sort(function (a, b) {
|
|
639
|
+
return a.start - b.start || a.id.localeCompare(b.id);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const clashes = [];
|
|
643
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
644
|
+
const left = rows[i];
|
|
645
|
+
for (let j = i + 1; j < rows.length; j += 1) {
|
|
646
|
+
const right = rows[j];
|
|
647
|
+
if (right.start >= left.end) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const sameStation = left.stationId === right.stationId;
|
|
651
|
+
const sameOperator = left.operatorId === right.operatorId;
|
|
652
|
+
if (!sameStation && !sameOperator) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const overlapStart = Math.max(left.start, right.start);
|
|
656
|
+
const overlapEnd = Math.min(left.end, right.end);
|
|
657
|
+
const overlapMs = overlapEnd - overlapStart;
|
|
658
|
+
if (overlapMs <= 0) {
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
clashes.push({
|
|
662
|
+
leftId: left.id,
|
|
663
|
+
rightId: right.id,
|
|
664
|
+
shared: sameStation && sameOperator ? "both" : sameStation ? "station" : "operator",
|
|
665
|
+
overlapMs: overlapMs,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
clashes.sort(function (a, b) {
|
|
671
|
+
return b.overlapMs - a.overlapMs || a.leftId.localeCompare(b.leftId) || a.rightId.localeCompare(b.rightId);
|
|
672
|
+
});
|
|
673
|
+
return clashes;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
module.exports = {
|
|
677
|
+
coalesceStationBlocks,
|
|
678
|
+
rankFloorQueue,
|
|
679
|
+
packStationBookings,
|
|
680
|
+
matchOperatorSkills,
|
|
681
|
+
runStationLanes,
|
|
682
|
+
foldDrawerEvents,
|
|
683
|
+
detectBookingCollisions,
|
|
684
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fenne",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Appointment studio helpers for station blocks, floor queues, booking packs, operator skills, paced lanes, drawer ledgers, and collision scans",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
6
10
|
"scripts": {
|
|
7
11
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
12
|
},
|
|
9
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"appointment",
|
|
15
|
+
"studio",
|
|
16
|
+
"station",
|
|
17
|
+
"queue",
|
|
18
|
+
"booking",
|
|
19
|
+
"operator",
|
|
20
|
+
"drawer",
|
|
21
|
+
"collision"
|
|
22
|
+
],
|
|
10
23
|
"author": "",
|
|
11
24
|
"license": "ISC",
|
|
12
25
|
"type": "commonjs"
|