diff --git a/app/Http/Controllers/BiometricAttendanceController.php b/app/Http/Controllers/BiometricAttendanceController.php index 3ffe1213b..c671b9099 100644 --- a/app/Http/Controllers/BiometricAttendanceController.php +++ b/app/Http/Controllers/BiometricAttendanceController.php @@ -165,6 +165,8 @@ class BiometricAttendanceController extends Controller return redirect()->back()->with('error', __('Permission Denied.')); } + $overwrite = $request->boolean('overwrite', false); + // Fetch all pending records $pendingRecords = \App\Models\BiometricAttendance::where('sync_status', 'pending')->get(); @@ -173,6 +175,8 @@ class BiometricAttendanceController extends Controller }); $syncedCount = 0; + $updatedCount = 0; + foreach ($groupedAttendances as $key => $dayEntries) { $sorted = $dayEntries->sortBy('punch_time'); $firstEntry = $sorted->first(); @@ -185,8 +189,36 @@ class BiometricAttendanceController extends Controller $clockInTime = $firstEntry->punch_time->format('H:i:s'); $clockOutTime = $lastEntry->punch_time->format('H:i:s'); - $exists = AttendanceRecord::where('employee_id', $employee->user_id)->where('date', $attedanceDate)->whereIn('created_by', getCompanyAndUsersId())->exists(); - if (!$exists) { + $attendance = AttendanceRecord::where('employee_id', $employee->user_id) + ->where('date', $attedanceDate) + ->whereIn('created_by', getCompanyAndUsersId()) + ->first(); + + if ($attendance) { + if ($overwrite) { + // Overwrite existing record + $shift = Shift::where('id', $employee->shift_id)->where('status', 'active')->first() ?? Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); + $policy = AttendancePolicy::where('id', $employee->attendance_policy_id)->where('status', 'active')->first() ?? AttendancePolicy::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); + + $attendance->biometric_id = $firstEntry->id; + $attendance->branch_id = $firstEntry->branch_id; + $attendance->shift_id = $shift?->id; + $attendance->attendance_policy_id = $policy?->id; + $attendance->clock_in = $clockInTime; + $attendance->clock_out = $clockOutTime; + $attendance->save(); + + $attendance->fresh(); + $attendance->processAttendance(); + + $updatedCount++; + } + + // Mark as synced so they don't pile up in pending + foreach ($dayEntries as $entry) { + $entry->update(['sync_status' => 'synced']); + } + } else { $shift = Shift::where('id', $employee->shift_id)->where('status', 'active')->first() ?? Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); $policy = AttendancePolicy::where('id', $employee->attendance_policy_id)->where('status', 'active')->first() ?? AttendancePolicy::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); @@ -211,15 +243,16 @@ class BiometricAttendanceController extends Controller } $syncedCount++; - } else { - // Already exists, just mark as synced so they don't pile up in pending - foreach ($dayEntries as $entry) { - $entry->update(['sync_status' => 'synced']); - } } } } - return redirect()->back()->with('success', __("Bulk sync completed. Synced {$syncedCount} new records.")); + + $message = __("Bulk sync completed. Synced :synced new records.", ['synced' => $syncedCount]); + if ($updatedCount > 0) { + $message .= " " . __("Overwrote :updated existing records.", ['updated' => $updatedCount]); + } + + return redirect()->back()->with('success', $message); } public function sync(Request $request, $id) diff --git a/branch-agent/sync-agent-1.0.zip b/branch-agent/sync-agent-1.0.zip index c98743d9c..91e8f5ce6 100644 Binary files a/branch-agent/sync-agent-1.0.zip and b/branch-agent/sync-agent-1.0.zip differ diff --git a/branch-agent/sync-agent.exe b/branch-agent/sync-agent.exe index c7b2f2542..be151e947 100644 Binary files a/branch-agent/sync-agent.exe and b/branch-agent/sync-agent.exe differ diff --git a/branch-agent/sync.js b/branch-agent/sync.js index cb947a6d3..e7569c4d5 100644 --- a/branch-agent/sync.js +++ b/branch-agent/sync.js @@ -1,4 +1,5 @@ const path = require('path'); +const fs = require('fs'); // For pkg-bundled .exe: read .env from same folder as the executable const envPath = path.join(path.dirname(process.execPath), '.env'); require('dotenv').config({ path: envPath }); @@ -12,22 +13,44 @@ const API_KEY = process.env.API_KEY || 'default_secure_sync_key_123'; const BRANCH_ID = process.env.BRANCH_ID || 1; const SYNC_DAYS = parseInt(process.env.SYNC_DAYS || 7, 10); +const logFilePath = path.join(path.dirname(process.execPath), 'sync.log'); + +// Enhanced Logger Helper +function log(message, type = 'INFO') { + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); + const formattedMessage = `[${timestamp}] [${type}] ${message}\n`; + + // Output to console + if (type === 'ERROR') { + console.error(formattedMessage.trim()); + } else { + console.log(formattedMessage.trim()); + } + + // Append to file + try { + fs.appendFileSync(logFilePath, formattedMessage, 'utf8'); + } catch (err) { + console.error(`[${timestamp}] [ERROR] Failed to write to local sync.log: ${err.message}`); + } +} + async function syncAttendance() { - console.log(`[${new Date().toLocaleString()}] Starting ZKTeco Sync...`); - console.log(`Target Device: ${ZKTECO_IP}:${ZKTECO_PORT}`); - console.log(`Sync Window: Last ${SYNC_DAYS} days`); + log('Starting ZKTeco Sync...'); + log(`Target Device: ${ZKTECO_IP}:${ZKTECO_PORT}`); + log(`Sync Window: Last ${SYNC_DAYS} days`); const zkInstance = new ZKLib(ZKTECO_IP, ZKTECO_PORT, 10000, 4000); try { await zkInstance.createSocket(); - console.log('✅ Connected to ZKTeco device.'); + log('Connected to ZKTeco device.'); - console.log('Fetching attendance records...'); + log('Fetching attendance records...'); const attendances = await zkInstance.getAttendances(); if (attendances && attendances.data && attendances.data.length > 0) { - console.log(`✅ Found total ${attendances.data.length} records on device.`); + log(`Found total ${attendances.data.length} records on device.`); // Filter to last X days const targetDate = new Date(); @@ -38,31 +61,33 @@ async function syncAttendance() { return logDate >= targetDate; }); - console.log(`✅ Filtered to ${recentLogs.length} records from the last ${SYNC_DAYS} days.`); + log(`Filtered to ${recentLogs.length} records from the last ${SYNC_DAYS} days.`); if (recentLogs.length > 0) { - console.log(`Pushing to Cloud API: ${SERVER_API_URL}`); + log(`Pushing to Cloud API: ${SERVER_API_URL}`); const response = await axios.post(SERVER_API_URL, { api_key: API_KEY, branch_id: BRANCH_ID, zkteco_ip: ZKTECO_IP, attendances: recentLogs }); - console.log('✅ Sync Successful:', response.data); + log(`Sync Successful: ${JSON.stringify(response.data)}`); + } else { + log('No records found within the sync window.'); } } else { - console.log('ℹ️ No attendance records found on device.'); + log('No attendance records found on device.'); } } catch (e) { - console.error('❌ Error during sync:', e.message); + log(`Error during sync: ${e.message}`, 'ERROR'); if (e.response && e.response.data) { - console.error('API Response:', e.response.data); + log(`API Response Error: ${JSON.stringify(e.response.data)}`, 'ERROR'); } } finally { try { await zkInstance.disconnect(); - console.log('Disconnected from ZKTeco device.'); + log('Disconnected from ZKTeco device.'); } catch (err) {} // Keep window open for user to read logs diff --git a/docs/PLAN-sync-logs-overwrite.md b/docs/PLAN-sync-logs-overwrite.md new file mode 100644 index 000000000..691f203d8 --- /dev/null +++ b/docs/PLAN-sync-logs-overwrite.md @@ -0,0 +1,51 @@ +# PLAN: Sync Logs & Overwrite Option + +**Target Files:** +- `branch-agent/sync.js` (Windows agent logging) +- `app/Http/Controllers/BiometricAttendanceController.php` (bulk sync overwrite backend) +- `resources/js/pages/hr/biometric-attendance/index.tsx` (overwrite option frontend) + +--- + +## Goal +Implement local logging for the Windows sync agent (saving logs in the executable's directory) and add an optional "Overwrite" checkbox to the bulk sync process to allow updating existing records instead of skipping them. + +--- + +## Tasks + +### Phase 1: Windows Sync Agent Logging +- [ ] **Implement `writeLog` helper in `branch-agent/sync.js`**: + * Write a function to append messages to `sync.log` in the executable's directory (`path.dirname(process.execPath)`). + * Format: `[YYYY-MM-DD HH:ii:ss] MESSAGE`. +- [ ] **Add log statements for key events**: + * Log start of sync, target device, connection success/failure, number of records found, and API response success/failure. + * Verify: Run the sync script and check if `sync.log` is created with clear information. + +### Phase 2: Backend Overwrite Support +- [ ] **Modify `syncAll` in `BiometricAttendanceController.php`**: + * Extract `$overwrite = $request->boolean('overwrite', false);`. + * If `$exists` is true and `$overwrite` is true: + * Fetch the existing `AttendanceRecord`. + * Update its `clock_in`, `clock_out`, `biometric_id`, and save it. + * Call `processAttendance()` on it. + * Update return flash message to show how many records were created vs updated. + +### Phase 3: Frontend Overwrite UI Option +- [ ] **Add Overwrite Checkbox in `resources/js/pages/hr/biometric-attendance/index.tsx`**: + * Add a state variable `overwrite` (boolean, defaults to `false` or `true` based on preference). + * Place a styled checkbox component next to the "Pull Data & Sync" button or wrap it inside a confirm dialog before running bulk sync. + * Pass `{ overwrite }` payload inside the Inertia post request to `route('hr.biometric-attendance.sync-all')`. + +### Phase 4: Build and Release Packaging +- [ ] **Rebuild the agent executable**: + * Run `npx pkg -t node18-win-x64 --output sync-agent.exe sync.js`. +- [ ] **Update the ZIP distribution archive**: + * Package `sync-agent.exe` and `.env.example` into `sync-agent-1.0.zip`. + +--- + +## Verification Checklist +- [ ] **Local Logging Verification**: Run the Node agent, confirm `sync.log` is created in the same folder, and verify it contains timestamps and sync statuses. +- [ ] **Bulk Sync Skips by Default**: Trigger bulk sync with `overwrite = false` and ensure existing records are not changed. +- [ ] **Bulk Sync Overwrites when Checked**: Trigger bulk sync with `overwrite = true` and verify existing records update to reflect current biometric punches. diff --git a/resources/js/pages/hr/biometric-attendance/index.tsx b/resources/js/pages/hr/biometric-attendance/index.tsx index 2155dcb57..9f1cbe328 100755 --- a/resources/js/pages/hr/biometric-attendance/index.tsx +++ b/resources/js/pages/hr/biometric-attendance/index.tsx @@ -25,6 +25,7 @@ export default function BiometricAttendance() { const [showDetailsModal, setShowDetailsModal] = useState(false); const [detailEntries, setDetailEntries] = useState([]); const [selectedEmployee, setSelectedEmployee] = useState(null); + const [overwrite, setOverwrite] = useState(false); // Check if any filters are active const hasActiveFilters = () => { @@ -274,30 +275,41 @@ export default function BiometricAttendance() { {t('Biometric attendance data from device')} - + }); + }} + > + + {t('Pull Data & Sync')} + + {configurationMissing ? (