Attendance — clock-in/out capture, shifts & timetables

The whole attendance model reduces to one idea: every clock event is one immutable Attendance row carrying a time, a start-of-day date, and a kind (CLOCK_IN/CLOCK_OUT); there is no per-day "session" record. Whatever you need to know about a day (worked hours, who was present, who was absent) is derived by aggregating those rows — grouped by employeeId + date. The four capture channels (QR, location, biometric device, manual/admin) all funnel into the same Attendance collection via the same service; they differ only in submitType and how they reach the service. Shifts, timetables and attendance-groups are configuration that describes the expected schedule; the heavy computation (worked-hours, absence, overtime money) that actually consumes that configuration lives downstream in payroll, not here.

Source: BE src/modules/hr/attendance (attendance.{schema,service,repository,resolver,dto}.ts + sub-folders shift/, timetable/, group/, device-sync/) · Admin src/modules/hr/attendance, src/modules/hr/attendance-devices, src/modules/hr/attendance-group, src/modules/hr/shift, src/modules/hr/timetable, page src/pages/attendance-qr.tsx


1. Purpose & scope

The attendance subsystem is responsible for:

  • Capturing clock events. A single Attendance row per clock punch, regardless of channel: QR scan (ESS), GPS/location, HikVision biometric device, or manual admin entry / Excel import.
  • Auto-alternating kind. It never asks "is this a clock-in or clock-out?"; it looks at the person's last record for that day and flips CLOCK_IN ↔︎ CLOCK_OUT.
  • Biometric device ingestion. A polling addon (device-sync/) that drains HikVision ISAPI access-control events into Attendance, keyed by the device's employeeNoString (== Employee.ref) so swipes can be logged before the employee row exists and back-linked later.
  • Shift / timetable / attendance-group configuration. The schedule model: Timetable (work-hours + allowances for one day-pattern) → Shift (a 7-day pattern mapping each weekday to a timetable) → AttendanceGroup (assigns a shift + clock-in policy + working-hours/days constants to a set of employees via Employee.attendanceGroupId).
  • Read surfaces. Raw paged logs (attendancePage) and a per-employee-per-day rollup (attendanceByDayPage) with firstIn / lastOut / totalHrs.

It explicitly does NOT:

  • Compute lateness / overtime / early-leave status on the attendance row. The AttendanceStatus enum (REGULAR | LATE | EARLY_LEAVE | OVERTIME) and the late / earlyLeave minute fields on AttendanceByDay are declared in the schema/DTO and surfaced in GraphQL but are never populated by any code path (no writer assigns them). The timetable's lateAllowance / earlyLeaveAllowance / overtimeStartTime are stored as configuration but are not read by any calculation. See §4.3 and §9 — this is a deliberate, code-verified "not implemented" call-out.
  • Compute worked-hours into money. totalHrs is the only worked-hours figure attendance produces, and it is a naive lastOut − firstIn. Absence deductions and overtime pay are computed in payroll (hr/payroll/employee/employee.service.ts), which reads attendance + the shift schedule + the calendar holidays. See §4.4 / §8.
  • Own the timesheet. Overtime hours come from approved timesheet rows, not from attendance.
  • Drive the dashboard "present/absent" tiles correctly. HrDashboardService.getAttendanceMetrics() queries Attendance.status for "PRESENT" | "ON_LEAVE" | "ABSENT" — values that are neither in the AttendanceStatus enum nor ever written — so those tiles always read 0 (everyone falls into untracked). See §8 and §9.

2. Data model

Five collections. The Attendance ledger is the runtime data; the other four are configuration. All extend BaseSchema (_id, companyId, branchId, createdAt/By, updatedAt/By, soft-delete) and use the @HrSchema decorator. Note the collection-name quirk: the decorators set explicit collection names without the hr_ prefix (attendances, shifts, shift_days, timetables, attendance_groups, attendance_devices, attendance_sync) — yet the @AuditMeta decorators on the resolvers tag them with hr_-prefixed names (hr_attendances, hr_shifts, …). The ShiftRepository even joins from: 'hr_shift_days' (prefixed) while the schema declares collection: "shift_days" (unprefixed). Treat the schema decorator as authoritative for the actual Mongo collection. Flag (§9).

2.1 attendances — the clock-event ledger (the heart of the system)

Every clock punch is one row. Quantity-of-presence is always derived by grouping these rows; there is no mutable "hours today" field.

// attendance.schema.ts
export enum AttendanceKind {
  CLOCK_IN  = "CLOCK_IN",
  CLOCK_OUT = "CLOCK_OUT",
}

export enum AttendanceStatus {        // ⚠ declared, registered in GraphQL, but NEVER written
  REGULAR     = "REGULAR",
  LATE        = "LATE",
  EARLY_LEAVE = "EARLY_LEAVE",
  OVERTIME    = "OVERTIME",
}

export enum AttendanceSubmitType {    // how the punch arrived
  QR       = "QR",
  LOCATION = "LOCATION",
  DEVICE   = "DEVICE",
  MANUAL   = "MANUAL",                // default
}

