kawayanmedia / dtr-pdf
Framework-agnostic Daily Time Record (DTR) computation and PDF generator
Requires
- php: ^8.2
- kawayanmedia/persistence: ^0.1
- setasign/fpdf: ^1.9
- setasign/fpdi: ^2.6
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Framework-agnostic Daily Time Record (DTR) engine and PDF generator for Philippine government attendance forms.
It is a faithful port of the legacy iHRMIS DTR screen: the same tardiness computation, week buckets, 40-hour rule, OB/TO/leave/holiday labels and the same FPDI drawing coordinates. Database access, HTTP handling and side-effect writes are injected through small interfaces, so the package works in CodeIgniter, Laravel, Symfony or plain PHP.
Requirements
- PHP 8.2+
setasign/fpdi^2.6 andsetasign/fpdf^1.9 (installed automatically)- A DTR template PDF — one is bundled at
resources/templates/dtr.pdf, andDtrLayout::legacyDtr()matches it exactly
Installation
composer require kawayanmedia/dtr-pdf
While the package lives inside the iHRMIS repository, install it through a path repository:
{
"repositories": [
{ "type": "path", "url": "packages/dtr-pdf", "options": { "symlink": true } }
],
"require": {
"kawayanmedia/dtr-pdf": "@dev"
}
}
kawayanmedia/persistence comes with it as a dependency; it is only used by the
write side, so printing needs no database at all.
See it running
The package ships with a database-free example that renders two employees (regular and overnight shifts) onto the iHRMIS template:
php packages/dtr-pdf/examples/generate-sample.php open packages/dtr-pdf/examples/output/dtr-sample.pdf
The script writes examples/output/dtr-sample.pdf and prints the computed
totals. examples/output/ is git-ignored, so generated files never get
committed. Use examples/generate-sample.php as a
starting point for your own integration.
The script uses the bundled template, so it runs from a plain checkout. When the
package is installed with Composer it lives at
vendor/kawayanmedia/dtr-pdf/examples/generate-sample.php; pass your own
template and output paths as arguments to override the defaults:
php vendor/kawayanmedia/dtr-pdf/examples/generate-sample.php \
/path/to/dtr.pdf /tmp/dtr-sample.pdf
The write side has its own runnable example, which creates a SQLite schema and records logs, tardiness and an undertime posting:
php packages/dtr-pdf/examples/write-sample.php
Quick start
use KawayanMedia\DtrPdf\Adapter\ArrayHolidays; use KawayanMedia\DtrPdf\Adapter\ArrayHistory; use KawayanMedia\DtrPdf\Adapter\ArrayLeaveTypes; use KawayanMedia\DtrPdf\Adapter\ArrayManualLogs; use KawayanMedia\DtrPdf\Adapter\ArraySchedules; use KawayanMedia\DtrPdf\Adapter\ArraySettings; use KawayanMedia\DtrPdf\Adapter\ArraySignatories; use KawayanMedia\DtrPdf\Adapter\NullTardinessStore; use KawayanMedia\DtrPdf\Config\DtrLayout; use KawayanMedia\DtrPdf\DtrGenerator; use KawayanMedia\DtrPdf\Engine\DtrEngine; use KawayanMedia\DtrPdf\Model\Employee; use KawayanMedia\DtrPdf\Model\Period; use KawayanMedia\DtrPdf\Model\RawDtrDay; use KawayanMedia\DtrPdf\Renderer\DtrPdfRenderer; $engine = new DtrEngine( schedules: new ArraySchedules([ 1 => ['time_a' => '08:00', 'time_b' => '12:00', 'time_c' => '13:00', 'time_d' => '17:00'], ]), holidays: new ArrayHolidays(), leaveTypes: new ArrayLeaveTypes([1 => ['name' => 'Vacation Leave', 'code' => 'VL']]), manualLogs: new ArrayManualLogs(), history: new ArrayHistory(), tardiness: new NullTardinessStore(), // or your repository settings: new ArraySettings(['allow_forty_hours' => '1']), signatories: new ArraySignatories(['013001' => ['name' => 'Maria Santos', 'position' => 'HR Officer']]), ); $generator = new DtrGenerator( $engine, new DtrPdfRenderer(DtrLayout::legacyDtr('/absolute/path/dtr/template/dtr.pdf')), ); $employee = new Employee( id: '013001', firstName: 'Juan', middleName: 'Santos', lastName: 'Dela Cruz', shiftType: 1, ); $report = $generator->report( $employee, Period::monthly(2026, 11, 1, 15), [ new RawDtrDay('2026-11-02', '08:00', '12:00', '13:00', '17:00'), new RawDtrDay('2026-11-03', '08:30', '12:00', '13:10', '17:00'), ], ); $bytes = $generator->renderer()->render($report); // PDF string $generator->renderer()->save($report, '/tmp/dtr.pdf'); $generator->renderer()->stream([$report], 'dtr.pdf'); // multi-report, one page each
Arrays shaped like the legacy employee_dtr() rows are accepted too:
new RawDtrDay(...); // preferred RawDtrDay::fromArray($legacyRow); // [log_date, am_login, am_logout, pm_login, …]
Architecture
RawDtrDay[] ──► DtrEngine ──► TardinessCalculator ──► TardinessState
│ │
│ ├── RegularShiftCalculator (shift 1 / 3)
│ └── ShiftingCalculator (shift 2 / 4)
│
├── DtrPresenter ──► PresentedDay[] (labels, colours, 12-hour text)
└── RunningTotals ──► DtrTotals (40-hour rule, weeks)
DtrReport ──► DtrPdfRenderer ──► FPDI + your template ──► %PDF
| Legacy | Package |
|---|---|
Attendance::dtr() drawing |
Renderer\DtrPdfRenderer |
Attendance::regular_hours() |
Engine\RegularShiftCalculator |
Attendance::shifting_same_day() |
Engine\ShiftingCalculator |
Helps::check_late*, check_undertime* |
Engine\TardinessCalculator |
Helps::compute_time |
Support\DurationFormatter |
| DTR label rules | Engine\DtrPresenter |
Dtr, Tardiness, Holiday, Leave_type, Manual_log, Shift, Schedule_employees, Setting, Office |
contracts in Contract/ |
Contracts to implement
| Interface | Legacy source |
|---|---|
ScheduleProviderInterface |
Shift::shift_times(), Schedule_employees::get_schedule() |
HolidayProviderInterface |
Holiday::is_holiday(), half_day, am_pm, holiday_name() |
LeaveTypeProviderInterface |
Leave_type::get_leave_name(), get_leave_code() |
ManualLogProviderInterface |
Manual_log::get_notes() |
DtrHistoryProviderInterface |
Dtr::date_tom() |
TardinessStoreInterface |
Tardiness::delete_tardiness(), check_tardiness() |
SettingsInterface |
Setting::getField() |
SignatoryProviderInterface |
Office / Office_m office-head lookup |
LeaveConversionProviderInterface |
Leave_conversion_table::compute_hour_minute() |
The package calls TardinessStoreInterface in the same order as the legacy
controller, so existing tardiness reports remain identical.
Configuration
Settings read through SettingsInterface
| Key | Meaning |
|---|---|
show_employee_number_dtr |
yes prints the employee id on both copies |
last_name_first_dtr |
yes prints LAST, FIRST MIDDLE |
print_overtime_in_dtr |
1 prints the overtime line |
print_office_head_in_dtr |
1 prints the signatory block |
allow_forty_hours |
1 enables the 40-hour weekly offset (shift_type 2/4) |
lgu_code |
marinduque_province selects the combined late/undertime total |
undertime_tardi |
1 colours undertime red and counts it |
minutes_tardy |
grace period in minutes (legacy default 1 = no grace) |
minutes_tardy_am_only |
1 applies the grace only in the morning |
dont_compute |
comma-separated dates excluded from tardiness |
tardy_autodeduct |
yes marks the computed leave-card undertime as active |
Profile
DtrProfile::legacyIhrmis() reproduces the controller's hard-coded exceptions
(employee 152 is treated as shift type 1). DtrProfile::default() has none.
DtrProfile::$preserveLastShiftWindow keeps the previous shift window when the
schedule repository has no row for a day, matching the legacy stateful
behaviour.
Layout
Every coordinate, font size and colour lives in DtrLayout. The
DtrLayout::legacyDtr() preset matches dtr/template/dtr.pdf, including both
copies per page. Override individual values (for example a Letter-sized
template or a different totals position):
$layout = new DtrLayout( templatePath: '/path/to/dtr.pdf', rowSpacing: 3.6, totalsAnchorY: 36, signatoryY: 219, );
compress: false disables PDF stream compression (useful for text assertions
in tests).
Leave-card undertime
The value computed by the legacy leave_card_undertime() is available on every
report, so it can be used later (leave-card import, payroll deduction, audit):
$report = $generator->report($employee, $period, $rows); // Or skip building the report in your code: // $undertime = $generator->leaveCardUndertime($employee, $period, $rows); /** @var \KawayanMedia\DtrPdf\Model\LeaveCardUndertime $undertime */ $undertime = $report->leaveCardUndertime; $undertime->action; // "Nov. 2026 Undertime" $undertime->particulars; // "UT-0-8-35" $undertime->days; // 0 $undertime->hours; // 8 $undertime->minutes; // 35 $undertime->value; // "1.000" (leave-credit equivalent, 3 decimals) $undertime->lastDay; // "2026-11-30" $undertime->active; // true when tardy_autodeduct = yes $undertime->isZero(); // true when particulars is "UT-0-0-0" $undertime->hasValue(); // !isZero()
The minutes lookup comes from LeaveConversionProviderInterface (the legacy
leave_conversion_table). It is optional and defaults to zero:
use KawayanMedia\DtrPdf\Adapter\ArrayLeaveConversions; $engine = new DtrEngine( // … leaveConversions: new ArrayLeaveConversions([ 0 => 0.0, 15 => 0.031, 30 => 0.063, ]), );
The package writes the leave card itself. Hand the computed value to
Service\DtrRecorder::writeLeaveCard(), which performs the legacy
is_entry_exists() / insert_entry() / update_entry() decision and removes
the month's entry when the undertime is zero (delete_undertime()):
use KawayanMedia\DtrPdf\Adapter\PdoDtrStore; use KawayanMedia\DtrPdf\Service\DtrRecorder; use KawayanMedia\Persistence\Adapter\PdoDatabase; $recorder = new DtrRecorder( new PdoDtrStore(PdoDatabase::fromDsn('mysql:host=localhost;dbname=ihrmis', $user, $pass)), ); $recorder->writeLeaveCard($undertime); // insert, update or delete
The plain PHP and Laravel guides show the provider and the recorder call.
Behaviour notes preserved from the legacy code
- The period always computes from day 1; days before the visible
fromday are blanked and excluded from tardiness. - Week buckets:
01–07,08–14,15–21,22–31. - The 40-hour rule compares the formatted week duration with
'5 days'exactly as the legacy code did, then subtracts that week's late/undertime counts. shift_type1 and 3 useRegularShiftCalculator; 2 and 4 useShiftingCalculator(day, overnight and 24-hour windows).- Overnight and 24-hour branches read the next day through
DtrHistoryProviderInterface. - The renderer returns bytes or writes files and never sends HTTP headers or
touches sessions. Attendance writes go through
Service\DtrRecorder, including the leave-card posting; archiving and concatenation stay with the host application.
Writing data
Service\DtrRecorder is the write side: blank-row creation and clean-up, manual
logs, cell edits, tardiness accrual and the monthly undertime posting.
use KawayanMedia\DtrPdf\Adapter\PdoDtrStore; use KawayanMedia\DtrPdf\Service\DtrRecorder; use KawayanMedia\Persistence\Adapter\PdoDatabase; $store = new PdoDtrStore(PdoDatabase::fromDsn('mysql:host=localhost;dbname=ihrmis', $user, $pass)); $recorder = new DtrRecorder($store); $created = $recorder->ensureDates($employeeId, '2026-01-01', '2026-01-31', $officeId); $recorder->removeDates($employeeId, $created); $recorder->log($employeeId, '2026-01-05', 1, '8:00', $officeId); $recorder->updateRow($rowId, ['pm_logout' => '5:45']); $recorder->recordTardiness($employeeId, '2026-01-05', 1, 300); $recorder->postUndertime($employeeId, 1, 2026, 3600); // … or write the value already computed by a report: $recorder->writeLeaveCard($report->leaveCardUndertime);
Log types 3–6 normalise afternoon times ("1:30" → "13:30") and accept the legacy
o:b marker. Tables, columns, the DtrStoreInterface seam and the behaviour
differences are documented in
docs/writing-data.md.
Using it in another project
| Stack | Guide |
|---|---|
| Plain PHP (PDO, no framework) | docs/plain-php.md |
| Laravel (service provider, query builder, controller, command) | docs/laravel.md |
| CodeIgniter | see below |
Both guides include complete provider implementations, saving/streaming the PDF and handling multiple employees.
CodeIgniter
The package has no framework dependency, so inside a controller:
$engine = new \KawayanMedia\DtrPdf\Engine\DtrEngine( schedules: new MyScheduleRepository(), // … ); $generator = new \KawayanMedia\DtrPdf\DtrGenerator( $engine, new \KawayanMedia\DtrPdf\Renderer\DtrPdfRenderer( \KawayanMedia\DtrPdf\Config\DtrLayout::legacyDtr(FCPATH . 'dtr/template/dtr.pdf'), ), ); return $this->response->download('dtr.pdf', $generator->render($employee, $period, $rows));
For CodeIgniter 2/3, enable $config['composer_autoload'] = TRUE; (or require
vendor/autoload.php) and use the same classes.
Testing
vendor/bin/phpunit -c packages/dtr-pdf/phpunit.xml.dist
The suite covers the formatter, calendar, tardiness rules, both shift
calculators, the presenter, totals, engine orchestration and real PDF output.
LegacyParityTest compares DurationFormatter with the legacy
Helps::compute_time() whenever the iHRMIS app/ tree is available.