@happyvertical/smrt-timesheets 0.55.23 → 0.56.0
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/AGENTS.md +26 -0
- package/README.md +156 -11
- package/agents/period-rollup.md +20 -0
- package/dist/attendance/context.d.ts +80 -0
- package/dist/attendance/context.d.ts.map +1 -0
- package/dist/attendance/correction-models.d.ts +39 -0
- package/dist/attendance/correction-models.d.ts.map +1 -0
- package/dist/attendance/correction-service.d.ts +118 -0
- package/dist/attendance/correction-service.d.ts.map +1 -0
- package/dist/attendance/models.d.ts +57 -0
- package/dist/attendance/models.d.ts.map +1 -1
- package/dist/attendance/segments.d.ts +20 -0
- package/dist/attendance/segments.d.ts.map +1 -0
- package/dist/attendance/service.d.ts +106 -22
- package/dist/attendance/service.d.ts.map +1 -1
- package/dist/attendance/write.d.ts +12 -0
- package/dist/attendance/write.d.ts.map +1 -0
- package/dist/attendance.d.ts +2 -0
- package/dist/attendance.d.ts.map +1 -1
- package/dist/attendance.js +3 -323
- package/dist/chunks/{attendance-BVeStEBv.js → attendance-B1jCbqSb.js} +3 -3
- package/dist/chunks/{attendance-BVeStEBv.js.map → attendance-B1jCbqSb.js.map} +1 -1
- package/dist/chunks/attendance-DGVy-iHI.js +1246 -0
- package/dist/chunks/attendance-DGVy-iHI.js.map +1 -0
- package/dist/chunks/models-0-YD5F_o.js +372 -0
- package/dist/chunks/models-0-YD5F_o.js.map +1 -0
- package/dist/chunks/{rollup-U1IO_CMA.js → rollup-CDqzCxI2.js} +211 -6
- package/dist/chunks/rollup-CDqzCxI2.js.map +1 -0
- package/dist/chunks/service-time-entry-t7P-bkOQ.js +227 -0
- package/dist/chunks/service-time-entry-t7P-bkOQ.js.map +1 -0
- package/dist/index.js +5 -5
- package/dist/manifest.json +2965 -1358
- package/dist/rollup/correction-guard.d.ts +12 -0
- package/dist/rollup/correction-guard.d.ts.map +1 -0
- package/dist/rollup/models.d.ts +31 -1
- package/dist/rollup/models.d.ts.map +1 -1
- package/dist/rollup/service.d.ts +25 -7
- package/dist/rollup/service.d.ts.map +1 -1
- package/dist/rollup/types.d.ts +18 -0
- package/dist/rollup/types.d.ts.map +1 -1
- package/dist/rollup/write.d.ts +2 -0
- package/dist/rollup/write.d.ts.map +1 -1
- package/dist/rollup.d.ts +3 -2
- package/dist/rollup.d.ts.map +1 -1
- package/dist/rollup.js +2 -2
- package/dist/smrt-knowledge.json +700 -34
- package/package.json +7 -7
- package/dist/attendance.js.map +0 -1
- package/dist/chunks/models-zU8Um7c2.js +0 -176
- package/dist/chunks/models-zU8Um7c2.js.map +0 -1
- package/dist/chunks/rollup-U1IO_CMA.js.map +0 -1
- package/dist/chunks/service-time-entry-CBv-FF-W.js +0 -227
- package/dist/chunks/service-time-entry-CBv-FF-W.js.map +0 -1
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
import { i as ServiceTimeEntryCollection, o as parseTimesheetJson } from "./service-time-entry-t7P-bkOQ.js";
|
|
2
|
+
import { c as AttendanceSegmentCollection, d as persistAutoClose, f as persistCorrection, i as AttendancePunchCollection, l as deleteCorrection, n as AttendanceBreakCollection, o as AttendanceReplayCollection, r as AttendancePunch, s as AttendanceSegment, t as AttendanceBreak, u as isCorrectionWrite } from "./models-0-YD5F_o.js";
|
|
3
|
+
import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, isEmbeddedDatabase, isPostgresDatabase, smrt, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
4
|
+
import { TenantScoped, getTenantId, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
//#region src/attendance/correction-models.ts
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
value
|
|
13
|
+
}) : obj[key] = value;
|
|
14
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
15
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
16
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
17
|
+
if (kind && result) __defProp(target, key, result);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
21
|
+
var ATTENDANCE_CORRECTION_KINDS = [
|
|
22
|
+
"punch.corrected",
|
|
23
|
+
"punch.added",
|
|
24
|
+
"break.added",
|
|
25
|
+
"break.changed",
|
|
26
|
+
"break.removed",
|
|
27
|
+
"segment.added",
|
|
28
|
+
"segment.changed",
|
|
29
|
+
"segment.removed"
|
|
30
|
+
];
|
|
31
|
+
var IMMUTABLE_COLUMNS = [
|
|
32
|
+
"tenant_id",
|
|
33
|
+
"subject_profile_id",
|
|
34
|
+
"actor_profile_id",
|
|
35
|
+
"kind",
|
|
36
|
+
"punch_id",
|
|
37
|
+
"target_id",
|
|
38
|
+
"before_state",
|
|
39
|
+
"after_state",
|
|
40
|
+
"reason"
|
|
41
|
+
];
|
|
42
|
+
var AttendanceCorrection = class extends SmrtObject {
|
|
43
|
+
tenantId = "";
|
|
44
|
+
subjectProfileId = "";
|
|
45
|
+
actorProfileId = "";
|
|
46
|
+
kind = "punch.corrected";
|
|
47
|
+
punchId = "";
|
|
48
|
+
/** The break or segment id for those kinds; the punch id for punch kinds. */
|
|
49
|
+
targetId = "";
|
|
50
|
+
beforeState = "null";
|
|
51
|
+
afterState = "null";
|
|
52
|
+
reason = "";
|
|
53
|
+
acknowledgementRequired = false;
|
|
54
|
+
acknowledgedAt = null;
|
|
55
|
+
acknowledgedByProfileId = null;
|
|
56
|
+
/** The values before the change, or null for an addition. */
|
|
57
|
+
getBefore() {
|
|
58
|
+
return parseTimesheetJson(this.beforeState, null);
|
|
59
|
+
}
|
|
60
|
+
/** The values after the change, or null for a removal. */
|
|
61
|
+
getAfter() {
|
|
62
|
+
return parseTimesheetJson(this.afterState, null);
|
|
63
|
+
}
|
|
64
|
+
async save() {
|
|
65
|
+
if (!isCorrectionWrite(this)) throw new Error("Attendance corrections are writable only through AttendanceCorrectionService.");
|
|
66
|
+
if (!this.tenantId || !this.subjectProfileId || !this.actorProfileId || !this.punchId || !this.targetId || !ATTENDANCE_CORRECTION_KINDS.includes(this.kind) || typeof this.reason !== "string" || !this.reason.trim() || typeof this.acknowledgementRequired !== "boolean") throw new Error("Invalid attendance correction.");
|
|
67
|
+
for (const json of [this.beforeState, this.afterState]) JSON.parse(json);
|
|
68
|
+
if (!!this.acknowledgedAt !== !!this.acknowledgedByProfileId) throw new Error("Acknowledgement needs both a time and a profile.");
|
|
69
|
+
if (this.acknowledgedAt) {
|
|
70
|
+
if (!this.acknowledgementRequired) throw new Error("This correction does not require acknowledgement.");
|
|
71
|
+
if (this.acknowledgedByProfileId !== this.subjectProfileId) throw new Error("Only the subject can acknowledge a correction.");
|
|
72
|
+
}
|
|
73
|
+
const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
|
|
74
|
+
if (prior) {
|
|
75
|
+
const row = this;
|
|
76
|
+
const camel = (column) => column.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
77
|
+
if (IMMUTABLE_COLUMNS.some((column) => prior[column] !== row[camel(column)]) || Boolean(prior.acknowledgement_required) !== this.acknowledgementRequired || prior.acknowledged_at && (new Date(prior.acknowledged_at).getTime() !== this.acknowledgedAt?.getTime() || prior.acknowledged_by_profile_id !== this.acknowledgedByProfileId)) throw new Error("Attendance corrections are append-only; only a first acknowledgement may change.");
|
|
78
|
+
}
|
|
79
|
+
this.id ??= crypto.randomUUID();
|
|
80
|
+
return super.save();
|
|
81
|
+
}
|
|
82
|
+
async delete() {
|
|
83
|
+
throw new Error("Attendance correction history cannot be deleted.");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
__publicField(AttendanceCorrection, "__smrtPackage__", "@happyvertical/smrt-timesheets");
|
|
87
|
+
__decorateClass([tenantId()], AttendanceCorrection.prototype, "tenantId", 2);
|
|
88
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], AttendanceCorrection.prototype, "subjectProfileId", 2);
|
|
89
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], AttendanceCorrection.prototype, "actorProfileId", 2);
|
|
90
|
+
__decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "kind", 2);
|
|
91
|
+
__decorateClass([foreignKey(AttendancePunch)], AttendanceCorrection.prototype, "punchId", 2);
|
|
92
|
+
__decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "beforeState", 2);
|
|
93
|
+
__decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "afterState", 2);
|
|
94
|
+
__decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "reason", 2);
|
|
95
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile", { nullable: true })], AttendanceCorrection.prototype, "acknowledgedByProfileId", 2);
|
|
96
|
+
AttendanceCorrection = __decorateClass([TenantScoped({ mode: "required" }), smrt({
|
|
97
|
+
tableName: "attendance_corrections",
|
|
98
|
+
sensitive: true,
|
|
99
|
+
indexes: [{
|
|
100
|
+
name: "attendance_corrections_punch_idx",
|
|
101
|
+
columns: ["tenantId", "punchId"]
|
|
102
|
+
}, {
|
|
103
|
+
name: "attendance_corrections_subject_idx",
|
|
104
|
+
columns: ["tenantId", "subjectProfileId"]
|
|
105
|
+
}],
|
|
106
|
+
api: { include: [] },
|
|
107
|
+
cli: { include: [] },
|
|
108
|
+
mcp: { include: [] }
|
|
109
|
+
})], AttendanceCorrection);
|
|
110
|
+
var AttendanceCorrectionCollection = class extends SmrtCollection {
|
|
111
|
+
static _itemClass = AttendanceCorrection;
|
|
112
|
+
};
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/attendance/context.ts
|
|
115
|
+
var MAX_LABEL = 64;
|
|
116
|
+
function attendanceLabel(value, name) {
|
|
117
|
+
if (value === void 0 || value === null) return "";
|
|
118
|
+
if (typeof value !== "string" || value !== value.trim() || value.length > MAX_LABEL) throw new AttendanceConflict(`${name} must be a trimmed string of at most ${MAX_LABEL} characters.`, "invalid");
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
function attendanceProvenance(options) {
|
|
122
|
+
const source = attendanceLabel(options?.source, "source");
|
|
123
|
+
const device = options?.device;
|
|
124
|
+
if (device !== void 0 && (!device || typeof device !== "object")) throw new AttendanceConflict("device must be { type, id }.", "invalid");
|
|
125
|
+
const deviceRefType = attendanceLabel(device?.type, "device.type");
|
|
126
|
+
const deviceRefId = attendanceLabel(device?.id, "device.id");
|
|
127
|
+
if (!!deviceRefType !== !!deviceRefId) throw new AttendanceConflict("device.type and device.id must be provided together.", "invalid");
|
|
128
|
+
return {
|
|
129
|
+
source,
|
|
130
|
+
deviceRefType,
|
|
131
|
+
deviceRefId
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
var AttendanceConflict = class extends Error {
|
|
135
|
+
/**
|
|
136
|
+
* Stable machine-readable reason (see {@link AttendanceRefusalCode}); stored
|
|
137
|
+
* on a refused replay outcome so a device screen can say why.
|
|
138
|
+
*/
|
|
139
|
+
code;
|
|
140
|
+
constructor(message, code = "conflict") {
|
|
141
|
+
super(message);
|
|
142
|
+
this.code = code;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
function validateAttendanceWork(work) {
|
|
146
|
+
if (!work || typeof work.workRefType !== "string" || !work.workRefType || typeof work.workRefId !== "string" || !work.workRefId || typeof work.description !== "string") throw new AttendanceConflict("A complete work reference and description are required.");
|
|
147
|
+
for (const key of [
|
|
148
|
+
"taskRefType",
|
|
149
|
+
"taskRefId",
|
|
150
|
+
"kind"
|
|
151
|
+
]) if (work[key] !== void 0 && typeof work[key] !== "string") throw new AttendanceConflict(`${key} must be a string.`);
|
|
152
|
+
if (!!work.taskRefType !== !!work.taskRefId) throw new AttendanceConflict("taskRefType and taskRefId must be provided together.");
|
|
153
|
+
}
|
|
154
|
+
function attendanceIdentity(db, value) {
|
|
155
|
+
if (!isPostgresDatabase(db)) return value;
|
|
156
|
+
const hex = value.replace(/[{}-]/g, "").toLowerCase();
|
|
157
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error("PostgreSQL attendance identities must be UUIDs.");
|
|
158
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/attendance/segments.ts
|
|
162
|
+
async function openSegment(tx, punch) {
|
|
163
|
+
const [segment] = await tx.segments.list({
|
|
164
|
+
where: {
|
|
165
|
+
tenantId: punch.tenantId,
|
|
166
|
+
punchId: punch.id,
|
|
167
|
+
endedAt: null
|
|
168
|
+
},
|
|
169
|
+
limit: 1
|
|
170
|
+
});
|
|
171
|
+
return segment ?? null;
|
|
172
|
+
}
|
|
173
|
+
async function listSegments(tx, punch) {
|
|
174
|
+
return tx.segments.list({
|
|
175
|
+
where: {
|
|
176
|
+
tenantId: punch.tenantId,
|
|
177
|
+
punchId: punch.id
|
|
178
|
+
},
|
|
179
|
+
orderBy: "started_at ASC"
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async function closeOpenSegment(tx, punch, at, reason) {
|
|
183
|
+
const segment = await openSegment(tx, punch);
|
|
184
|
+
if (!segment) return null;
|
|
185
|
+
if (at < segment.startedAt) throw new AttendanceConflict("Segment cannot end before it starts.");
|
|
186
|
+
segment.endedAt = at;
|
|
187
|
+
segment.closedReason = reason;
|
|
188
|
+
await segment.save();
|
|
189
|
+
return segment;
|
|
190
|
+
}
|
|
191
|
+
async function beginSegment(tx, punch, at, work) {
|
|
192
|
+
validateAttendanceWork(work);
|
|
193
|
+
if (punch.endedAt) throw new AttendanceConflict("Punch is closed.");
|
|
194
|
+
if ((await tx.breaks.list({ where: {
|
|
195
|
+
tenantId: punch.tenantId,
|
|
196
|
+
punchId: punch.id
|
|
197
|
+
} })).some((b) => !b.endedAt)) throw new AttendanceConflict("Cannot work during an open break.", "during_break");
|
|
198
|
+
if (await openSegment(tx, punch)) throw new AttendanceConflict("A work segment is already open.", "work_open");
|
|
199
|
+
return tx.segments.create({
|
|
200
|
+
tenantId: punch.tenantId,
|
|
201
|
+
punchId: punch.id ?? void 0,
|
|
202
|
+
workRefType: work.workRefType,
|
|
203
|
+
workRefId: work.workRefId,
|
|
204
|
+
taskRefType: work.taskRefType ?? "",
|
|
205
|
+
taskRefId: work.taskRefId ?? "",
|
|
206
|
+
kind: work.kind ?? "",
|
|
207
|
+
description: work.description,
|
|
208
|
+
startedAt: at,
|
|
209
|
+
_insertOnly: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async function replaceSegment(tx, punch, at, work) {
|
|
213
|
+
validateAttendanceWork(work);
|
|
214
|
+
if (!await closeOpenSegment(tx, punch, at, "switch")) throw new AttendanceConflict("No open work segment to switch.", "no_open_work");
|
|
215
|
+
return beginSegment(tx, punch, at, work);
|
|
216
|
+
}
|
|
217
|
+
async function endSegment(tx, punch, at) {
|
|
218
|
+
const segment = await closeOpenSegment(tx, punch, at, "stop");
|
|
219
|
+
if (!segment) throw new AttendanceConflict("No open work segment.", "no_open_work");
|
|
220
|
+
return segment;
|
|
221
|
+
}
|
|
222
|
+
async function continueSegment(tx, punch, at) {
|
|
223
|
+
const all = await listSegments(tx, punch);
|
|
224
|
+
const last = all[all.length - 1];
|
|
225
|
+
const breaks = await tx.breaks.list({
|
|
226
|
+
where: {
|
|
227
|
+
tenantId: punch.tenantId,
|
|
228
|
+
punchId: punch.id
|
|
229
|
+
},
|
|
230
|
+
orderBy: "started_at DESC",
|
|
231
|
+
limit: 1
|
|
232
|
+
});
|
|
233
|
+
if (last?.closedReason !== "break" || !last.endedAt || last.endedAt.getTime() !== breaks[0]?.startedAt.getTime()) throw new AttendanceConflict("No work interrupted by a break to resume.", "no_resumable_work");
|
|
234
|
+
return beginSegment(tx, punch, at, last);
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/attendance/service.ts
|
|
238
|
+
function sameAttribution(metadata, work) {
|
|
239
|
+
const stored = parseTimesheetJson(metadata, {});
|
|
240
|
+
return (stored.taskRefType ?? "") === (work.taskRefType ?? "") && (stored.taskRefId ?? "") === (work.taskRefId ?? "") && (stored.kind ?? "") === (work.kind ?? "");
|
|
241
|
+
}
|
|
242
|
+
var TAP_ACTIONS = [
|
|
243
|
+
"punchIn",
|
|
244
|
+
"punchOut",
|
|
245
|
+
"startBreak",
|
|
246
|
+
"endBreak",
|
|
247
|
+
"startWork",
|
|
248
|
+
"switchWork",
|
|
249
|
+
"stopWork",
|
|
250
|
+
"resumeWork"
|
|
251
|
+
];
|
|
252
|
+
var AttendanceService = class {
|
|
253
|
+
constructor(db, actor) {
|
|
254
|
+
this.db = db;
|
|
255
|
+
this.actor = actor;
|
|
256
|
+
if (!actor.tenantId || !actor.profileId) throw new Error("Attendance requires a trusted tenant/profile actor.");
|
|
257
|
+
this.actor = {
|
|
258
|
+
tenantId: this.identity(actor.tenantId),
|
|
259
|
+
profileId: this.identity(actor.profileId)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
db;
|
|
263
|
+
actor;
|
|
264
|
+
identity(value) {
|
|
265
|
+
return attendanceIdentity(this.db, value);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Run `operation` in the actor's serialized attendance transaction. The
|
|
269
|
+
* context's collections share the executor, so corrections and replay can
|
|
270
|
+
* compose several attendance mutations (with the segment helpers) atomically.
|
|
271
|
+
* Results that are punches or segments are re-read after commit.
|
|
272
|
+
*/
|
|
273
|
+
async withTransaction(operation) {
|
|
274
|
+
return this.transact(operation);
|
|
275
|
+
}
|
|
276
|
+
async transact(operation) {
|
|
277
|
+
const current = getTenantId();
|
|
278
|
+
if (current && this.identity(current) !== this.actor.tenantId) throw new Error("Attendance actor differs from active tenant.");
|
|
279
|
+
return withTenant({ tenantId: this.actor.tenantId }, async () => {
|
|
280
|
+
const result = await withEmbeddedWriteTransaction(this.db, isEmbeddedDatabase(this.db), async (db) => {
|
|
281
|
+
if (isPostgresDatabase(db)) await db.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", JSON.stringify([
|
|
282
|
+
"smrt.attendance",
|
|
283
|
+
this.actor.tenantId,
|
|
284
|
+
this.actor.profileId
|
|
285
|
+
]));
|
|
286
|
+
return operation({
|
|
287
|
+
db,
|
|
288
|
+
punches: await AttendancePunchCollection.create({ db }),
|
|
289
|
+
breaks: await AttendanceBreakCollection.create({ db }),
|
|
290
|
+
segments: await AttendanceSegmentCollection.create({ db }),
|
|
291
|
+
replays: await AttendanceReplayCollection.create({ db })
|
|
292
|
+
});
|
|
293
|
+
}, true);
|
|
294
|
+
if (result instanceof AttendancePunch) {
|
|
295
|
+
const [fresh] = await (await AttendancePunchCollection.create({ db: this.db })).list({
|
|
296
|
+
where: {
|
|
297
|
+
...this.actor,
|
|
298
|
+
id: result.id
|
|
299
|
+
},
|
|
300
|
+
limit: 1
|
|
301
|
+
});
|
|
302
|
+
if (!fresh) throw new Error("Committed attendance punch disappeared.");
|
|
303
|
+
return fresh;
|
|
304
|
+
}
|
|
305
|
+
if (result instanceof AttendanceSegment) {
|
|
306
|
+
const [fresh] = await (await AttendanceSegmentCollection.create({ db: this.db })).list({
|
|
307
|
+
where: {
|
|
308
|
+
tenantId: this.actor.tenantId,
|
|
309
|
+
id: result.id
|
|
310
|
+
},
|
|
311
|
+
limit: 1
|
|
312
|
+
});
|
|
313
|
+
if (!fresh) throw new Error("Committed attendance segment disappeared.");
|
|
314
|
+
return fresh;
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
date(at) {
|
|
320
|
+
if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new AttendanceConflict("Attendance timestamps must be valid dates.");
|
|
321
|
+
return at;
|
|
322
|
+
}
|
|
323
|
+
async open(tx) {
|
|
324
|
+
const [punch] = await tx.punches.list({
|
|
325
|
+
where: {
|
|
326
|
+
...this.actor,
|
|
327
|
+
endedAt: null
|
|
328
|
+
},
|
|
329
|
+
limit: 1
|
|
330
|
+
});
|
|
331
|
+
if (!punch) throw new AttendanceConflict("No open punch.", "no_open_punch");
|
|
332
|
+
return punch;
|
|
333
|
+
}
|
|
334
|
+
async chronological(tx, at) {
|
|
335
|
+
const open = await tx.punches.list({
|
|
336
|
+
where: {
|
|
337
|
+
...this.actor,
|
|
338
|
+
endedAt: null
|
|
339
|
+
},
|
|
340
|
+
limit: 1
|
|
341
|
+
});
|
|
342
|
+
const rows = open.length ? open : await tx.punches.list({
|
|
343
|
+
where: { ...this.actor },
|
|
344
|
+
orderBy: "ended_at DESC",
|
|
345
|
+
limit: 1
|
|
346
|
+
});
|
|
347
|
+
if (!rows.length) return;
|
|
348
|
+
const punch = rows[0];
|
|
349
|
+
const breaks = await tx.breaks.list({ where: {
|
|
350
|
+
tenantId: this.actor.tenantId,
|
|
351
|
+
punchId: punch.id
|
|
352
|
+
} });
|
|
353
|
+
const segments = await tx.segments.list({ where: {
|
|
354
|
+
tenantId: this.actor.tenantId,
|
|
355
|
+
punchId: punch.id
|
|
356
|
+
} });
|
|
357
|
+
const latest = Math.max(punch.startedAt.getTime(), ...segments.flatMap((g) => [g.startedAt.getTime(), g.endedAt?.getTime() ?? 0]), punch.endedAt?.getTime() ?? 0, ...breaks.flatMap((b) => [b.startedAt.getTime(), b.endedAt?.getTime() ?? 0]));
|
|
358
|
+
if (at.getTime() < latest) throw new AttendanceConflict("Tap predates committed attendance; manual review required.", "predates_committed");
|
|
359
|
+
}
|
|
360
|
+
/** Open one punch; the database unique slot also protects direct model writers. */
|
|
361
|
+
async punchIn(at = /* @__PURE__ */ new Date(), options) {
|
|
362
|
+
const meta = attendanceProvenance(options);
|
|
363
|
+
return this.transact((tx) => this.transition(tx, "punchIn", this.date(at), false, void 0, false, {
|
|
364
|
+
...meta,
|
|
365
|
+
kind: ""
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
/** Close the punch and any active break; optionally create linked draft evidence. */
|
|
369
|
+
async punchOut(at, work) {
|
|
370
|
+
return this.transact((tx) => this.transition(tx, "punchOut", this.date(at), false, work));
|
|
371
|
+
}
|
|
372
|
+
/** Begin a break; paid defaults false and must be explicitly configured by the caller. */
|
|
373
|
+
async startBreak(at, paid = false, options) {
|
|
374
|
+
const kind = attendanceLabel(options?.kind, "kind");
|
|
375
|
+
return this.transact((tx) => this.transition(tx, "startBreak", this.date(at), paid, void 0, false, {
|
|
376
|
+
...attendanceProvenance(),
|
|
377
|
+
kind
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* End the current break. With `{ resumeWork: true }` the work interrupted by
|
|
382
|
+
* `startBreak` resumes at the same instant, atomically with the break end.
|
|
383
|
+
*/
|
|
384
|
+
async endBreak(at, options) {
|
|
385
|
+
return this.transact((tx) => this.transition(tx, "endBreak", this.date(at), false, void 0, options?.resumeWork));
|
|
386
|
+
}
|
|
387
|
+
/** Open a work segment in the open punch; refuses during a break or while one is open. */
|
|
388
|
+
async startWork(at, work) {
|
|
389
|
+
return this.transact(async (tx) => {
|
|
390
|
+
validateAttendanceWork(work);
|
|
391
|
+
await this.chronological(tx, this.date(at));
|
|
392
|
+
return beginSegment(tx, await this.open(tx), at, work);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/** Atomically close the open segment and open the next one at `at`. */
|
|
396
|
+
async switchWork(at, work) {
|
|
397
|
+
return this.transact(async (tx) => {
|
|
398
|
+
validateAttendanceWork(work);
|
|
399
|
+
await this.chronological(tx, this.date(at));
|
|
400
|
+
return replaceSegment(tx, await this.open(tx), at, work);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/** Close the open segment without starting another. */
|
|
404
|
+
async stopWork(at) {
|
|
405
|
+
return this.transact(async (tx) => {
|
|
406
|
+
await this.chronological(tx, this.date(at));
|
|
407
|
+
return endSegment(tx, await this.open(tx), at);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** Reopen the work a break interrupted (the latest segment must have closed on `startBreak`). */
|
|
411
|
+
async resumeWork(at) {
|
|
412
|
+
return this.transact(async (tx) => {
|
|
413
|
+
await this.chronological(tx, this.date(at));
|
|
414
|
+
return continueSegment(tx, await this.open(tx), at);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
/** The open work segment of the actor's open punch, or null. */
|
|
418
|
+
async currentWork() {
|
|
419
|
+
return this.transact(async (tx) => {
|
|
420
|
+
const [punch] = await tx.punches.list({
|
|
421
|
+
where: {
|
|
422
|
+
...this.actor,
|
|
423
|
+
endedAt: null
|
|
424
|
+
},
|
|
425
|
+
limit: 1
|
|
426
|
+
});
|
|
427
|
+
return punch ? openSegment(tx, punch) : null;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** Work segments of one of the actor's punches, oldest first. */
|
|
431
|
+
async segments(punchId) {
|
|
432
|
+
return this.transact(async (tx) => {
|
|
433
|
+
const [punch] = await tx.punches.list({
|
|
434
|
+
where: {
|
|
435
|
+
...this.actor,
|
|
436
|
+
id: punchId
|
|
437
|
+
},
|
|
438
|
+
limit: 1
|
|
439
|
+
});
|
|
440
|
+
if (!punch) throw new AttendanceConflict("Punch not found for actor.");
|
|
441
|
+
return listSegments(tx, punch);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async transition(tx, action, at, paid = false, work, resumeWork = false, meta = {
|
|
445
|
+
...attendanceProvenance(),
|
|
446
|
+
kind: ""
|
|
447
|
+
}) {
|
|
448
|
+
await this.chronological(tx, at);
|
|
449
|
+
if (action === "punchIn") {
|
|
450
|
+
if ((await tx.punches.list({
|
|
451
|
+
where: {
|
|
452
|
+
...this.actor,
|
|
453
|
+
endedAt: null
|
|
454
|
+
},
|
|
455
|
+
limit: 1
|
|
456
|
+
})).length) throw new AttendanceConflict("A punch is already open.", "punch_open");
|
|
457
|
+
return tx.punches.create({
|
|
458
|
+
...this.actor,
|
|
459
|
+
startedAt: at,
|
|
460
|
+
source: meta.source,
|
|
461
|
+
deviceRefType: meta.deviceRefType,
|
|
462
|
+
deviceRefId: meta.deviceRefId,
|
|
463
|
+
_insertOnly: true
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const punch = await this.open(tx);
|
|
467
|
+
const breaks = await tx.breaks.list({ where: {
|
|
468
|
+
tenantId: this.actor.tenantId,
|
|
469
|
+
punchId: punch.id
|
|
470
|
+
} });
|
|
471
|
+
const active = breaks.find((b) => !b.endedAt);
|
|
472
|
+
if (action === "startBreak") {
|
|
473
|
+
if (active) throw new AttendanceConflict("A break is already open.", "break_open");
|
|
474
|
+
if (typeof paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
|
|
475
|
+
await closeOpenSegment(tx, punch, at, "break");
|
|
476
|
+
await tx.breaks.create({
|
|
477
|
+
tenantId: this.actor.tenantId,
|
|
478
|
+
punchId: punch.id ?? void 0,
|
|
479
|
+
startedAt: at,
|
|
480
|
+
paid,
|
|
481
|
+
kind: meta.kind,
|
|
482
|
+
_insertOnly: true
|
|
483
|
+
});
|
|
484
|
+
return punch;
|
|
485
|
+
}
|
|
486
|
+
if (action !== "endBreak" && action !== "punchOut") throw new AttendanceConflict("Unknown attendance action.");
|
|
487
|
+
if (action === "endBreak" && !active) throw new AttendanceConflict("No open break.", "no_open_break");
|
|
488
|
+
if (action === "punchOut" && work) {
|
|
489
|
+
this.validateWork(work);
|
|
490
|
+
const unpaidMilliseconds2 = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + (b.endedAt ?? at).getTime() - b.startedAt.getTime(), 0);
|
|
491
|
+
if (Math.round((at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds2) / 1e3) <= 0) throw new AttendanceConflict("Only closed punches with positive duration can produce service evidence.");
|
|
492
|
+
}
|
|
493
|
+
if (active) {
|
|
494
|
+
active.endedAt = at;
|
|
495
|
+
await active.save();
|
|
496
|
+
}
|
|
497
|
+
if (action === "endBreak") {
|
|
498
|
+
if (resumeWork) await continueSegment(tx, punch, at);
|
|
499
|
+
return punch;
|
|
500
|
+
}
|
|
501
|
+
await closeOpenSegment(tx, punch, at, "punchOut");
|
|
502
|
+
punch.endedAt = at;
|
|
503
|
+
const unpaidMilliseconds = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + b.endedAt.getTime() - b.startedAt.getTime(), 0);
|
|
504
|
+
punch.unpaidBreakSeconds = Math.round(unpaidMilliseconds / 1e3);
|
|
505
|
+
punch.durationSeconds = Math.round((at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds) / 1e3);
|
|
506
|
+
await punch.save();
|
|
507
|
+
if (work) await this.link(tx, punch, work);
|
|
508
|
+
return punch;
|
|
509
|
+
}
|
|
510
|
+
/** Run one replayed tap on `tx`; work actions share the punch changes' transaction. */
|
|
511
|
+
async apply(tx, tap, at) {
|
|
512
|
+
const work = () => {
|
|
513
|
+
if (!tap.work) throw new AttendanceConflict("Work is required.", "invalid");
|
|
514
|
+
return tap.work;
|
|
515
|
+
};
|
|
516
|
+
let segment;
|
|
517
|
+
switch (tap.action) {
|
|
518
|
+
case "startWork":
|
|
519
|
+
await this.chronological(tx, at);
|
|
520
|
+
segment = await beginSegment(tx, await this.open(tx), at, work());
|
|
521
|
+
break;
|
|
522
|
+
case "switchWork":
|
|
523
|
+
await this.chronological(tx, at);
|
|
524
|
+
segment = await replaceSegment(tx, await this.open(tx), at, work());
|
|
525
|
+
break;
|
|
526
|
+
case "stopWork":
|
|
527
|
+
await this.chronological(tx, at);
|
|
528
|
+
segment = await endSegment(tx, await this.open(tx), at);
|
|
529
|
+
break;
|
|
530
|
+
case "resumeWork":
|
|
531
|
+
await this.chronological(tx, at);
|
|
532
|
+
segment = await continueSegment(tx, await this.open(tx), at);
|
|
533
|
+
break;
|
|
534
|
+
default: return { punch: await this.transition(tx, tap.action, at, tap.paid, tap.work, false, {
|
|
535
|
+
...attendanceProvenance({
|
|
536
|
+
source: tap.source,
|
|
537
|
+
device: tap.device
|
|
538
|
+
}),
|
|
539
|
+
kind: attendanceLabel(tap.kind, "kind")
|
|
540
|
+
}) };
|
|
541
|
+
}
|
|
542
|
+
const [punch] = await tx.punches.list({
|
|
543
|
+
where: {
|
|
544
|
+
...this.actor,
|
|
545
|
+
id: segment.punchId
|
|
546
|
+
},
|
|
547
|
+
limit: 1
|
|
548
|
+
});
|
|
549
|
+
return {
|
|
550
|
+
punch,
|
|
551
|
+
segment
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
validateWork(work) {
|
|
555
|
+
validateAttendanceWork(work);
|
|
556
|
+
}
|
|
557
|
+
async link(tx, punch, work) {
|
|
558
|
+
this.validateWork(work);
|
|
559
|
+
if (!punch.endedAt || punch.durationSeconds <= 0) throw new AttendanceConflict("Only closed punches with positive duration can produce service evidence.");
|
|
560
|
+
const entries = await ServiceTimeEntryCollection.create({ db: tx.db });
|
|
561
|
+
if (punch.serviceTimeEntryId) {
|
|
562
|
+
const [existing] = await entries.list({
|
|
563
|
+
where: {
|
|
564
|
+
id: punch.serviceTimeEntryId,
|
|
565
|
+
tenantId: this.actor.tenantId
|
|
566
|
+
},
|
|
567
|
+
limit: 1
|
|
568
|
+
});
|
|
569
|
+
if (!existing || existing.workRefType !== work.workRefType || existing.workRefId !== work.workRefId || existing.description !== work.description || !sameAttribution(existing.metadata, work)) throw new AttendanceConflict("Punch already linked to different work.");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const entry = await entries.create({
|
|
573
|
+
tenantId: this.actor.tenantId,
|
|
574
|
+
participantKind: "human",
|
|
575
|
+
participantProfileId: this.actor.profileId,
|
|
576
|
+
workRefType: work.workRefType,
|
|
577
|
+
workRefId: work.workRefId,
|
|
578
|
+
description: work.description,
|
|
579
|
+
source: "timer",
|
|
580
|
+
startedAt: punch.startedAt,
|
|
581
|
+
endedAt: punch.endedAt,
|
|
582
|
+
durationSeconds: punch.durationSeconds,
|
|
583
|
+
status: "draft",
|
|
584
|
+
metadata: JSON.stringify({
|
|
585
|
+
attendancePunchId: punch.id,
|
|
586
|
+
...work.taskRefType ? {
|
|
587
|
+
taskRefType: work.taskRefType,
|
|
588
|
+
taskRefId: work.taskRefId
|
|
589
|
+
} : {},
|
|
590
|
+
...work.kind ? { kind: work.kind } : {}
|
|
591
|
+
}),
|
|
592
|
+
_insertOnly: true
|
|
593
|
+
});
|
|
594
|
+
if (!entry.id) throw new Error("Saved service entry has no id.");
|
|
595
|
+
punch.serviceTimeEntryId = entry.id;
|
|
596
|
+
await punch.save();
|
|
597
|
+
}
|
|
598
|
+
/** Idempotently link a closed, owned punch to draft service evidence. */
|
|
599
|
+
async linkServiceTimeEntry(punchId, work) {
|
|
600
|
+
return this.transact(async (tx) => {
|
|
601
|
+
const [punch] = await tx.punches.list({
|
|
602
|
+
where: {
|
|
603
|
+
...this.actor,
|
|
604
|
+
id: punchId
|
|
605
|
+
},
|
|
606
|
+
limit: 1
|
|
607
|
+
});
|
|
608
|
+
if (!punch) throw new AttendanceConflict("Punch not found for actor.");
|
|
609
|
+
await this.link(tx, punch, work);
|
|
610
|
+
return punch;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Close the open punch once its deadline has passed, flagging it for review.
|
|
615
|
+
* `deadline` is either a fixed maximum open duration in seconds (closing at
|
|
616
|
+
* `startedAt + seconds`, reason `maxDuration`) or a resolver called with the
|
|
617
|
+
* open punch that returns its deadline (a `Date`, or `{ deadline, reason }`),
|
|
618
|
+
* or null to leave it open. The consumer owns time zones and shop-day rules.
|
|
619
|
+
* The open work segment and break close at the deadline; returns the closed
|
|
620
|
+
* punch, or null when nothing was due.
|
|
621
|
+
*/
|
|
622
|
+
async autoClose(now, deadline) {
|
|
623
|
+
this.date(now);
|
|
624
|
+
if (typeof deadline !== "function" && (!Number.isSafeInteger(deadline) || deadline <= 0)) throw new AttendanceConflict("maxOpenSeconds must be a positive integer.");
|
|
625
|
+
return this.transact(async (tx) => {
|
|
626
|
+
const [punch] = await tx.punches.list({
|
|
627
|
+
where: {
|
|
628
|
+
...this.actor,
|
|
629
|
+
endedAt: null
|
|
630
|
+
},
|
|
631
|
+
limit: 1
|
|
632
|
+
});
|
|
633
|
+
if (!punch) return null;
|
|
634
|
+
let at;
|
|
635
|
+
let reason = "maxDuration";
|
|
636
|
+
if (typeof deadline === "function") {
|
|
637
|
+
const resolved = await deadline(punch);
|
|
638
|
+
if (resolved === null || resolved === void 0) return null;
|
|
639
|
+
const decision = resolved instanceof Date ? { deadline: resolved } : resolved;
|
|
640
|
+
at = decision.deadline;
|
|
641
|
+
if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new AttendanceConflict("Auto-close deadline is invalid.");
|
|
642
|
+
if (at < punch.startedAt) throw new AttendanceConflict("Auto-close deadline predates the punch.");
|
|
643
|
+
reason = decision.reason ?? "deadline";
|
|
644
|
+
if (typeof reason !== "string" || !reason.trim() || reason.length > 200) throw new AttendanceConflict("Auto-close reason must be 1 to 200 characters.");
|
|
645
|
+
} else at = new Date(punch.startedAt.getTime() + deadline * 1e3);
|
|
646
|
+
if (at > now) return null;
|
|
647
|
+
const closed = await this.transition(tx, "punchOut", at);
|
|
648
|
+
closed.reviewRequired = true;
|
|
649
|
+
closed.autoCloseReason = reason;
|
|
650
|
+
await persistAutoClose(closed);
|
|
651
|
+
return closed;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Replay device timestamps in order (ties use clientId). Committed attendance
|
|
656
|
+
* wins: stale/state-conflicting taps get durable refusals; identical retries
|
|
657
|
+
* return the same outcome, changed payloads using a key are rejected. A
|
|
658
|
+
* database failure rolls back its tap and receipt and remains retryable.
|
|
659
|
+
*/
|
|
660
|
+
async replay(taps, options = {}) {
|
|
661
|
+
for (const tap of taps) {
|
|
662
|
+
if (!tap || typeof tap.at !== "string" || typeof tap.clientId !== "string" || !tap.clientId || !TAP_ACTIONS.includes(tap.action)) throw new AttendanceConflict("Malformed attendance tap.");
|
|
663
|
+
this.date(new Date(tap.at));
|
|
664
|
+
if (tap.work !== void 0) this.validateWork(tap.work);
|
|
665
|
+
if ((tap.action === "startWork" || tap.action === "switchWork") && tap.work === void 0) throw new AttendanceConflict("Malformed attendance tap.");
|
|
666
|
+
if (tap.paid !== void 0 && typeof tap.paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
|
|
667
|
+
attendanceProvenance({
|
|
668
|
+
source: tap.source,
|
|
669
|
+
device: tap.device
|
|
670
|
+
});
|
|
671
|
+
attendanceLabel(tap.kind, "kind");
|
|
672
|
+
}
|
|
673
|
+
const ordered = [...taps].sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.clientId < b.clientId ? -1 : a.clientId > b.clientId ? 1 : 0));
|
|
674
|
+
const outcomes = [];
|
|
675
|
+
for (const tap of ordered) outcomes.push(await this.transact(async (tx) => {
|
|
676
|
+
const replayKey = JSON.stringify([
|
|
677
|
+
this.actor.tenantId,
|
|
678
|
+
this.actor.profileId,
|
|
679
|
+
tap.clientId
|
|
680
|
+
]);
|
|
681
|
+
const content = JSON.stringify([
|
|
682
|
+
tap.action,
|
|
683
|
+
new Date(tap.at).toISOString(),
|
|
684
|
+
tap.paid ?? false,
|
|
685
|
+
tap.work?.workRefType ?? null,
|
|
686
|
+
tap.work?.workRefId ?? null,
|
|
687
|
+
tap.work?.description ?? null,
|
|
688
|
+
...tap.work?.taskRefType || tap.work?.kind ? [
|
|
689
|
+
tap.work.taskRefType ?? null,
|
|
690
|
+
tap.work.taskRefId ?? null,
|
|
691
|
+
tap.work.kind ?? null
|
|
692
|
+
] : [],
|
|
693
|
+
...tap.source || tap.device || tap.kind ? [{
|
|
694
|
+
source: tap.source ?? "",
|
|
695
|
+
deviceType: tap.device?.type ?? "",
|
|
696
|
+
deviceId: tap.device?.id ?? "",
|
|
697
|
+
kind: tap.kind ?? ""
|
|
698
|
+
}] : []
|
|
699
|
+
]);
|
|
700
|
+
const [receipt] = await tx.replays.list({
|
|
701
|
+
where: {
|
|
702
|
+
tenantId: this.actor.tenantId,
|
|
703
|
+
replayKey
|
|
704
|
+
},
|
|
705
|
+
limit: 1
|
|
706
|
+
});
|
|
707
|
+
if (receipt) {
|
|
708
|
+
if (receipt.content !== content) throw new AttendanceConflict("Client id reused with different content.");
|
|
709
|
+
const outcome2 = receipt.getOutcome();
|
|
710
|
+
if (outcome2.clientId !== tap.clientId) throw new Error("Stored attendance receipt belongs to another client id.");
|
|
711
|
+
return outcome2;
|
|
712
|
+
}
|
|
713
|
+
const deviceAt = new Date(tap.at);
|
|
714
|
+
let at = deviceAt;
|
|
715
|
+
let review = false;
|
|
716
|
+
if (options.resolveAt) {
|
|
717
|
+
const serverNow = this.date(options.now?.() ?? /* @__PURE__ */ new Date());
|
|
718
|
+
const resolved = await options.resolveAt({
|
|
719
|
+
tap,
|
|
720
|
+
deviceAt,
|
|
721
|
+
serverNow,
|
|
722
|
+
waitedMs: serverNow.getTime() - deviceAt.getTime(),
|
|
723
|
+
actor: { ...this.actor }
|
|
724
|
+
});
|
|
725
|
+
if (!resolved || !(resolved.at instanceof Date) || !Number.isFinite(resolved.at.getTime())) throw new Error("resolveAt must return a valid date.");
|
|
726
|
+
at = resolved.at;
|
|
727
|
+
review = resolved.reviewRequired === true;
|
|
728
|
+
}
|
|
729
|
+
let outcome;
|
|
730
|
+
try {
|
|
731
|
+
outcome = await withEmbeddedWriteTransaction(tx.db, isEmbeddedDatabase(tx.db), async (db) => {
|
|
732
|
+
const inner = {
|
|
733
|
+
db,
|
|
734
|
+
punches: await AttendancePunchCollection.create({ db }),
|
|
735
|
+
breaks: await AttendanceBreakCollection.create({ db }),
|
|
736
|
+
segments: await AttendanceSegmentCollection.create({ db }),
|
|
737
|
+
replays: tx.replays
|
|
738
|
+
};
|
|
739
|
+
const { punch, segment } = await this.apply(inner, tap, at);
|
|
740
|
+
punch.recordedOffline = true;
|
|
741
|
+
punch.reviewRequired = true;
|
|
742
|
+
await punch.save();
|
|
743
|
+
if (segment && review) {
|
|
744
|
+
segment.reviewRequired = true;
|
|
745
|
+
await segment.save();
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
clientId: tap.clientId,
|
|
749
|
+
punchId: punch.id,
|
|
750
|
+
...segment ? { segmentId: segment.id } : {},
|
|
751
|
+
...at.getTime() !== deviceAt.getTime() ? { resolvedAt: at.toISOString() } : {}
|
|
752
|
+
};
|
|
753
|
+
}, true);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (!(error instanceof AttendanceConflict)) throw error;
|
|
756
|
+
const custom = options.refusal?.(tap, error) || void 0;
|
|
757
|
+
outcome = {
|
|
758
|
+
clientId: tap.clientId,
|
|
759
|
+
error: custom?.message || error.message,
|
|
760
|
+
code: custom?.code || error.code
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
await tx.replays.create({
|
|
764
|
+
tenantId: this.actor.tenantId,
|
|
765
|
+
replayKey,
|
|
766
|
+
content,
|
|
767
|
+
outcome: JSON.stringify(outcome),
|
|
768
|
+
_insertOnly: true
|
|
769
|
+
});
|
|
770
|
+
return outcome;
|
|
771
|
+
}));
|
|
772
|
+
return outcomes;
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/attendance/correction-service.ts
|
|
777
|
+
var iso = (d) => d ? d.toISOString() : null;
|
|
778
|
+
var punchState = (p) => ({
|
|
779
|
+
startedAt: iso(p.startedAt),
|
|
780
|
+
endedAt: iso(p.endedAt),
|
|
781
|
+
durationSeconds: p.durationSeconds,
|
|
782
|
+
unpaidBreakSeconds: p.unpaidBreakSeconds,
|
|
783
|
+
reviewRequired: p.reviewRequired,
|
|
784
|
+
...p.source ? { source: p.source } : {},
|
|
785
|
+
...p.deviceRefType ? {
|
|
786
|
+
deviceRefType: p.deviceRefType,
|
|
787
|
+
deviceRefId: p.deviceRefId
|
|
788
|
+
} : {}
|
|
789
|
+
});
|
|
790
|
+
var breakState = (b) => ({
|
|
791
|
+
startedAt: iso(b.startedAt),
|
|
792
|
+
endedAt: iso(b.endedAt),
|
|
793
|
+
paid: b.paid,
|
|
794
|
+
...b.kind ? { kind: b.kind } : {}
|
|
795
|
+
});
|
|
796
|
+
var segmentState = (s) => ({
|
|
797
|
+
startedAt: iso(s.startedAt),
|
|
798
|
+
endedAt: iso(s.endedAt),
|
|
799
|
+
workRefType: s.workRefType,
|
|
800
|
+
workRefId: s.workRefId,
|
|
801
|
+
taskRefType: s.taskRefType,
|
|
802
|
+
taskRefId: s.taskRefId,
|
|
803
|
+
kind: s.kind,
|
|
804
|
+
description: s.description,
|
|
805
|
+
durationSeconds: s.durationSeconds,
|
|
806
|
+
closedReason: s.closedReason
|
|
807
|
+
});
|
|
808
|
+
function instant(value, name) {
|
|
809
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new AttendanceConflict(`${name} must be a valid date.`);
|
|
810
|
+
return value;
|
|
811
|
+
}
|
|
812
|
+
function overlapping(a, b) {
|
|
813
|
+
return a.start < b.end && b.start < a.end;
|
|
814
|
+
}
|
|
815
|
+
function validateLayout(layout) {
|
|
816
|
+
const inside = (r) => r.endedAt.getTime() >= r.startedAt.getTime() && r.startedAt.getTime() >= layout.startsAt && r.endedAt.getTime() <= layout.endsAt;
|
|
817
|
+
if (!layout.breaks.every(inside)) throw new AttendanceConflict("A break must be an interval inside its punch.");
|
|
818
|
+
if (!layout.segments.every(inside)) throw new AttendanceConflict("A work segment must be an interval inside its punch.");
|
|
819
|
+
const span = (r) => ({
|
|
820
|
+
start: r.startedAt.getTime(),
|
|
821
|
+
end: r.endedAt.getTime()
|
|
822
|
+
});
|
|
823
|
+
const sets = [layout.breaks, layout.segments];
|
|
824
|
+
for (const set of sets) for (let i = 0; i < set.length; i++) for (let j = i + 1; j < set.length; j++) if (overlapping(span(set[i]), span(set[j]))) throw new AttendanceConflict(set === layout.breaks ? "Breaks must not overlap." : "Work segments must not overlap.");
|
|
825
|
+
for (const b of layout.breaks) for (const s of layout.segments) if (overlapping(span(b), span(s))) throw new AttendanceConflict("A work segment must not overlap a break.");
|
|
826
|
+
}
|
|
827
|
+
var AttendanceCorrectionService = class {
|
|
828
|
+
constructor(db, actor, options = {}) {
|
|
829
|
+
this.db = db;
|
|
830
|
+
this.options = options;
|
|
831
|
+
if (!actor?.tenantId || !actor.profileId) throw new Error("Corrections require a trusted tenant/profile actor.");
|
|
832
|
+
this.actor = {
|
|
833
|
+
tenantId: attendanceIdentity(db, actor.tenantId),
|
|
834
|
+
profileId: attendanceIdentity(db, actor.profileId)
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
db;
|
|
838
|
+
options;
|
|
839
|
+
actor;
|
|
840
|
+
/** Change a closed punch's start and/or end. A reviewed punch no longer needs review. */
|
|
841
|
+
async correctPunch(input) {
|
|
842
|
+
return this.run(input, async (tx, subject, record) => {
|
|
843
|
+
const punch = await this.closedPunch(tx, subject, input.punchId);
|
|
844
|
+
const before = punchState(punch);
|
|
845
|
+
const startedAt = input.startedAt === void 0 ? punch.startedAt : instant(input.startedAt, "startedAt");
|
|
846
|
+
const endedAt = input.endedAt === void 0 ? punch.endedAt : instant(input.endedAt, "endedAt");
|
|
847
|
+
if (endedAt <= startedAt) throw new AttendanceConflict("A punch must end after it starts.");
|
|
848
|
+
const children = await this.children(tx, punch);
|
|
849
|
+
validateLayout(this.layout(startedAt, endedAt, children));
|
|
850
|
+
for (const other of await tx.punches.list({ where: {
|
|
851
|
+
tenantId: subject.tenantId,
|
|
852
|
+
profileId: subject.profileId
|
|
853
|
+
} })) if (other.id !== punch.id && overlapping({
|
|
854
|
+
start: startedAt.getTime(),
|
|
855
|
+
end: endedAt.getTime()
|
|
856
|
+
}, {
|
|
857
|
+
start: other.startedAt.getTime(),
|
|
858
|
+
end: other.endedAt?.getTime() ?? Number.POSITIVE_INFINITY
|
|
859
|
+
})) throw new AttendanceConflict("Punches must not overlap.");
|
|
860
|
+
await this.guard(tx, subject, [{
|
|
861
|
+
startsAt: punch.startedAt,
|
|
862
|
+
endsAt: punch.endedAt
|
|
863
|
+
}, {
|
|
864
|
+
startsAt: startedAt,
|
|
865
|
+
endsAt: endedAt
|
|
866
|
+
}]);
|
|
867
|
+
const entry = await this.editableEntry(tx, punch);
|
|
868
|
+
punch.startedAt = startedAt;
|
|
869
|
+
punch.endedAt = endedAt;
|
|
870
|
+
punch.reviewRequired = false;
|
|
871
|
+
await this.settle(tx, punch, children.breaks, entry);
|
|
872
|
+
return record("punch.corrected", punch, punch.id, before, punchState(punch));
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/** Record a missed punch retroactively, as an already-closed interval. */
|
|
876
|
+
async addPunch(input) {
|
|
877
|
+
return this.run(input, async (tx, subject, record) => {
|
|
878
|
+
const startedAt = instant(input.startedAt, "startedAt");
|
|
879
|
+
const endedAt = instant(input.endedAt, "endedAt");
|
|
880
|
+
const meta = attendanceProvenance(input);
|
|
881
|
+
if (endedAt <= startedAt) throw new AttendanceConflict("A punch must end after it starts.");
|
|
882
|
+
for (const other of await tx.punches.list({ where: {
|
|
883
|
+
tenantId: subject.tenantId,
|
|
884
|
+
profileId: subject.profileId
|
|
885
|
+
} })) if (overlapping({
|
|
886
|
+
start: startedAt.getTime(),
|
|
887
|
+
end: endedAt.getTime()
|
|
888
|
+
}, {
|
|
889
|
+
start: other.startedAt.getTime(),
|
|
890
|
+
end: other.endedAt?.getTime() ?? Number.POSITIVE_INFINITY
|
|
891
|
+
})) throw new AttendanceConflict("Punches must not overlap.");
|
|
892
|
+
await this.guard(tx, subject, [{
|
|
893
|
+
startsAt: startedAt,
|
|
894
|
+
endsAt: endedAt
|
|
895
|
+
}]);
|
|
896
|
+
const punch = await tx.punches.create({
|
|
897
|
+
tenantId: subject.tenantId,
|
|
898
|
+
profileId: subject.profileId,
|
|
899
|
+
startedAt,
|
|
900
|
+
endedAt,
|
|
901
|
+
durationSeconds: Math.round((endedAt.getTime() - startedAt.getTime()) / 1e3),
|
|
902
|
+
...meta,
|
|
903
|
+
_insertOnly: true
|
|
904
|
+
});
|
|
905
|
+
return record("punch.added", punch, punch.id, null, punchState(punch));
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/** Add a break to a closed punch, or change one (times and/or paid). */
|
|
909
|
+
async saveBreak(input) {
|
|
910
|
+
return this.run(input, async (tx, subject, record) => {
|
|
911
|
+
const punch = await this.closedPunch(tx, subject, input.punchId);
|
|
912
|
+
const children = await this.children(tx, punch);
|
|
913
|
+
const existing = input.breakId ? children.breaks.find((b) => b.id === input.breakId) : void 0;
|
|
914
|
+
if (input.breakId && !existing) throw new AttendanceConflict("Break not found in that punch.");
|
|
915
|
+
if (!existing && (input.startedAt === void 0 || input.endedAt === void 0)) throw new AttendanceConflict("A new break needs startedAt and endedAt.");
|
|
916
|
+
if (input.paid !== void 0 && typeof input.paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
|
|
917
|
+
const next = {
|
|
918
|
+
startedAt: instant(input.startedAt ?? existing?.startedAt, "startedAt"),
|
|
919
|
+
endedAt: instant(input.endedAt ?? existing?.endedAt ?? void 0, "endedAt"),
|
|
920
|
+
paid: input.paid ?? existing?.paid ?? false,
|
|
921
|
+
kind: input.kind === void 0 ? existing?.kind ?? "" : attendanceLabel(input.kind, "kind")
|
|
922
|
+
};
|
|
923
|
+
const rest = children.breaks.filter((b) => b !== existing);
|
|
924
|
+
validateLayout(this.layout(punch.startedAt, punch.endedAt, {
|
|
925
|
+
breaks: [...rest, next],
|
|
926
|
+
segments: children.segments
|
|
927
|
+
}));
|
|
928
|
+
await this.guard(tx, subject, [{
|
|
929
|
+
startsAt: punch.startedAt,
|
|
930
|
+
endsAt: punch.endedAt
|
|
931
|
+
}]);
|
|
932
|
+
const entry = await this.editableEntry(tx, punch);
|
|
933
|
+
const before = existing ? breakState(existing) : null;
|
|
934
|
+
let target;
|
|
935
|
+
if (existing) {
|
|
936
|
+
existing.startedAt = next.startedAt;
|
|
937
|
+
existing.endedAt = next.endedAt;
|
|
938
|
+
existing.paid = next.paid;
|
|
939
|
+
existing.kind = next.kind;
|
|
940
|
+
await persistCorrection(existing);
|
|
941
|
+
target = existing;
|
|
942
|
+
} else {
|
|
943
|
+
target = await new AttendanceBreak({
|
|
944
|
+
db: tx.db,
|
|
945
|
+
id: crypto.randomUUID(),
|
|
946
|
+
_skipLoad: true
|
|
947
|
+
}).initialize();
|
|
948
|
+
Object.assign(target, {
|
|
949
|
+
tenantId: subject.tenantId,
|
|
950
|
+
punchId: punch.id ?? void 0,
|
|
951
|
+
...next
|
|
952
|
+
});
|
|
953
|
+
await persistCorrection(target);
|
|
954
|
+
}
|
|
955
|
+
await this.settle(tx, punch, [...rest, target], entry);
|
|
956
|
+
return record(existing ? "break.changed" : "break.added", punch, target.id, before, breakState(target));
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
/** Remove a break from a closed punch. */
|
|
960
|
+
async removeBreak(input) {
|
|
961
|
+
return this.run(input, async (tx, subject, record) => {
|
|
962
|
+
const [target] = await tx.breaks.list({
|
|
963
|
+
where: {
|
|
964
|
+
tenantId: subject.tenantId,
|
|
965
|
+
id: input.breakId
|
|
966
|
+
},
|
|
967
|
+
limit: 1
|
|
968
|
+
});
|
|
969
|
+
if (!target) throw new AttendanceConflict("Break not found.");
|
|
970
|
+
const punch = await this.closedPunch(tx, subject, target.punchId);
|
|
971
|
+
const children = await this.children(tx, punch);
|
|
972
|
+
await this.guard(tx, subject, [{
|
|
973
|
+
startsAt: punch.startedAt,
|
|
974
|
+
endsAt: punch.endedAt
|
|
975
|
+
}]);
|
|
976
|
+
const entry = await this.editableEntry(tx, punch);
|
|
977
|
+
const before = breakState(target);
|
|
978
|
+
await deleteCorrection(target);
|
|
979
|
+
await this.settle(tx, punch, children.breaks.filter((b) => b.id !== target.id), entry);
|
|
980
|
+
return record("break.removed", punch, target.id, before, null);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
/** Add a work segment to a closed punch, or change one (times and/or work attribution). */
|
|
984
|
+
async saveSegment(input) {
|
|
985
|
+
return this.run(input, async (tx, subject, record) => {
|
|
986
|
+
const punch = await this.closedPunch(tx, subject, input.punchId);
|
|
987
|
+
const children = await this.children(tx, punch);
|
|
988
|
+
const existing = input.segmentId ? children.segments.find((s) => s.id === input.segmentId) : void 0;
|
|
989
|
+
if (input.segmentId && !existing) throw new AttendanceConflict("Work segment not found in that punch.");
|
|
990
|
+
if (!existing && (input.startedAt === void 0 || input.endedAt === void 0 || !input.work)) throw new AttendanceConflict("A new work segment needs startedAt, endedAt and work.");
|
|
991
|
+
if (input.work) validateAttendanceWork(input.work);
|
|
992
|
+
const next = {
|
|
993
|
+
startedAt: instant(input.startedAt ?? existing?.startedAt, "startedAt"),
|
|
994
|
+
endedAt: instant(input.endedAt ?? existing?.endedAt ?? void 0, "endedAt")
|
|
995
|
+
};
|
|
996
|
+
const rest = children.segments.filter((s) => s !== existing);
|
|
997
|
+
validateLayout(this.layout(punch.startedAt, punch.endedAt, {
|
|
998
|
+
breaks: children.breaks,
|
|
999
|
+
segments: [...rest, next]
|
|
1000
|
+
}));
|
|
1001
|
+
await this.guard(tx, subject, [{
|
|
1002
|
+
startsAt: punch.startedAt,
|
|
1003
|
+
endsAt: punch.endedAt
|
|
1004
|
+
}]);
|
|
1005
|
+
const before = existing ? segmentState(existing) : null;
|
|
1006
|
+
let target;
|
|
1007
|
+
if (existing) {
|
|
1008
|
+
existing.startedAt = next.startedAt;
|
|
1009
|
+
existing.endedAt = next.endedAt;
|
|
1010
|
+
if (input.work) this.attribute(existing, input.work);
|
|
1011
|
+
await persistCorrection(existing);
|
|
1012
|
+
target = existing;
|
|
1013
|
+
} else {
|
|
1014
|
+
target = await new AttendanceSegment({
|
|
1015
|
+
db: tx.db,
|
|
1016
|
+
id: crypto.randomUUID(),
|
|
1017
|
+
_skipLoad: true
|
|
1018
|
+
}).initialize();
|
|
1019
|
+
Object.assign(target, {
|
|
1020
|
+
tenantId: subject.tenantId,
|
|
1021
|
+
punchId: punch.id ?? void 0,
|
|
1022
|
+
workRefType: input.work.workRefType,
|
|
1023
|
+
workRefId: input.work.workRefId,
|
|
1024
|
+
taskRefType: input.work.taskRefType ?? "",
|
|
1025
|
+
taskRefId: input.work.taskRefId ?? "",
|
|
1026
|
+
kind: input.work.kind ?? "",
|
|
1027
|
+
description: input.work.description,
|
|
1028
|
+
...next,
|
|
1029
|
+
closedReason: "stop"
|
|
1030
|
+
});
|
|
1031
|
+
await persistCorrection(target);
|
|
1032
|
+
}
|
|
1033
|
+
return record(existing ? "segment.changed" : "segment.added", punch, target.id, before, segmentState(target));
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
/** Remove a work segment from a closed punch. */
|
|
1037
|
+
async removeSegment(input) {
|
|
1038
|
+
return this.run(input, async (tx, subject, record) => {
|
|
1039
|
+
const [target] = await tx.segments.list({
|
|
1040
|
+
where: {
|
|
1041
|
+
tenantId: subject.tenantId,
|
|
1042
|
+
id: input.segmentId
|
|
1043
|
+
},
|
|
1044
|
+
limit: 1
|
|
1045
|
+
});
|
|
1046
|
+
if (!target) throw new AttendanceConflict("Work segment not found.");
|
|
1047
|
+
const punch = await this.closedPunch(tx, subject, target.punchId);
|
|
1048
|
+
await this.guard(tx, subject, [{
|
|
1049
|
+
startsAt: punch.startedAt,
|
|
1050
|
+
endsAt: punch.endedAt
|
|
1051
|
+
}]);
|
|
1052
|
+
const before = segmentState(target);
|
|
1053
|
+
await deleteCorrection(target);
|
|
1054
|
+
return record("segment.removed", punch, target.id, before, null);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The subject confirms they have seen a correction that required it. Only
|
|
1059
|
+
* the subject (this service's actor) may; it can be done once.
|
|
1060
|
+
*/
|
|
1061
|
+
async acknowledge(correctionId, at = /* @__PURE__ */ new Date()) {
|
|
1062
|
+
instant(at, "at");
|
|
1063
|
+
const id = await this.attendance(this.actor.profileId).withTransaction(async (tx) => {
|
|
1064
|
+
const [row] = await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
|
|
1065
|
+
where: {
|
|
1066
|
+
tenantId: this.actor.tenantId,
|
|
1067
|
+
id: correctionId
|
|
1068
|
+
},
|
|
1069
|
+
limit: 1
|
|
1070
|
+
});
|
|
1071
|
+
if (!row) throw new AttendanceConflict("Correction not found.");
|
|
1072
|
+
if (row.subjectProfileId !== this.actor.profileId) throw new AttendanceConflict("Only the subject can acknowledge a correction.");
|
|
1073
|
+
if (!row.acknowledgementRequired) throw new AttendanceConflict("This correction does not require acknowledgement.");
|
|
1074
|
+
if (row.acknowledgedAt) throw new AttendanceConflict("Correction already acknowledged.");
|
|
1075
|
+
row.acknowledgedAt = at;
|
|
1076
|
+
row.acknowledgedByProfileId = this.actor.profileId;
|
|
1077
|
+
await persistCorrection(row);
|
|
1078
|
+
return row.id;
|
|
1079
|
+
});
|
|
1080
|
+
return this.read(id);
|
|
1081
|
+
}
|
|
1082
|
+
/** Corrections to one punch, oldest first. */
|
|
1083
|
+
async listForPunch(punchId) {
|
|
1084
|
+
return this.attendance(this.actor.profileId).withTransaction(async (tx) => await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
|
|
1085
|
+
where: {
|
|
1086
|
+
tenantId: this.actor.tenantId,
|
|
1087
|
+
punchId
|
|
1088
|
+
},
|
|
1089
|
+
orderBy: "created_at ASC"
|
|
1090
|
+
}));
|
|
1091
|
+
}
|
|
1092
|
+
/** Corrections of the actor's own time that still await their acknowledgement. */
|
|
1093
|
+
async pendingAcknowledgements() {
|
|
1094
|
+
return this.attendance(this.actor.profileId).withTransaction(async (tx) => (await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
|
|
1095
|
+
where: {
|
|
1096
|
+
tenantId: this.actor.tenantId,
|
|
1097
|
+
subjectProfileId: this.actor.profileId
|
|
1098
|
+
},
|
|
1099
|
+
orderBy: "created_at ASC"
|
|
1100
|
+
})).filter((row) => row.acknowledgementRequired && !row.acknowledgedAt));
|
|
1101
|
+
}
|
|
1102
|
+
attendance(subjectProfileId) {
|
|
1103
|
+
return new AttendanceService(this.db, {
|
|
1104
|
+
tenantId: this.actor.tenantId,
|
|
1105
|
+
profileId: subjectProfileId
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
async read(id) {
|
|
1109
|
+
return this.attendance(this.actor.profileId).withTransaction(async (tx) => {
|
|
1110
|
+
const [row] = await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
|
|
1111
|
+
where: {
|
|
1112
|
+
tenantId: this.actor.tenantId,
|
|
1113
|
+
id
|
|
1114
|
+
},
|
|
1115
|
+
limit: 1
|
|
1116
|
+
});
|
|
1117
|
+
if (!row) throw new Error("Committed attendance correction disappeared.");
|
|
1118
|
+
return row;
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
async run(request, operation) {
|
|
1122
|
+
const reason = typeof request.reason === "string" ? request.reason.trim() : "";
|
|
1123
|
+
if (!reason) throw new AttendanceConflict("A correction needs a reason.");
|
|
1124
|
+
if (request.acknowledgementRequired !== void 0 && typeof request.acknowledgementRequired !== "boolean") throw new AttendanceConflict("acknowledgementRequired must be boolean.");
|
|
1125
|
+
if (!request.subjectProfileId) throw new AttendanceConflict("A correction needs a subject.");
|
|
1126
|
+
const subject = {
|
|
1127
|
+
tenantId: this.actor.tenantId,
|
|
1128
|
+
profileId: attendanceIdentity(this.db, request.subjectProfileId)
|
|
1129
|
+
};
|
|
1130
|
+
const id = await this.attendance(subject.profileId).withTransaction(async (tx) => {
|
|
1131
|
+
await AttendanceCorrectionCollection.create({ db: tx.db });
|
|
1132
|
+
return operation(tx, subject, async (kind, punch, targetId, before, after) => {
|
|
1133
|
+
const row = await new AttendanceCorrection({
|
|
1134
|
+
db: tx.db,
|
|
1135
|
+
id: crypto.randomUUID(),
|
|
1136
|
+
_skipLoad: true
|
|
1137
|
+
}).initialize();
|
|
1138
|
+
Object.assign(row, {
|
|
1139
|
+
tenantId: subject.tenantId,
|
|
1140
|
+
subjectProfileId: subject.profileId,
|
|
1141
|
+
actorProfileId: this.actor.profileId,
|
|
1142
|
+
kind,
|
|
1143
|
+
punchId: punch.id ?? void 0,
|
|
1144
|
+
targetId,
|
|
1145
|
+
beforeState: JSON.stringify(before),
|
|
1146
|
+
afterState: JSON.stringify(after),
|
|
1147
|
+
reason,
|
|
1148
|
+
acknowledgementRequired: request.acknowledgementRequired ?? false
|
|
1149
|
+
});
|
|
1150
|
+
await persistCorrection(row);
|
|
1151
|
+
return row.id;
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
return this.read(id);
|
|
1155
|
+
}
|
|
1156
|
+
async closedPunch(tx, subject, punchId) {
|
|
1157
|
+
const [punch] = await tx.punches.list({
|
|
1158
|
+
where: {
|
|
1159
|
+
tenantId: subject.tenantId,
|
|
1160
|
+
profileId: subject.profileId,
|
|
1161
|
+
id: punchId
|
|
1162
|
+
},
|
|
1163
|
+
limit: 1
|
|
1164
|
+
});
|
|
1165
|
+
if (!punch) throw new AttendanceConflict("Punch not found for subject.");
|
|
1166
|
+
if (!punch.endedAt) throw new AttendanceConflict("Only closed punches are corrected; use AttendanceService for an open one.");
|
|
1167
|
+
return punch;
|
|
1168
|
+
}
|
|
1169
|
+
async children(tx, punch) {
|
|
1170
|
+
const where = {
|
|
1171
|
+
tenantId: punch.tenantId,
|
|
1172
|
+
punchId: punch.id
|
|
1173
|
+
};
|
|
1174
|
+
const breaks = await tx.breaks.list({
|
|
1175
|
+
where,
|
|
1176
|
+
orderBy: "started_at ASC"
|
|
1177
|
+
});
|
|
1178
|
+
const segments = await tx.segments.list({
|
|
1179
|
+
where,
|
|
1180
|
+
orderBy: "started_at ASC"
|
|
1181
|
+
});
|
|
1182
|
+
if (breaks.some((b) => !b.endedAt) || segments.some((s) => !s.endedAt)) throw new AttendanceConflict("Closed punch has an open break or segment.");
|
|
1183
|
+
return {
|
|
1184
|
+
breaks,
|
|
1185
|
+
segments
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
layout(startedAt, endedAt, children) {
|
|
1189
|
+
return {
|
|
1190
|
+
startsAt: startedAt.getTime(),
|
|
1191
|
+
endsAt: endedAt.getTime(),
|
|
1192
|
+
breaks: children.breaks.map((b) => ({
|
|
1193
|
+
startedAt: b.startedAt,
|
|
1194
|
+
endedAt: b.endedAt,
|
|
1195
|
+
paid: b.paid
|
|
1196
|
+
})),
|
|
1197
|
+
segments: children.segments.map((s) => ({
|
|
1198
|
+
startedAt: s.startedAt,
|
|
1199
|
+
endedAt: s.endedAt
|
|
1200
|
+
}))
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
attribute(segment, work) {
|
|
1204
|
+
segment.workRefType = work.workRefType;
|
|
1205
|
+
segment.workRefId = work.workRefId;
|
|
1206
|
+
segment.taskRefType = work.taskRefType ?? "";
|
|
1207
|
+
segment.taskRefId = work.taskRefId ?? "";
|
|
1208
|
+
segment.kind = work.kind ?? "";
|
|
1209
|
+
segment.description = work.description;
|
|
1210
|
+
}
|
|
1211
|
+
async guard(tx, subject, intervals) {
|
|
1212
|
+
const refusal = await this.options.guard?.(tx, subject, intervals);
|
|
1213
|
+
if (refusal) throw new AttendanceConflict(refusal);
|
|
1214
|
+
}
|
|
1215
|
+
/** The punch's still-draft evidence entry, refusing when it is no longer editable. */
|
|
1216
|
+
async editableEntry(tx, punch) {
|
|
1217
|
+
if (!punch.serviceTimeEntryId) return null;
|
|
1218
|
+
const [entry] = await (await ServiceTimeEntryCollection.create({ db: tx.db })).list({
|
|
1219
|
+
where: {
|
|
1220
|
+
id: punch.serviceTimeEntryId,
|
|
1221
|
+
tenantId: punch.tenantId
|
|
1222
|
+
},
|
|
1223
|
+
limit: 1
|
|
1224
|
+
});
|
|
1225
|
+
if (!entry) return null;
|
|
1226
|
+
if (entry.status !== "draft") throw new AttendanceConflict(`Linked service evidence is ${entry.status}; correct it with ServiceEvidenceService first.`);
|
|
1227
|
+
return entry;
|
|
1228
|
+
}
|
|
1229
|
+
/** Recompute the punch's durations from its breaks and persist it and its draft entry. */
|
|
1230
|
+
async settle(tx, punch, breaks, entry) {
|
|
1231
|
+
const unpaidMs = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + b.endedAt.getTime() - b.startedAt.getTime(), 0);
|
|
1232
|
+
punch.unpaidBreakSeconds = Math.round(unpaidMs / 1e3);
|
|
1233
|
+
punch.durationSeconds = Math.round((punch.endedAt.getTime() - punch.startedAt.getTime() - unpaidMs) / 1e3);
|
|
1234
|
+
await persistCorrection(punch);
|
|
1235
|
+
if (entry) {
|
|
1236
|
+
entry.startedAt = punch.startedAt;
|
|
1237
|
+
entry.endedAt = punch.endedAt;
|
|
1238
|
+
entry.durationSeconds = punch.durationSeconds;
|
|
1239
|
+
await entry.save();
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
//#endregion
|
|
1244
|
+
export { continueSegment as a, openSegment as c, validateAttendanceWork as d, ATTENDANCE_CORRECTION_KINDS as f, closeOpenSegment as i, replaceSegment as l, AttendanceCorrectionCollection as m, AttendanceService as n, endSegment as o, AttendanceCorrection as p, beginSegment as r, listSegments as s, AttendanceCorrectionService as t, AttendanceConflict as u };
|
|
1245
|
+
|
|
1246
|
+
//# sourceMappingURL=attendance-DGVy-iHI.js.map
|