@HrSchema({ collection: "attendances" })
export class Attendance extends BaseSchema {
  employeeId?: ObjectId;     // → employees. OPTIONAL — device swipes may arrive before the employee is linked
  time: number;              // REQUIRED — exact unix-ms timestamp of the punch
  date: number;              // REQUIRED — start-of-day unix-ms, used to group a person's punches into one "day"
  kind: AttendanceKind;      // CLOCK_IN | CLOCK_OUT  (auto-alternated, see §4.1)
  status: AttendanceStatus;  // ⚠ never set by any code path
  submitType: AttendanceSubmitType;  // default MANUAL
  note?: string;
  latitude?: number;         // LOCATION punches
  longitude?: number;
  address?: string;
  deviceIndex?: string;      // indexed — HikVision device that produced this (null for QR/location/manual)
  employeeNoString?: string; // indexed — HikVision employeeNoString == Employee.ref; enables pre-link logging
}
field type required description
employeeId ObjectId → employees no The person. Unset on a device swipe until the matching employee exists (then back-filled, §4.2).
time number (unix ms) yes Exact moment of the punch. firstIn/lastOut/totalHrs are computed from this.
date number (unix ms) yes Start-of-day of time (DateUtils.startOfDay). The grouping key — all of one person's punches on a calendar day share this value.
kind enum AttendanceKind CLOCK_IN / CLOCK_OUT. Auto-alternated from the last record (§4.1).
status enum AttendanceStatus Dead field. Declared + GraphQL-registered, never assigned.
submitType enum AttendanceSubmitType — (default MANUAL) Channel provenance: QR, LOCATION, DEVICE, MANUAL.
note string Free text (manual/admin).
latitude / longitude / address number / string GPS for LOCATION punches (written through UpdateAttendanceInput; clockIn does not set them).
deviceIndex string (indexed) HikVision devIndex of the source device.
employeeNoString string (indexed) The device-side staff number; equals Employee.ref. The link key for pre-employee device records.

Indexes (attendance.schema.ts): { employeeId, date }, { date }, and a unique partial index { employeeNoString, time, deviceIndex } (only when employeeNoString exists) — the idempotency guard that makes re-syncing the same device window safe.

Multi-tenancy / scoping: companyId is the tenant boundary. The repository's buildQuery / buildDateRangeQuery force employeeId = contextSvc.employeeId when the caller is an employee context (ESS) — so an ESS user can only ever read their own records; an admin context (no employeeId) sees all. Device swipes get companyId only once linked to an employee.

2.2 timetables — a single day-pattern's work-hours & allowances

The lowest-level schedule unit. Defines, for one kind of working day, when work starts/ends and the grace/overtime parameters. All time fields are minutes-from-midnight (e.g. 540 = 09:00), except minWorkHours / maxOvertimeDuration / breakTimeDuration which are plain durations in minutes.

// timetable/timetable.schema.ts
export enum TimetableType   { NormalShift = "NormalShift", OvertimeShift = "OvertimeShift" }
export enum TimetableCalcBy { FirstInLastOut = "FirstInLastOut", EveryInLastOut = "EveryInLastOut" }

@HrSchema({ collection: "timetables" })
export class Timetable extends BaseSchema {
  name: string;                        // REQUIRED
  type: TimetableType;                 // default NormalShift
  startWorkTime: number;               // minutes from midnight (default 0)
  endWorkTime: number;                 // minutes from midnight
  minWorkHours: number;                // minimum hours to count a full day
  lateAllowance: number;               // grace minutes before "late"  ⚠ stored, never consumed
  earlyLeaveAllowance: number;         // grace minutes before "early leave"  ⚠ stored, never consumed
  overtimeStartTime: number;           // minutes from midnight OT begins  ⚠ stored, never consumed
  maxOvertimeDuration: number;         // cap, minutes  ⚠ stored, never consumed
  breakTimeDuration: number;           // minutes
  calcBreakTimeIntoAttendance: boolean;// default false
  calcBy: TimetableCalcBy;             // FirstInLastOut | EveryInLastOut  ⚠ stored, never consumed
}

Every lateAllowance / earlyLeaveAllowance / overtimeStartTime / maxOvertimeDuration / calcBy field is configuration the engine does not yet read. They are persisted and editable in the admin timetable form (§7) but no service computes late/early/overtime status from them. The only timetable field that influences anything downstream is the day-of-week schedule (via the shift's ShiftDay.day), used by payroll's working-day set (§4.4).

2.3 shifts + shift_days — a weekday → timetable pattern

A Shift is a named repeating pattern; its ShiftDay children map a day-of-week to a timetable. One shift can map several weekdays, each to a different timetable.

// shift/shift.schema.ts
export enum ShiftRepeatMode { Week = "Week", Month = "Month", Day = "Day" }

@HrSchema({ collection: "shifts" })
export class Shift extends BaseSchema {
  name: string;                 // REQUIRED
  description: string;
  repeatMode: ShiftRepeatMode;  // default Week
  repeatCycle: number;          // default 1
}

// shift/shift-day.schema.ts
@HrSchema({ collection: "shift_days" })
export class ShiftDay extends BaseSchema {
  day: number;            // REQUIRED — 0=Sun … 6=Sat
  timeTableId: ObjectId;  // → timetables (the work-hours for that weekday)
  shiftId: ObjectId;      // → shifts (parent)
}

ShiftDay rows are children written/replaced by ShiftService on create/update (delete-all-then-recreate; see §4.5). repeatMode/repeatCycle are stored but only repeatMode=Week semantics are used in practice (the day-of-week mapping). The shift is read by payroll to derive which weekdays are working daysShiftDay.day values become the employee's working-day set (§4.4).

2.4 attendance_groups — policy + working-constants for a cohort of employees

The bridge between an employee and a shift. Employee.attendanceGroupId points here; everything that needs "how should this person's attendance behave" reads the group.

// group/group.schema.ts
@HrSchema({ collection: "attendance_groups" })
export class AttendanceGroup extends BaseSchema {
  name: string;                    // REQUIRED
  description: string;
  shiftId: ObjectId;               // → shifts (the schedule this cohort follows)
  locationClockinEnabled: boolean; // default false — allow GPS clock-in
  qrClockinEnabled: boolean;       // default false — allow QR clock-in
  checkInNotRequired: boolean;     // default false
  checkOutNotRequired: boolean;    // default false
  workingHoursPerDay: number;      // default 8  — used by payroll hourly-rate
  workingDaysPerMonth: number;     // default 30 — used by payroll daily/hourly-rate
}
field type default consumed by
shiftId ObjectId → shifts payroll getWorkingDaysOfWeek() → working-day set (§4.4).
locationClockinEnabled / qrClockinEnabled boolean false policy flags — surfaced in the admin form & ESS but not enforced server-side by clockIn/qrClockIn (no guard checks them). Flag (§9).
checkInNotRequired / checkOutNotRequired boolean false stored; not consumed by any computation. Flag.
workingHoursPerDay number 8 payroll getWorkingSettings() → hourly rate salary / workingDaysPerMonth / workingHoursPerDay.
workingDaysPerMonth number 30 payroll daily rate fallback salary / workingDaysPerMonth.

2.5 attendance_devices + attendance_sync — the HikVision addon's bookkeeping

// device-sync/device.schema.ts
@HrSchema({ collection: "attendance_devices" })
export class Device extends BaseSchema {
  devIndex: string;       // indexed — HikVision device identifier (the sync key)
  devName?: string; devType?: string; devStatus?: string;  // "online" | "offline"
  protocolType?: string; ehomeId?: string;
  activeStatus?: boolean; // default true
}

// device-sync/sync.schema.ts — one resumable cursor per device
@HrSchema({ collection: "attendance_sync" })
export class AttendanceSync extends BaseSchema {
  deviceIndex: string;             // indexed
  syncedAttendanceRecords: number; // pagination offset within the current day-window (default 0)
  startTime: number;               // current sync day-window start (unix ms)
  endTime: number;                 // current sync day-window end
  lastSyncedTime?: number;         // time of last imported event
  lastSyncedId?: string;           // employeeNoString of last imported event
}

Devices are discovered from the gateway and upserted globally (no company scoping) keyed on devIndex (DeviceRepository.upsertByDevIndex). AttendanceSync is the per-device resumable cursor that walks day-windows forward (§4.6).


3. API surface

All resolvers carry @ApGqlAuthorize() (JWT) + @UseGuards(GqlFeatureGuard) + @RequireFeature("HR_MODULE"). Mutations carry @AuditMeta (module attendance). The device-sync resolver is read-only.

Attendance (attendance.resolver.ts)

Operation Type Input Returns Permission
clockIn Mutation CreateAttendanceInput Attendance HR_MODULE; audit CREATE. Auto-alternates kind.
adminClockIn Mutation AdminClockInInput { employeeId, clockins[] } Boolean HR_MODULE; audit CREATE. Bulk manual pairs.
updateAttendance Mutation id, UpdateAttendanceInput Attendance HR_MODULE; audit UPDATE.
deleteAttendance Mutation id Boolean HR_MODULE; audit DELETE (soft-delete).
importAttendance Mutation AttendanceImportInput { file } AttendanceImportResult HR_MODULE; audit CREATE. XLSX import (§4.7).
attendancePage Query AttendancePageInput AttendancePageResult HR_MODULE. Raw paged events.
attendanceByDayPage Query AttendancePageInput AttendanceByDayPageResult HR_MODULE. Per-employee-per-day rollup.

CreateAttendanceInput (attendance.dto.ts): employeeId (ID, required), time (number, required), date (number, required), kind?, submitType?, note?, latitude?, longitude?, address?. (Service ignores latitude/longitude/address on clockIn; only time/date/kind/submitType/note are persisted there.) AdminClockInInput: employeeId + clockins: [{ date, clockInTime?, clockOutTime? }] (all unix-ms). AttendancePageInput extends BasePageInput { skip, take, keyword } + employeeId?, fromDate?, toDate?, kind?, status?. (status filter is honored by buildQuery but, since no row ever has a status, it matches nothing.)

AttendanceByDay (the rollup return shape) — date, employeeId, employeeName, employeeRef, data: [Attendance], firstIn, lastOut, totalHrs, late (always 0), earlyLeave (always 0).

Shift / Timetable / AttendanceGroup (CRUD, identical shape)

Operation Type Input Returns
createShift / updateShift / deleteShift Mutation CreateShiftInput { name, repeatMode, repeatCycle, days[] } / Update… / id Shift / Boolean
shiftPage / shifts Query ShiftPageInput / keyword? ShiftPageResult / [Shift]
createTimetable / updateTimetable / deleteTimetable Mutation CreateTimetableInput / Update… / id Timetable / Boolean
timetablePage / timetables Query TimetablePageInput / keyword? TimetablePageResult / [Timetable]
createAttendanceGroup / updateAttendanceGroup / deleteAttendanceGroup Mutation CreateAttendanceGroupInput / Update… / id AttendanceGroup / Boolean
attendanceGroupPage / attendanceGroups Query AttendanceGroupPageInput / keyword? AttendanceGroupPageResult / [AttendanceGroup]

All gated by HR_MODULE; all mutations audited (hr_shifts / hr_timetables / hr_attendance_groups).

Device sync (device-sync/device-sync.resolver.ts) — read-only

Operation Type Returns Notes
attendanceDevices Query [Device] All discovered devices (tenant-global).
attendanceSyncStatus Query [AttendanceSync] Per-device cursor state.

QR clock-in (lives in ess, shown here because it writes attendance)

Operation Type Returns Notes
attendanceQrToken Query AttendanceQrResult { token, expiresAt } A 25-second JWT (type: 'attendance_qr') for the on-screen QR.
qrClockIn Mutation QrClockInResult { kind, time } Verifies the token, resolves the ESS employee from context, calls attendanceSvc.clockIn({ submitType: QR }).

4. Business rules & calculations

4.1 Auto-alternating kind (the core capture rule)

clockIn() never trusts the caller to say in-vs-out; it derives it:

// attendance.service.ts → clockIn()
const date = DateUtils.startOfDay(input.date);
const time = input.time ?? DateUtils.now();
const last = await this.attendanceRepo.findLastByEmployee(employeeId, date); // latest by time, same day
const kind = input.kind ?? (last?.kind === CLOCK_IN ? CLOCK_OUT : CLOCK_IN);
// persists { employeeId, time, date, kind, submitType ?? MANUAL, note, companyId }
  • First punch of the day → CLOCK_IN; next → CLOCK_OUT; next → CLOCK_IN, and so on (any even number of punches = balanced).
  • input.kind may override the auto-detection.
  • employeeId falls back to contextSvc.employeeId (ESS self-service). Missing employee → BadRequestException("Employee not found").

The device path (createFromDevice) uses the same alternation but keyed on employeeNoString for that day (findLastDeviceByNoString).

4.2 Device pre-linking (log before the employee exists)

createFromDevice({ employeeNoString, time, deviceIndex }):

  1. Dedup on (employeeNoString, time, deviceIndex) → if it exists, return null.
  2. Compute date = startOfDay(time); alternate kind from the last device record that day.
  3. Best-effort lookup of an existing Employee by ref == employeeNoString. If found, set employeeId + companyId. It never creates an employee.
  4. Insert with submitType: DEVICE. On a duplicate-key race (err.code === 11000) it swallows and returns null (idempotent).

When an employee is later created, the employee module emits employee.created; attendance listens:

@OnEvent("employee.created")
async linkDeviceRecords({ ref, employeeId, companyId }) {
  // updateMany({ employeeNoString: ref, employeeId: null/absent }, { $set: { employeeId, companyId } })
}

So historical swipes logged under a staff ref get back-linked to the employee row the moment it exists. (This is the consumer side of the employee employee.created event; a one-off migration 2026-06-07-link-device-attendance.ts does the same backfill for pre-existing data.)

4.3 Worked-hours, late, overtime, early-leave — what is and isn't computed

This is the section a rebuilder most needs to get right, because the field surface promises more than the code delivers.

Worked hours (totalHrs) — the ONE thing that is computed. In the per-day rollup (pageByDay) and the standalone helper:

// repository pageByDay: $group → firstIn=$min(time), lastOut=$max(time)
totalHrs = firstIn && lastOut ? (lastOut - firstIn) / (1000 * 60 * 60) : 0;

// service.calculateWorkHours(records): same idea
if (records.length < 2) return 0;
sorted = records.sort by time;
return (last.time - first.time) / 3_600_000;   // hours between first and last punch

Worked hours = (last punch − first punch) of the day, in hours. It is the span, not the sum of in/out pairs: a person who clocks in, out for lunch, in, out is credited the full first-in→last-out span. Break time is not subtracted (the timetable's breakTimeDuration / calcBreakTimeIntoAttendance are ignored). minWorkHours, startWorkTime, endWorkTime do not clamp it.

Late / early-leave / overtime status — NOT computed. Verified across the whole BE: no code assigns AttendanceStatus.LATE / EARLY_LEAVE / OVERTIME / REGULAR, and no code reads lateAllowance / earlyLeaveAllowance / overtimeStartTime to derive them. The AttendanceByDay.late and AttendanceByDay.earlyLeave GraphQL fields default to 0 and are never overwritten (the repository builds AttendanceByDay objects without them). The admin daily table and calendar render late/earlyLeave columns, but they are always 0 / . A rebuild that needs real lateness must implement it — the intended formula is implied by the timetable config: late = max(0, firstIn − (startWorkTime + lateAllowance)), earlyLeave = max(0, (endWorkTime − earlyLeaveAllowance) − lastOut), overtime = max(0, lastOut − overtimeStartTime) capped at maxOvertimeDuration — but none of this exists in code today. (See §9.)

Overtime pay hours come from a different source entirely: approved timesheet rows (Timesheet.overtimeHours), summed in payroll (§4.4) — not from attendance punches.

4.4 The real worked-day / absence / overtime computation (downstream, in payroll)

The substantive computation that does consume the shift + calendar lives in hr/payroll/employee/employee.service.ts. Attendance, attendance-group, shift and calendar all feed it. Per employee, per pay period [fromDate, toDate]:

Step 1 — working-day set (getWorkingContext): intersect the shift schedule with the calendar.

workingDaysOfWeek = AttendanceGroup(employee.attendanceGroupId).shiftId
                      → Shift.days[].day   (the weekdays the shift covers)
                      → fallback [1,2,3,4,5] (Mon–Fri) when no shift assigned
holidays           = CalendarService.findHolidays(companyId, from, to)   // [calendar]
workingDaySet      = CalendarService.workingDayStrings(from, to, holidays, workingDaysOfWeek)
                      // every date in range whose weekday ∈ workingDaysOfWeek AND not a holiday
totalWorkingDays   = workingDaySet.size

Step 2 — present days (addAbsenceDeductions): a day counts as present if there is any attendance punch that day OR it is covered by an approved leave.

attendedDates = attendanceSvc.findAttendedDates(employeeId, from, to);   // distinct Attendance.date
presentSet    = new Set(attendedDates.map(toDateString));
// approved leaves (paid OR unpaid) also mark their days present (so they aren't double-deducted)
for (leave of approvedLeavesOverlapping) markDays(leave.fromDate..leave.toDate);

presentDays = count(workingDaySet ∩ presentSet);
absentDays  = max(0, totalWorkingDays − presentDays);
dailyRate   = salary / totalWorkingDays;
totalAbsenceDeductionAmount = round(absentDays * dailyRate, 2);

Absence = a scheduled working day with no attendance punch and no approved leave. Attendance here is binary (present iff ≥1 punch on that date) — totalHrs is not used for absence; a one-minute punch counts as a full present day.

Step 3 — overtime amount (addOvertime): from approved timesheets, not attendance.

hourlyRate = salary / workingDaysPerMonth / workingHoursPerDay;   // both from AttendanceGroup (default 30 / 8)
totalOvertimeHours = Σ Timesheet.overtimeHours where status=APPROVED in [from,to];
totalOvertimeAmount = round(totalOvertimeHours * hourlyRate, 2);

Step 4 — unpaid-leave amount (addUnpaidLeaves): approved unpaid leaves, clamped to the period, half-days = 0.5, holidays/non-working days skipped via the same workingDaySet; dailyRate = salary / totalWorkingDays.

So the link to payroll is: AttendanceGroup → (workingHoursPerDay, workingDaysPerMonth) set the rates; AttendanceGroup → Shift → ShiftDay.day + calendar holidays set the working-day set; Attendance.findAttendedDates supplies presence; timesheet supplies OT hours; leave supplies paid/unpaid days. See payroll §5.2.

4.5 Shift create/update/delete (child-row management)

ShiftService manages ShiftDay children non-transactionally:

  • create: super.create(shiftData)saveShiftDays(shiftId, days) (one create per day) → return findWithDays.
  • update: super.update(shiftData) → if days provided, shiftDayRepo.deleteByShiftId(id) then recreate all → return findWithDays. (Full replace, not diff.)
  • delete: deleteByShiftId then super.delete. No guard against an AttendanceGroup still referencing the shift — deleting a shift can orphan a group's shiftId. Flag (§9).

4.6 Device sync loop (HikVision addon)

DeviceSyncService (device-sync.service.ts) is an OnModuleInit poller, off by default — it stays inert unless process.env.hikv_api_url is set (DeviceSyncConfig.enabled). When enabled it registers a setInterval (default 300 000 ms / 5 min, unref'd so it never holds the process open) and runs tick():

tick():  (re-entrancy guarded by isRunning)
  discoverDevices()                       // HikvService.fetchDevices() → upsert each by devIndex
  targets = devices where devStatus != "offline"
  Promise.allSettled(targets.map(syncDevice))   // each device in isolation; failures logged, not fatal

syncDevice(device):
  cursor = ensureCursor(devIndex)         // AttendanceSync, starts at startOfMonth on first run
  loop ≤ MAX_DAYS_PER_TICK (60) days, never past startOfToday:
     drainDayEvents(devIndex, cursor)     // page the day-window following the "MORE" signal
        fetchEvents(searchResultPosition, maxResults=100, startTime..endTime)
        processEvents → for each event with employeeNoString+time → createFromDevice(...)
        repeat while AcsEvent.responseStatusStrg === "MORE" (device caps page size) up to 100 000/day
     advance cursor to next day (startTime/endTime), stamp lastSyncedTime/lastSyncedId
     when cursor.startTime >= today → reset offset, keep on today, break

Idempotency is owned by createFromDevice's dedup + the unique partial index, so re-walking a window is safe. The HikVision client (hikv/hikv.service.ts) is Surface A only (device discovery + AcsEvent fetch over digest auth) — no person/fingerprint enrolment. Date format sent to the device is YYYY-MM-DDTHH:mm:ssZ (AP_DEVICE_DATE_FORMAT).

4.7 XLSX import (importAttendance)

Columns (tolerant aliases): ref | date | clockIn | clockOut. Algorithm:

  1. Parse rows; collect unique refs; look each up via employeeSvc.findOne({ ref }).
  2. Abort the whole import (BadRequestException) if any ref is unmatched — this is all-or-nothing on ref validity (unlike employee import which skips bad rows). Flag.
  3. Per row: parse date (Excel serial or several string formats) and times (Excel fractional-day or HH:mm[:ss] / h:mm A); write a CLOCK_IN row for clockIn and a CLOCK_OUT row for clockOut, submitType: MANUAL.
  4. Returns { imported, skipped, rows[] } with per-row results.

4.8 Transactionality

There is none in the attendance subsystem. clockIn, adminClockIn, importAttendance, and ShiftService child writes all run as independent inserts/updates (no withRetryTransaction). adminClockIn writing both an in and out row is two separate inserts; a partial failure leaves an unbalanced day. Flag (§9).


5. Permissions

  • Feature gate: every attendance/shift/timetable/group/device resolver = @RequireFeature("HR_MODULE") + GqlFeatureGuard (company subscription must include HR). See permissions-access and subscription-config.
  • Auth: @ApGqlAuthorize() (JWT) on all. ESS QR clock-in runs under the ESS employee context (its own JWT, role EMPLOYEE).
  • Admin RBAC modules (zerp-admin/src/constants/UserAccess.ts): ATTENDANCES (attendances), HR_TIMETABLES (hr-timetables), HR_SHIFTS (hr-shifts), HR_ATTENDANCE_GROUPS (hr-attendance-groups) — each with view/create/update/delete. Admin buttons gate on …ACTIONS.CREATE. The devices page guards via the route's haveModuleAccess('/hr/attendance-devices').
  • Row scoping for employees: the repository injects employeeId = contextSvc.employeeId into every attendance query when an employee context is present, so an ESS user reads only their own attendance regardless of the requested filter (§2.1).
  • Policy flags not enforced server-side: AttendanceGroup.qrClockinEnabled / locationClockinEnabled are not checked by clockIn / qrClockIn; any authenticated employee with a valid QR token can clock in. Flag (§9).

6. Flows

6.1 ESS QR clock-in (happy path)

1. Wall display (/attendance-qr or /hr/attendance/qr-display) polls attendanceQrToken every ~14–20s
     → EssService.generateQrToken() → 25s JWT { type:'attendance_qr' }  → renders <QRCode value={token}>
2. Staff opens ESS app (/ess/attendance-scan) → scans QR → qrClockIn(token)
     → EssService.qrClockIn: jwt.verify(token) (expired/invalid → BadRequestException)
     → employeeId = contextSvc.employeeId (the logged-in ESS staff)
     → attendanceSvc.clockIn({ employeeId, time:now, date:now, submitType: QR })
          → findLastByEmployee(today) → alternate kind → insert Attendance
     ← { kind, time }  → app shows "Clocked IN/OUT at HH:mm"

The QR encodes only a short-lived attendance-session token; identity comes from the scanner's own ESS session, not the QR. The token's 25s TTL + display refresh prevents screenshot replay.

6.2 Biometric device swipe (background)

Device records a swipe → DeviceSyncService.tick() (≤ every 5 min, if hikv_api_url set)
  → fetchEvents → createFromDevice({ employeeNoString, time, deviceIndex })
       dedup → alternate kind → best-effort link to Employee by ref → insert Attendance(submitType=DEVICE)
  (employee not yet created → employeeId stays null)
Later: createEmployee(ref=…) → emit employee.created
  → AttendanceService.linkDeviceRecords → updateMany backfills employeeId/companyId on those swipes

6.3 Admin manual clock-in / bulk

Admin /hr/attendance → "Admin Clock-in" modal → pick employee + date + in/out times
  → adminClockIn({ employeeId, clockins:[{date, clockInTime, clockOutTime}] })
       per entry: insert CLOCK_IN row (if clockInTime) + CLOCK_OUT row (if clockOutTime), submitType=MANUAL
"Bulk Clock-In" modal → all employees in a table, set in/out per row, Save All
  → loops silentAdminClockIn per employee; pre-checks duplicates for the date (warning icon only)

Admin UI disallows a past date and clock-out ≤ clock-in (Yup); the backend does not re-validate time ordering — it inserts whatever it's given. Flag.

6.4 Configure schedule (the wiring an admin must do)

1. /hr/timetable → create Timetable(s): start/end (mins-from-midnight), allowances, OT, break
2. /hr/shift → create Shift: name + per-weekday timetable (Mon..Sun → timetableId)  → ShiftDay rows
3. /hr/attendance-group → create AttendanceGroup: pick Shift, set workingDaysPerMonth (+ hidden hrs/day),
     toggle QR / Location / check-in-not-required policies
4. /hr/employees → assign employee.attendanceGroupId = that group   [employee]
   → now payroll can derive the employee's working-day set & rates (§4.4)

6.5 Unhappy paths

  • clockIn with unknown employee → BadRequestException("Employee not found").
  • qrClockIn with expired/invalid token → BadRequestException("QR code has expired …").
  • importAttendance with any unmatched ref → entire import aborts with the list of missing refs.
  • Device duplicate swipe (same employeeNoString+time+deviceIndex) → silently ignored (returns null / 11000 swallowed).
  • Delete attendance → soft-delete; the row drops out of all aggregations (deleted: { $ne: true }), so totalHrs/presence self-correct.

7. Admin UI

Area Route Module
Attendance (logs / daily / calendar / devices tabs) /hr/attendance src/modules/hr/attendance
Attendance devices (devices + sync status) /hr/attendance-devices src/modules/hr/attendance-devices
Attendance groups /hr/attendance-group src/modules/hr/attendance-group
Shifts /hr/shift src/modules/hr/shift
Timetables /hr/timetable src/modules/hr/timetable
Wall-mounted QR display (standalone, Apollo-wrapped) /attendance-qr and /hr/attendance/qr-display src/pages/attendance-qr.tsx, …/qr-display.tsx
ESS staff: attendance summary & scan /ess/attendance, /ess/attendance-scan reuse hr/attendance/context

Attendance page (hr/attendance/page.tsx)

A single page with a view switcher: Daily Summary (default) · Raw Logs · Calendar · Devices.

  • DailyattendanceByDayPage; table columns Date / Employee / First In / Last Out / Total Hrs / Late (min) / Early Leave (min) (the last two always render because the values are always 0); expandable row lists each punch.
  • LogsattendancePage; Date / Time / Employee / Kind (color tag) / Status / Submit Type / delete.
  • CalendarattendanceByDayPage per month; events titled "{totalHrs}h", colored late ? orange : blue (i.e. always blue, since late is always falsy).
  • Devices → embeds DevicesPanel (its own context provider) showing the same device/sync tables as the standalone devices page.
  • Header actions: Export (AttendanceExport — client-side XLSX of the current view; daily export emits importable Ref/DD/MM/YYYY columns so it round-trips through import), Import (AttendanceImport), Bulk Clock-In (BulkClockIn), Admin Clock-in (single-employee modal).

Context (attendance/context.tsx) methods: fetchAttendancePage, fetchAttendanceByDay, fetchAllAttendanceLogs, fetchAllAttendanceByDay (take 100 000 for export), clockIn, adminClockIn, silentAdminClockIn, fetchAttendanceForDate, deleteAttendance, importAttendance, fetchQrToken, qrClockIn. GraphQL ops in gql/query.ts.

Timetable form (hr/timetable/page.tsx)

Sectioned modal: basics (name, type, calcBy) · Work Hours (start / end via ApTimePicker timeMode="mins", min-work-hours via timeMode="hours") · Allowances (late / early-leave, minutes) · Overtime (OT-start time, max OT) · Break (duration + "count break in attendance" switch). Times persist as minutes-from-midnight. Required (Yup): name, type, calcBy.

Shift form (hr/shift/page.tsx)

Name / description / repeat-mode / repeat-cycle, then a per-day timetable matrix (Sun..Sat each → a timetable select, plus a "Default (all days)" select that fills every row). On submit it builds days = [{day, timeTableId}] for the non-empty rows. Loads timetable options from useTimetableState().fetchAll.

Attendance-group form (hr/attendance-group/page.tsx)

Name / description / Shift select (useShiftState().fetchAll) / Working Days-per-Month (the Hours-per-Day input is commented out in the form, so it keeps the schema default 8) / four policy switches: check-in-not-required, check-out-not-required, location clock-in, QR clock-in.

Devices page / panel (attendance-devices/page.tsx, attendance/components/DevicesPanel.tsx)

Two tabs: Devices (name / status badge / type / protocol / EHome ID / active / devIndex) and Sync Status (device / synced records / window start-end / last synced / last ref). Read-only + Refresh. Both render the same columns; DevicesPanel wraps its own AttendanceDeviceContextProvider.

QR display (pages/attendance-qr.tsx)

Full-screen kiosk: polls attendanceQrToken every ~14s, renders a react-qr-code of the token, shows a countdown ring, retries on error. Branded "Mabiz ESS / Attendance QR". qr-display.tsx is a near-identical variant (20s refresh, animated SVG ring).


8. Dependencies & integrations

Attendance depends on / calls:

  • employee (EmployeeService) — resolve employee by id / by ref (device + import linking). Hard dependency (forwardRef).
  • AttendanceGroupService injected into AttendanceService (constructor) though not heavily used by it directly; the group is mostly read by payroll.
  • AuthModule, SubscriptionModule (feature gate).
  • Device-sync: HikvService (HikVision ISAPI over UrlLibApiService digest auth), SchedulerRegistry, DeviceSyncConfig (env).

Consumed by:

  • payrollhr/payroll/employee/employee.service.ts injects AttendanceService (findAttendedDates), AttendanceGroupService (workingHoursPerDay, workingDaysPerMonth, shiftId), ShiftService (findWithDays → working weekdays). This is the real worked-day/absence/overtime engine (§4.4). Also pulls timesheet overtimeHours and leave days, and calendar holidays.
  • essEssService.qrClockInattendanceSvc.clockIn(submitType: QR); ESS pages reuse the attendance context.
  • dashboardHrDashboardService.getAttendanceMetrics() injects AttendanceRepository and aggregates Attendance.status into present/onLeave/absent/untracked. But it matches status against "PRESENT"/"ON_LEAVE"/"ABSENT", which are not the AttendanceStatus enum values and are never written — so those counts are always 0 and everyone is reported untracked. Flag (§9).
  • timesheet — sibling, not a hard caller of attendance; supplies OT hours to payroll.

Events:

  • Consumes employee.created (@OnEvent) → linkDeviceRecords (device back-link). Emits none.

Cron / external:

  • DeviceSyncService self-scheduled setInterval (not @Cron), gated on hikv_api_url. External service: HikVision ISAPI gateway (digest auth, env-configured).
  • The QR token & ESS auth use jwt_access_token_secret / jwt_refresh_token_secret.

9. Gotchas & project-specific rules

  1. AttendanceStatus (LATE/OVERTIME/EARLY_LEAVE/REGULAR) is a dead enum. No code assigns it; AttendanceByDay.late/earlyLeave are always 0; the timetable allowance/OT-start fields are never read. Late/early-leave/overtime status is not implemented — only totalHrs (first-to-last span) is computed. A rebuild must implement these against the timetable if needed (intended formulae sketched in §4.3).
  2. Worked hours = lastOut − firstIn span, not summed pairs, and break is not deducted. Multiple in/out punches still credit the whole span. breakTimeDuration / calcBreakTimeIntoAttendance are ignored.
  3. Dashboard attendance tiles are broken by an enum mismatch. getAttendanceMetrics queries status for PRESENT/ON_LEAVE/ABSENT — values that never exist on a row — so present/onLeave/absent are always 0 and untracked = total. The correct presence definition (any punch on a working day) only lives in payroll (§4.4).
  4. Absence is binary on punches. A scheduled working day with ≥1 punch (any time) OR an approved leave = present; otherwise absent and deducted at salary / totalWorkingDays. totalHrs is not used.
  5. Collection naming is inconsistent. Schemas declare unprefixed collections (attendances, shifts, shift_days, timetables, attendance_groups); audit-meta + one repository $lookup use hr_-prefixed names. The schema decorator is authoritative; the ShiftRepository.page lookup (from: 'hr_shift_days') may not match the real shift_days collection — verify before relying on the days join there (the service's findWithDays path is the safe one).
  6. AttendanceGroup policy flags are not enforced. qrClockinEnabled / locationClockinEnabled / checkInNotRequired / checkOutNotRequired are stored & shown in the admin form but no server guard reads them; QR clock-in works for any authenticated employee regardless.
  7. workingHoursPerDay is not editable in the admin form (the input is commented out), so it stays at the schema default 8; only workingDaysPerMonth is editable. Both feed payroll's hourly rate.
  8. No transactions anywhere in attendance. adminClockIn (two inserts), importAttendance (many inserts), and shift child-row replace are all non-atomic.
  9. Import is all-or-nothing on ref validity (aborts if any ref is unknown) — unlike employee import which skips bad rows.
  10. Backend does not re-validate clock-time ordering — the Yup "clock-out after clock-in" / "no past date" checks are admin-UI only; the service inserts whatever timestamps it receives.
  11. Device records are tenant-global until linked. Devices are upserted with no companyId; a swipe's companyId is set only when an employee with the matching ref exists (now or via employee.created backfill). Cross-tenant ref collisions would mis-link. The unique partial index {employeeNoString, time, deviceIndex} is the only dedup.
  12. Device sync is inert by default. Nothing runs unless hikv_api_url is set; the addon is HikVision-specific, Surface-A only (no enrolment), and self-throttles (5-min interval, ≤60 days/tick, ≤100k records/day).
  13. All times/dates are unix-ms numbers; date is always start-of-day; timetable startWorkTime/endWorkTime/overtimeStartTime are minutes-from-midnight, while minWorkHours/maxOvertimeDuration/breakTimeDuration are plain minute durations — don't conflate the two.
  14. ESS callers see only their own attendance — the repository forces employeeId = contextSvc.employeeId into every query when an employee context exists; admin (no employee context) sees all.