# XENHR → Odoo HR Migration Plan

Last updated: 2026-07-09
Owner: susu (Nyein Su Su)
Source docs: `/project/odoohrhandoff.zip` (HANDOFF.md/README.md), GitLab
`erp_xenoptics/xenhr_backend` + `xenhr_frontend` (cloned/reviewed 2026-07-09)

## Goal

Retire XENHR (Laravel/React HR system, in dev, no production users) by
building its still-missing functionality into the live Odoo 19 HR platform
(`192.168.60.33`, DB `xenoptics`), then cut over. Not git-mergeable —
different stacks — this is a feature-parity migration.

## Status legend
`DONE` / `IN PROGRESS` / `NOT STARTED` / `BLOCKED`

## Phase 1 — Discovery

| # | Task | Status | Notes |
|---|---|---|---|
| 1 | Read XENHR's actual code for accurate gap list | **DONE** (2026-07-09) | Cloned & reviewed both repos; verified gap list below supersedes HANDOFF.md's commit-history guesses |
| 2 | Sync with stakeholders on ticket `ERPDEV-751` + agree freeze date for further XENHR dev | **DONE** | XENHR is already frozen for further development (confirmed by susu 2026-07-15) — no outstanding stakeholder sign-off needed |

## Phase 2 — Build gaps in Odoo

Rows are ordered in **recommended build sequence** (top-to-bottom). The `#`
column is a **stable task ID** referenced by trackers elsewhere, so it is
intentionally non-contiguous after resequencing — do not renumber. Read the
`Notes` column for cross-item dependencies the old flat list hid.

| # | Task | Status | Est. | Notes |
|---|---|---|---|---|
| 10 | Biometric scanner attendance import | **IN PROGRESS** (updated 2026-07-10) | ~0.5d left | **Spike complete**: confirmed HRSystem (`192.168.60.211`) + biometric machine (`192.168.67.51`, 109k+ `Transcantime` rows, live) both reachable; `sp_get_attendance_month_V3` output shape verified (78 employees, matches design). **Shipped**: read-only monthly report live at `/odoo/attendances/bymonth` (module `xeno_attendance`), menu under Attendances → Reporting **embedded in Odoo's own sidebar/navbar** (client action + iframe, not a bare new-tab page), pymssql baked into a custom Docker image (survives container recreate, verified), `sa` replaced with scoped read-only login `xeno_odoo_ro`, an **Excel export** (`/odoo/attendances/bymonth/export`, xlsxwriter, same colored formatting as the HTML grid), and an **employee-code remap table** (`xeno.attendance.code.map`, under Attendances → Configuration → Attendance Code Remaps, seeded with the 8 known remaps from the shared SP) plus a **live diagnostic**: the report now flags any employee_code seen in raw scans that doesn't resolve to a known employee or a configured remap, right on the page — catching a *new* unmapped code proactively instead of silently losing that person's attendance data. Verified: no false positive in the healthy baseline, correctly flags an injected fake code. **Genuinely remaining**: still sources employee/shift/leave/holiday directly from HRSystem's own tables, not Odoo's `hr.employee`/`resource.calendar`/`hr.leave` — blocked on the same identity-reconciliation problem as #18 (only 23% name-match), not worth doing until that's solved properly. |
| 3 | "My Team" attendance view (manager sees team attendance) | **DONE — 2026-07-10** | 2-3d | Confirmed real gap: XENHR's `MyTeamAttendancePage.tsx`. Shipped in `xeno_attendance`: `/odoo/attendances/myteam` (+ export), new model `xeno.attendance.viewer` (employee_code → manager, config under Attendances → Configuration → "My Team Attendance — Access"), menu under Attendances → Overview, visible to **any** internal user (not just HR/attendance officers) since the whole point is letting a regular manager use it — access to actual data is gated at runtime by whether they have any viewer rows, not by group membership. **Keyed on HRSystem's own employee_code, not Odoo's `hr.employee`** — same reasoning as #4/#11: Odoo's org hierarchy (`parent_id`) is still empty/unreliable (see #18), so this sidesteps that blocker exactly like the Leave approver-config did. Verified end-to-end over real HTTP with a non-HR throwaway manager: sees only their configured employee, export works, and the full company-wide report correctly returns 403 for them. Reused the same `render_html`/`get_month_report`/`export_xlsx` code path as the company report via a new `restrict_codes` parameter — no duplicated rendering logic. |
| 11 | Configurable multi-step / per-department / per-employee approval chains | **DONE (v2) — 2026-07-15** | 2-3d (was 1-2d) | **v1 (2026-07-09)** shipped a single-current-approver-per-rule design (see below), explicitly scope-cut from a full N-step chain. **v2 (2026-07-15) replaces it**: ported XENHR's real `ApproverConfigController::buildChain()` — `xeno.leave.approver` rows now carry a real `step_order`, and submitting a leave snapshots the resolved chain onto a new `xeno.leave.approval.step` model (one row per step, `pending`/`approved`/`rejected`); each step is approved individually and previous steps must already be approved, the leave itself only transitions (via Odoo core's real `action_approve`) when the last step clears; rejecting any step rejects the whole request. Employee-level override steps fully replace the department chain when configured (never merged), and every chain always ends in a synthetic "any HR/Admin" final step — matches XENHR exactly, including the dead "personal supervisor" auto-step being deliberately **not** ported. Added a read-only chain-progress list on the leave form, a "Preview chain" wizard on the employee form, and per-step approval emails (see #39). Deployed to `192.168.60.33` (module v19.0.2.2.0, verified via `mail.notification` inspection that exactly one email reaches the requestor per decision). **Data migrated 2026-07-15 (susu sign-off given)**: pulled `department_approvers`/`employee_approvers` from HRSystem. Real blocker hit and resolved: almost none of the named approvers (Thin Zar Myo Lwin, Kotchakorn Kaewpradit, Malee Tipparoj, department team leads, etc.) had an Odoo login — created 13 placeholder `res.users` accounts (login = bare `employee_code`, random unusable password, no invite sent), following the login convention already decided in #34, and linked each to their existing `hr.employee` record. Populated `xeno.leave.approver` with 23 rows: the 4 originally-flagged departments (Human Resources/Admin/Account/R&D, 2 steps each) **plus 4 more with an unambiguous 1:1 Odoo department match** (Engineering, Software, Quality, Production — single-approver chains), the 5 real per-employee 2-step overrides from #38, and Thin Zar's own employee-level override ("MD" → Kotchakorn). Deactivated (not deleted) the one pre-existing stray row, now superseded. Verified via `build_chain()` for 6 representative employees — correct chain, correct fallback, correct final synthetic HR step every time. **Excluded, new finding — a real HRSystem/Odoo department-taxonomy mismatch, not a guess to make**: Marketing and Sales both only map to Odoo's single "Sales & Marketing" department; Logistics and Procurement both only map to Odoo's single "Purchasing"; HRSystem's "IT" department has no Odoo equivalent at all. Populating any of these would either silently overwrite one of two real configs or invent a mapping — left unconfigured (falls back to existing group-based access, the safe default) pending a decision on how Odoo's department structure should reconcile with HRSystem's for these 5. Also excluded as data-quality issues, not real config: 3 XENHR-side rows explicitly labeled test/dev ("Test leave request email send", "Test approver", "TEst supervisor") and one self-referential row (Sumit Majard configured as his own approver) that would trip xeno_leave's own never-approve-your-own-leave guard if it were ever reached. |
| 4 | "My Team Leaves" supervisor approval queue | **DONE (v1) — 2026-07-09** | 1-2d | Separate feature from #3, confirmed in XENHR (`SupervisorApprovalsPage.tsx`) — not in original HANDOFF.md estimate. **Delivered via #11's `xeno_leave` module** as a "My Team Leaves" menu (Leave → Management) — but note this is **approver-config-scoped, not manager-hierarchy-scoped**: it shows leaves where you're the configured approver (department or employee rule), not "employees who report to me." That sidesteps the blocker below entirely, and arguably matches XENHR's own model better (XENHR's `department_approvers`/`employee_approvers` are also explicit config, not derived from a generic org chart). **Still open**: Odoo's `hr.employee.parent_id` (manager) is empty company-wide, and only 23% reconciles by name against HRSystem (see #18) — so a *generic* "reports to me" view (and #3's attendance equivalent) still has nothing to filter on. Doesn't block this delivered version. |
| 6 | HR post-hoc editing: leave override (force approve/reject) + hr-update | **DONE — 2026-07-09** | 2-3d (was 1-2d) | Bigger than plain "editing" — two distinct operations in XENHR. Shipped in `xeno_leave`: `action_xeno_force_approve`/`action_xeno_force_reject` on `hr.leave` (buttons on the form, Time-Off-Officer-only, uses Odoo's own `action_approve(check_state=False)` bypass hook rather than reimplementing state logic) + an `xeno.leave.hr.update` wizard to edit type/dates after submission. Every action logs to the chatter for an audit trail. Verified with 6 throwaway test scenarios: search/compute correctness, a real random-user negative check, force-approve, force-reject, permission gate (code-reviewed; the negative-test run happened to use an omnipotent admin account so it hit a different, still-correct guard instead — worth a real non-privileged-user retest), and the HR Update wizard's date/duration recompute. **Bugs found and fixed along the way** (see memory `odoo-leave-approval-extensions`): an illegal `--` inside two XML comments; `ir.ui.view.groups_id` renamed to `group_ids` in Odoo 19 (matches the `res.users` rename found earlier) and can't be set on an inherited view record at all (must go on arch elements); and a real logic bug where Odoo 19 normalizes `(field,'=',True)` into `(field,'in',{True})` before calling a custom field's search method — naive `operator == "="` checks silently invert the result. |
| 5 | HR post-hoc editing: attendance inline edit | **DONE — 2026-07-10** | 1d | XENHR: editable grid on `AttendanceReportsPage.tsx`. **Precedence decided (susu, 2026-07-10)**: HR/Admin override saves as a new record, never touches the biometric/GPS source. Built in `xeno_attendance`: new model `xeno.attendance.override` (employee_code+date, unique), merged into `get_month_report`/`render_html`/`export_xlsx` — override always wins over the live-computed value, visibly flagged (blue dot + tooltip with editor/date/source value in HTML, blue fill + `*` in the xlsx export). Click-to-edit `<input type="time">` on each pill, saved via new JSON route `/odoo/attendances/bymonth/edit`, gated to `hr_attendance.group_hr_attendance_officer` (stricter than the view-only group, mirrors `xeno_leave`'s force-approve Officer-only gate) — not available on the My Team page. New "Attendance Overrides" list under Attendances → Configuration for audit/manual management (delete a row to revert to source). **Deployed 2026-07-10**: copied to `/opt/odoo/addons/xeno_attendance` on `192.168.60.33`, `-u xeno_attendance --stop-after-init --no-http` (clean load, no errors, only a pre-existing `@route(type='json')` deprecation warning), `docker compose restart odoo` — container back up. **Verified over real HTTP with throwaway fixtures** (created and deleted after): officer edit on a real report row (100026) persists, shows the blue-dot flag with correct tooltip (editor name/timestamp), and appears with blue fill + `*` in the xlsx export; non-officer edit attempt correctly rejected with `AccessError`; source HRSystem data structurally untouched (override is a separate Odoo table, SQL login is read-only). Test users/overrides cleaned up post-verification. |
| 9 | GPS check-in/out with geofencing | NOT STARTED | 2-3d | Not in original gap list — found in XENHR (`locations` table, `GpsErrorModal`). **Reconcile source-of-truth with #10 before building**: GPS manual punches and biometric punches are two attendance sources; decide precedence/merge rules or you get double counting. |
| 13 | Attendance auto-rules processing | NOT STARTED | 1-2d (was 1d) | Not in original gap list. Depends on attendance data existing (#10/#9). **Caveat**: if these rules classify overtime tiers/late penalties they brush against the out-of-scope OT tiering — define the attendance→OT boundary first (see out-of-scope note) or this silently pulls OT work in. |
| 7 | Export+template+import triad: employees, holidays, leave balances, leave applications, attendance | **DONE — 2026-07-13** | 0.5d (was 3-4d) | **Re-scoped after verifying Odoo's own capability, not built from scratch**: Odoo 19's built-in `base_import` gives every model a real round-trip for free — generic Export from any list view, generic Import wizard with a downloadable auto-generated template, column-header auto-mapping, and a dry-run preview with per-row error reporting. Verified this directly (created + cleaned up a real `hr.employee` test row via `execute_import`). That's the whole mechanism XENHR built by hand with `maatwebsite/excel`; no custom controller/wizard code needed. **Shipped**: new data-only module `xeno_data_io` — 4 curated "Favorite Export" field sets (`ir.exports`) for Employees, Leave Applications, Leave Balances, and Company Holidays, so HR sees a sane ~7-12 column subset instead of Odoo's 200+ raw technical fields. These same exports double as the import template (export → edit in Excel → re-import the same file; Odoo maps columns by header label). **Attendance intentionally excluded**: real attendance data lives in HRSystem via `xeno_attendance`, which already shipped its own Excel export in #10 — Odoo's native `hr.attendance` model (4 rows) isn't the actual data source, so a generic import/export there would be the wrong target. Deployed to `192.168.60.33`, verified all 4 saved exports exist with correct field lists via `odoo shell`. |
| 12 | Resignations module | **DONE — 2026-07-13** | 1d (was 1-2d) | **Checked XENHR's actual source first** (`ResignationController`/`Resignation` model, cloned `xenhr_backend` with susu-provided GitLab creds) rather than guessing scope — much simpler than expected: no draft/approval workflow at all, just a direct HR-entered record. Fields mirrored exactly: `resign_type` (resign/end_of_contract/discharge/retirement/terminate/transfer/pass_away), `resign_date`, notice-period `start_date`, HR `save_date`, `cause`, `resignation_detail`, single `attachment`. **Same side effect as XENHR's `store()`, replicated exactly**: creating the record immediately archives the employee (`active=False`, Odoo's equivalent of XENHR's `employment_status=inactive`) and deactivates their linked user login (`user_id.active=False`) — logged to the employee's chatter for audit. **Deliberately not replicated**: XENHR's `destroy()` only deletes the row without reactivating the employee, so this module doesn't reactivate on delete either — matches source behavior rather than inventing a "nicer" undo path. Shipped as new module `xeno_resignation` (model `xeno.resignation`, gated to `hr.group_hr_user` given it revokes login access), plus a resignation-count smart button on the employee form reusing task #27's `xeno_employee_code` bridge in the search view (search by HRSystem code, matching XENHR's `index()` search). Verified end-to-end with a throwaway employee+user: archive/deactivate/chatter/smart-button-count all correct, cleaned up after. |
| 8 | Multi-language parity check | **DONE — 2026-07-10** | 0.5d | **Confirmed**: `th_TH` is installed and active, and core `hr`/`hr_holidays`/`hr_attendance` ship real `i18n/th.po` files that actually render (verified live: "Employees"→"พนักงาน", "Department"→"แผนก", "Time Off"→"การลา"). This is genuinely ahead of XENHR's thin custom EN/TH key-value table (only wired into a few attendance widgets) — **gap closed, no work needed** for core screens. **New finding, tracked separately, not blocking**: all 4 custom Xenoptics modules — `xeno_theme_slate`, `xeno_sapb1_sync` (pre-existing) and `xeno_attendance`, `xeno_leave` (built this migration) — have **zero i18n coverage** (no `i18n/` directory at all, confirmed on disk). Every custom-built label (Monthly Attendance, My Team Leaves, Leave Approvers, Push to SAP B1, etc.) is English-only regardless of user language. Didn't write translations myself — inventing Thai HR/business terminology without native-speaker review risks shipping wrong or unprofessional wording into a production system. Worth a small follow-up (`i18n/th.po` per custom module) before a Thai-speaking user relies on these screens, ideally reviewed by a Thai speaker rather than machine-translated blind. |
| 14 | Audit log viewer (with table introspection) | **DONE — 2026-07-13** | 1d (was 1-2d) | **Checked XENHR's real source first**: a generic `AuditLog` model + an `Auditable` trait any Eloquent model opts into (create/update/delete, old/new values, performer, IP), wired onto Employee/LeaveApplication/LeaveType/Resignation/etc. Odoo's own chatter/field-tracking covers created/updated but **does not survive record deletion** (the chatter dies with the record) — not a full substitute, confirmed with susu before building. **Shipped**: new module `xeno_audit_log` — `xeno.audit.log` model + a reusable `xeno.audit.mixin` (AbstractModel) providing the same create/write/unlink hooks, changed-fields-only diffs, acting user, IP when available, all surviving the audited record's own deletion since it's a separate model. Wired onto `hr.employee`, `hr.leave`, `hr.leave.allocation`, `hr.leave.type`, `xeno.leave.approver`, `xeno.resignation`, `xeno.attendance.override`. **Real gap caught during verification, not guessed**: Odoo 19 moved `job_title`/`wage`/contract dates off `hr.employee` onto a new per-version model `hr.version` (an employee-history/versioning concept) — on `hr.employee` these are only a non-stored computed passthrough, so a `job_title` edit produced zero meaningful audit entry until `hr.version` was added to the audited list too. Viewer restricted to `hr.group_hr_manager` (Administrator), deliberately narrower than the `hr.group_hr_user` level most other actions in this migration use, since the log shouldn't be visible to the same people whose changes it records; read-only for everyone, no write/unlink access at all. Verified end-to-end with a throwaway employee: create/update(name)/update(job_title via hr.version)/delete all produced correct entries, and the delete entry survived the employee's own removal. |
| 22 | Security/permissions review of new SAP B1 write paths | **DONE — 2026-07-10** | 1d | **Driven by the earlier stray-write incident** (test write reached production `XEN`). Reviewed `xeno_sapb1_sync` (no `security/` access file at all — relies entirely on `hr.employee`'s own permissions plus view-level `groups`). **4 real findings, all fixed**: (1) the "Push to SAP B1" button and "Import from SAP B1" action had **no `groups` restriction** — any user with write access to `hr.employee` could trigger a cross-system write (now gated to `hr.group_hr_user` — "Officer: Manage all employees"). (2) `action_push_to_b1` silently pushed **all employees** when called on an empty recordset — fixed to raise a clear error instead, with a new explicit `action_push_all_to_b1()` as the only intentional bulk path. (3) **The real "environment guard" ask**: added `check_production_write_allowed()` in `sapb1_client.py`, wired into every write path (`action_push_to_b1`, `_cron_push_to_b1`, `_push_absence_to_b1`, `_cron_push_absences`) — if `sapb1.company_db` matches the configured production DB (defaults to `XEN`) and a new `sapb1.confirm_production_writes` flag isn't explicitly set, the write is blocked (hard `UserError` for interactive actions, soft log+skip for best-effort background paths so it never breaks an unrelated leave approval). New settings exposed in Settings → SAP B1 Sync → "Write Safety". (4) Non-finding worth noting: the password field was **already** correctly masked (`password="True"`). **Also discovered**: `hr.leave.write()` unconditionally triggers a live B1 push whenever state becomes `validate`, from *any* code path — including the `xeno_leave` module's Force Approve button built earlier tonight. That's exactly why the guard had to live in the shared client rather than per-caller. Verified all 4 guard scenarios (safe-allow, blocked, soft-skip, confirmed-override) without touching the network, plus a full real push to `XENDEMO` to confirm zero regression. |
| 23 | Add `i18n/th.po` Thai translations to the 4 custom Xenoptics modules | NOT STARTED | 0.5-1d per module (needs a Thai speaker) | **New, found while closing #8** — `xeno_theme_slate`, `xeno_sapb1_sync`, `xeno_attendance`, `xeno_leave` have zero i18n coverage; every custom label is English-only regardless of user language. Low urgency (core HR/Attendance/Leave screens are already properly Thai-translated), but should land before a Thai-speaking user relies on these specific custom screens day-to-day. **Needs a native Thai speaker to review wording** — not something to machine-translate blind for a production HR system. |
| 24 | Per-leave-type request timing limits (advance notice / max backdate) | **DONE — 2026-07-10** | 0.5d | **New — direct request, checked against XENHR's real `leave_types` data** (not just schema): `advance_days`/`max_backdate_days` mirrored onto a new `hr.leave.type` extension (`xeno_advance_days`/`xeno_max_backdate_days`) in `xeno_leave`, enforced on `hr.leave.create()` with XENHR's exact priority (advance_days, else max_backdate_days, else "today or future" default) — pulled from `LeaveApplicationController@store` in the XENHR backend source, not guessed. **Set to XENHR's real configured values**: Annual Leave 7-day advance notice, Sick Leave 3-day backdate allowance, Personal/Unpaid/Maternity unrestricted. **Bypassed for Time Off Officers** creating on an employee's behalf, matching XENHR's separate `storeForEmployee` endpoint which skips this check entirely. Verified with 8 scenarios covering all 3 rule branches, the exact boundary (today = allowed), and the HR bypass — all pass. **Real bug found in susu's own testing (2026-07-13) and fixed**: the "HR bypass" was checking only whether the *acting user* held the Time Off Officer role — not whose leave was actually being created — so an HR/admin account (e.g. susu's own login, which is in that group) could book Annual Leave for themselves with zero notice, since the check bypassed on role alone regardless of target employee. XENHR's actual distinction is between `store()` (self-service, always validated) and `storeForEmployee()` (HR explicitly creating *for someone else*, unvalidated) — not "is the current user HR." Fixed `_xeno_check_leave_timing` to only bypass when `vals['employee_id']` is explicitly set to an employee *other than* the acting user's own linked employee record; an HR officer requesting their own leave through the normal flow is now checked like anyone else. Re-verified with 4 targeted scenarios: HR officer booking their own leave for tomorrow now correctly rejected (the reported bug); the same officer booking a *different* employee's leave for tomorrow still correctly allowed (genuine on-behalf case preserved); a plain employee still correctly rejected for their own too-soon request; a plain employee's compliant (10-day-out) request still succeeds (hit and worked around an unrelated, pre-existing Odoo ACL quirk on a bare-minimum test user reading `private_name` post-create — confirmed via `sudo()` re-read that it's unconnected to this fix, not a regression). |
| 25 | Add missing leave types to Odoo (Unpaid, Maternity, Emergency) | **DONE — 2026-07-10** | 0.5d | **New — direct request**. Odoo only had 3 of XENHR's 6 leave types (Annual/Personal/Sick). Added the other 3 with proper field mapping (Odoo has native equivalents: `unpaid`↔XENHR's `is_paid`, `support_document`↔`requires_document`): **Unpaid Leave** (`unpaid=True`, 30-day allocation), **Maternity Leave** (`support_document=True`, 45-day allocation), **Emergency Leave** (created but left `active=False`, mirroring that it's disabled in XENHR's own source data too — not silently dropped, just inactive). Allocations created for the same 50 real employees the original 3 types cover (identified from existing allocation data, correctly excluding the stray "Test ERP Employee" record). Hit and worked around an Odoo constraint: `hr.leave.allocation` can't be created directly in `validate` state — must create in default `confirm` state then call `action_approve()`. Dry-run tested (create + rollback) before the real commit. Verified after: all 5 active types show exactly 50 allocations each, spot-checked one employee has all 5 with correct day counts (14/7/30/30/45), and Emergency Leave exists correctly as inactive. |
| 28 | Leave approval terminology: "Validate"/"Refuse" → "Approve"/"Reject" | **DONE — 2026-07-13** | 0.5d | **New — direct request**. Odoo shows "Validate" (not "Approve") for the first-level approver whenever a leave type has two-step validation enabled — both buttons call the same `action_approve` method, so susu wanted the wording unified to match this module's own Force Approve/Force Reject buttons, everywhere: `hr.leave` and `hr.leave.allocation` (form, list, kanban — desktop and mobile card layouts), i.e. 6 distinct views. Shipped in `xeno_leave/views/leave_terminology_views.xml` as pure `ir.ui.view` inheritance (no model/logic change) over the `hr_holidays` core views. **Two Odoo view-inheritance gotchas hit and fixed**: (1) `string` cannot be used as an xpath selector attribute at all ("View inheritance may not use attribute 'string' as a selector") — reselected by `invisible`/`icon` combinations instead; (2) a single `<xpath position="attributes">` only rewrites the *first* matching node, not all matches — the list view's header "Refuse" and per-row "Refuse" button both use `icon="fa-times"` and silently only the header one changed until a second, more specific xpath (keyed on presence/absence of the `invisible` attribute) was added for the row button. Also switched exact `@class='...'` matches on the kanban buttons to `hasclass()` per Odoo's own "error-prone selector" lint warning. Ribbon badges ("To Validate") and search filter labels intentionally left as-is — only the clickable action buttons were in scope. Verified via `get_view()` on all 6 views post-deploy: zero remaining "Validate"/"Refuse" occurrences, correct "Approve"/"Reject" counts on each. |
| 26 | Leave balance grid view on the Allocations page | **DONE — 2026-07-10** | 0.5d | **New — direct request**: "grid icon next to Kanban/List/Activity" on the Allocations page (`hr_holidays.hr_leave_allocation_action_approve_department`, `/odoo/action-239`). Odoo Community doesn't ship the Enterprise `web_grid` widget (confirmed not even present in this install) — used Odoo's native **Pivot view** instead, which is Community-native and gets its own switcher icon automatically: rows = Employee, columns = Time Off Type, measures = **Allocated / Used / Remaining** (Odoo's pivot naturally nests multiple measures under each column, matching XENHR's `LeaveBalancePage copy.tsx` 3-sub-column layout exactly, checked against the actual React source). Extended the existing core action's `view_mode` from another module rather than editing `hr_holidays` directly. Added `xeno_used_days` (stored, HR-revisable — mirrors XENHR's manual "Revise Balance" action) and `xeno_remaining_days` (computed, stored) to `hr.leave.allocation`, plus auto-sync: `hr.leave`'s create/write now recomputes the matching allocation's Used total whenever a leave becomes/un-becomes validated. Also added Used/Remaining as columns on the Allocations list and fields on its form. Verified end-to-end: pivot arch has all 3 measures, list/form show the new columns, and — the critical test — creating and approving a real leave correctly updated Used/Remaining, then refusing it correctly reverted them back. **Color added same day**: pivot has no native per-measure styling hook (confirmed via source read of `pivot_arch_parser.js`/`pivot_renderer.js` — a value cell's measure is never exposed on the DOM), so patched it with a QWeb `t-inherit` on `web.PivotRenderer` that stamps a `data-xeno-measure` attribute onto each value cell, then scoped SCSS keyed to our own field names: Used = red `#dc2626`, Remaining = green `#16a34a`, Allocated = blue `#1d4ed8`, matching XENHR's `text-red-600`/`text-green-600`. Deployed, module upgraded, container restarted, asset bundle rebuild confirmed clean in `odoo shell` with no compile errors. |

| 27 | Employee identity bridge (`xeno_employee_code`) | **DONE — 2026-07-10** | 0.5d | **New — part of #17's reconciliation work, split out since it's now independently useful**. Investigated reconciling XENHR's `employment_status` (59 active/19 inactive of 78 employees) against Odoo's 51 `hr.employee` records. Found no existing bridge key on either side: Odoo has no employee-code field, and SAP B1's `b1_employee_id` is SAP's own internal ID, unrelated. Attempted matching via HRSystem's `users.email`, but discovered **85% of HRSystem's `users.email` values are placeholder/test data** (66 of 78 rows share one developer's email) — completely invalidating email as a join key; an earlier automated pass on this basis would have wrongly archived an unrelated employee record and was caught before being applied. Fell back to fuzzy name-matching (`difflib.SequenceMatcher`) between the two employee lists: 19 rows scored ≥0.9 (near-exact), one of which (`EMP-0002` → "HR Manager") was excluded as a generic role account, not a real individual. **Shipped**: added `xeno_employee_code` (indexed, unique-if-set) to `hr.employee` via `xeno_attendance` (the module that already owns the HRSystem-identity-bridge pattern), exposed on the employee form under a new "HR System" tab, backfilled for the 18 confirmed real matches. Future attendance/leave/resignation work can now key off this field directly instead of re-deriving identity by name every time. **Also completed**: of those 18, 3 were HRSystem-`inactive` but still `active=True` in Odoo — Pawinee Jaruwaranon (100053), Paiboon Sirisathianwattana (100161), Darin Udomkarn (100100) — archived (`active=False`) after explicit confirmation, verified via `odoo shell`. The other 60 HRSystem employees (16 more inactive, 44 active) have no confident match at all and remain unresolved — full scope belongs to #17. |
| 29 | Holidays page: calendar view + rename + Public/Company type | **DONE — 2026-07-13** | 0.5d | **New — direct request**. Odoo's "Public Holidays" page (`hr_holidays.open_view_public_holiday`, `/odoo/action-228`) only had list/form, and `resource.calendar.leaves` has no notion of holiday type at all. **Referenced `xenhr_frontend`'s `src/pages/company/HolidaysPage.tsx`** for fields/styling: it distinguishes every holiday as Public (blue) or Company (purple) with List/Calendar/Grid modes. Shipped new module `xeno_holidays`: added `xeno_holiday_type` (Selection: public/company, mirrors XENHR's `Holiday.type`) to `resource.calendar.leaves`, exposed on list/form/search (with Public/Company filter chips + group-by, echoing the page's toolbar), and a new calendar view colored by that field, explicitly bound via `ir.actions.act_window.view` so it wins over the generic resource_id-colored calendar view already on this model (used elsewhere for per-employee schedules). Action + menu renamed "Public Holidays" → "Holidays". Backfilled the type for all existing imported holidays (tasks #7/#15) by parsing the "(public)"/"(company)" suffix baked into their names at import time. **Bugs hit and fixed**: `quick_add` isn't a valid Odoo 19 calendar-view attribute (RNG validation rejected the module on first install); more importantly, adding a field with a Python `default` backfills *every existing row* to that default at the schema level before `post_init_hook` runs — so the hook's `xeno_holiday_type = False` filter matched nothing and every holiday silently got the wrong type + kept its redundant name suffix. Fixed by re-scoping the hook (and a one-off correction script) to parse every record's name regardless of its current field value. Verified after: 44 of 46 existing holiday records correctly typed and renamed (2 unrelated pre-existing records untouched), company ones (Company Anniversary, Company Holiday, Company Songkran Holiday) correctly flagged `company`, action/menu name and calendar-view binding confirmed via `get_view()`/`ir.actions.act_window.view` lookup, and the real page (`https://192.168.60.33/odoo/action-228`) resolves. **Same-day follow-up**: added `xeno_remark` (Text) to the same model on request, backfilled from XENHR's `Holiday.description` (Thai annotations, e.g. "วันขึ้นปีใหม่") pulled fresh from the live API for both 2025 and 2026 and matched to Odoo records by date — 42 of 43 dated descriptions matched (the 1 miss is a 2025 date never imported into Odoo in the first place). Two records legitimately got no remark because their live-system description is empty, not because of a bug (double-checked directly). **Second follow-up**: on `/odoo/time-off` (the personal Time Off dashboard, year-view calendar), holidays and weekends were both rendered with Odoo's identical grey `o_calendar_disabled` styling — `get_unusual_days()` only returns a flat true/false per date with no type info, so the two can't be told apart server-side. Read Odoo's actual JS source (`web`'s `calendar_year_renderer.js` + `hr_holidays`'s `TimeOffCalendarYearRenderer`) rather than guessing, and added a small Owl patch (`patch()` on `TimeOffCalendarYearRenderer.prototype.getDayCellClassNames`) that keeps Sat/Sun grey but swaps any other unusual day to a new green `o_xeno_calendar_holiday` class. Verified the JS/SCSS actually compile (no browser available this session): built the real `web.assets_backend` bundle server-side via `env["ir.qweb"]._get_asset_bundle(...)` and confirmed both the patch code and the green CSS rule are present in the compiled output, not just that the module installed without error. |

**Explicitly out of scope for this migration**: Payroll (PIT/SSO/WCF/EWF),
OT tiering — XENHR only has empty schema stubs for these (no controllers),
so there's no existing functionality to migrate. Separate greenfield
build-vs-buy decision per `docs/hr-summary.html` §6.
**Caveat (do not treat as fully deferred)**: OT tiering is a *soft blocker*
for #13 (attendance auto-rules) — if #13 needs to emit OT hours/tiers, the
attendance→OT boundary has to be agreed now even though the tiering *engine*
stays out of scope. Otherwise #13 either stalls or quietly imports OT scope.

**Mobile**: unsized, separate decision. No React Native code found in
`xenhr_backend`/`xenhr_frontend` — need to check the referenced
`xenhr_application` repo to confirm it exists before scoping PWA vs.
port vs. rebuild.

## Phase 3 — Data & validation

**Sequencing note**: Phase 3 does not have to wait for *all* of Phase 2.
Start #16 parallel-run **incrementally, per feature**, as each Phase 2 item
lands (e.g. validate leave approvals once #11/#4/#6 are in) rather than
holding a single big-bang validation at the end. The `XENDEMO` stale-clone
issue in the infra track is a **blocker for #18/#16** — refresh it first.

| # | Task | Status | Est. | Notes |
|---|---|---|---|---|
| 15 | Import any XENHR test data worth keeping | **DONE — 2026-07-13** | 0.5d | **Checked XENHR's actual `database/seeders/` first** rather than guessing what "worth keeping" means. Found real, non-throwaway reference data: `HolidaySeeder.php`'s 19 Thailand public/company holidays for 2025 (correct statutory dates + Thai names — Songkran, Makha Bucha, HM the King's Birthday, etc.) against an Odoo `resource.calendar.leaves` that had only 2 records. **Everything else in the seeders was explicitly NOT imported**: `DepartmentSeeder`'s generic list (Admin/R&D/Engineering) is placeholder — Odoo already has Xenoptics's real departments from HRSystem sync (Electrical, Electronics, Embedded, Mechanical, Purchasing, etc.), importing the seeder would've added noise; `LeaveTypeSeeder`/email/language/admin-user/company-activity seeders are either superseded by #24/#25's real-data-checked leave types or XENHR-app-specific config with no Odoo equivalent. Of the 4 `WorkShiftSeeder` templates, susu confirmed only **Morning Shift (8:00-17:00) and Day Shift (9:00-18:00)** are real — created as two new `resource.calendar` records (5 weekday attendance lines each, split around a 12:30-13:30 lunch break matching XENHR's own `CompanyActivitySeeder`, 8h/day). **Gotcha hit**: XENHR's seeder has both "New Year's Eve" and "Year-End Company Holiday" on the same date (2025-12-31) — Odoo's `resource.calendar.leaves` validation rejects two global holidays overlapping the same day, so these were merged into one record. **Gotcha hit #2**: running a data-loading script via `odoo shell < script.py` does **not** auto-commit on clean exit (confirmed by re-querying in a fresh shell session and finding nothing persisted) — needs an explicit `env.cr.commit()` at the end of the script, unlike module install/upgrade via `-u`/`-i` which commits as part of the normal registry-load process. Verified in a separate shell session after commit: 20 holiday records + both new calendars present. **Follow-up, same day**: susu pointed out the seeder-based holidays were for 2025 (already past) and had me pull the real 2026 calendar directly from live XENHR's own API (`https://192.168.60.32:8443/api/holidays?year=2026`, logged in as employee_code `100202` via `POST /api/auth/login` for a Sanctum token) instead of relying on the static seeder — this is the actual HR-maintained current data (real edit history through 2026-06-24, including a mid-year date correction for HM Queen Suthida's Birthday that the static seeder wouldn't have known about). Imported all 25 active holidays (1 inactive/superseded entry correctly excluded) as 24 `resource.calendar.leaves` records (same Dec-31 double-booking merge as before: "New Year's Eve" + "Year-End Company Holiday" on one date). The earlier 2025 import was left in place (harmless historical record, not deleted without being asked) but is now superseded in practice by this 2026 data for anything forward-looking. |
| 18 | Master-data + historical-data migration & reconciliation: employees, org/supervisor tree, leave balances/accruals, historical attendance & leave — with a written reconciliation/validation checklist (row counts + spot totals Odoo vs. source) | **IN PROGRESS — employee reconciliation advanced 2026-07-14** | 2-4d | **Concrete finding**: name-matched Odoo `hr.employee` against HRSystem `employees` (78 rows) — only **18 matched (23%)**, 0 ambiguous, 60 unmatched (some inactive/terminated, some just not yet in Odoo). Of the 18, only **3** also had their `supervisor_id` uniquely resolvable to a matched Odoo record. **No `parent_id` (manager) writes were applied** — match quality too poor to trust for something that drives approval authority. Real fix: add `employee_code` to `hr.employee` and reconcile by code, not name, before any manager-hierarchy import. This blocks Odoo's native "My Team" leave/attendance approval views (#4/#3), which depend entirely on `parent_id` being populated correctly. **Follow-up 2026-07-14 — pulled a richer HRSystem dataset and re-matched**: full `employees` table (first/middle/last name, Thai name, phone, DOB, hire date, employee_code) vs. Odoo — but Odoo's own records have essentially zero phone/birthday data (0/51 work_phone, 0/51 birthday), so phone/DOB gave no extra matching signal; name similarity was the only usable signal beyond #27's original pass. Of the 57 employees left unmatched by #27: **1 genuine additional match** (100007 "Yossi Arol" → Odoo's "Yossi Arol Arol", clearly a surname-duplication data-entry quirk, score 0.82) backfilled into `xeno_employee_code`; the other 16 "matches" in the 0.55–0.65 range were chance-level noise between short, common Thai names and were **not** treated as matches (would have risked silently merging two different people); **40 had no name signal at all**. Per direct instruction, created all 56 remaining as new `hr.employee` records (name, `xeno_employee_code`, `job_title`, correct active/inactive per HRSystem's `employment_status`) and pushed them to `XENDEMO` via the existing `action_push_to_b1()` (target confirmed `XENDEMO` not `XEN` before pushing — `sapb1.company_db`, cross-checked against the real guard field `sapb1.production_company_db`, not the wrong key name first queried). **Push result: only 6 of 56 succeeded** (`b1_employee_id` 47-52) — the other 50 failed on `[B1 -1101] Critical cache refresh failure` / `[B1 299] login failed: Internal server error`, the *same* intermittent XENDEMO Service Layer instability already visible in the server logs from an unrelated leave-approval sync before this work started — not caused by this import. All 56 Odoo records exist regardless and are safely retryable (`action_push_to_b1()` is idempotent — checks `b1_employee_id` before deciding POST vs PATCH) once XENDEMO stabilizes; did not loop-retry against a visibly unstable external system. **Separate real issue, 3 records, independent of the connectivity problem**: `[B1 -1] Value too long in property 'JobTitle'` for job titles ≥24 characters with no spaces (e.g. "AutomationLeadConsultant") — the 6 that succeeded all had titles ≤21 chars, suggesting a real SAP B1 field-length limit worth confirming and truncating for before those 3 are retried. **Still not started**: org/supervisor tree, leave balances/accruals, historical attendance & leave. **Written reconciliation/validation checklist (2026-07-14)**: drafted at `RECONCILIATION_CHECKLIST.md` — defines "reconciled" per domain, the exact row-count/spot-total check to run, and current status per domain. Key output of that pass: 75/78 HRSystem employees now have `xeno_employee_code` set in Odoo (3 unaccounted for, not yet checked whether they're `EMP-000X`-style generic accounts or genuine gaps); and **org/supervisor tree is now worth re-attempting** — the original 2026-07-09 attempt only resolved 3/18 supervisor links because it joined by name, but now that 75/78 employees carry `xeno_employee_code`, re-joining `employees.supervisor_id` through that code (not name) should resolve far more and is the highest-value next step, since it unblocks "My Team" approval views (#3/#4). Leave-balance reconciliation is blocked on first identifying the actual XENHR/HRSystem source table for balances (not yet located). Historical attendance/leave reconciliation is blocked on a scope decision: there's currently no Odoo-native import target for historical attendance at all (task #10 is a live read-through report against HRSystem, not an import into `hr.attendance`) — needs a decision on whether that's an accepted permanent substitute before this check is even meaningful. All of the above requires `odoo shell`/HRSystem DB access to execute; this pass was checklist-drafting only, no server access available this round.

**Session A, 2026-07-14 (SSH access obtained)**: **org/supervisor tree — DONE.** Re-joined `employees.supervisor_id` (confirmed to be `employees.id`, not employee_code — settles the schema caveat) through `xeno_employee_code` instead of name: all 49 HRSystem org-tree rows now resolve on both sides (using `active_test=False`, since some are archived employees — an early pass that used the ORM default active-only search wrongly flagged 11 rows as unmatched). 34 already had the correct `parent_id`; **15 were corrected** via direct `write()`, committed and verified. This unblocks "My Team" attendance/leave (#3/#4) with real data. **XENDEMO retry**: found the real scope of the JobTitle-length issue was much bigger than the "3 records" first assumed — 20 of the 50 pending employees have `job_title` ≥22 characters (up to 67 chars). Fixed properly in `xeno_sapb1_sync/models/hr_employee.py`'s `_odoo_to_b1_vals()` — truncate to 20 chars **only on the outbound push**, Odoo's own `job_title` untouched — deployed (module upgraded, container restarted, clean load). Retried all 50 pending: **18 succeeded** this session (bringing the running total to 24/56 employees pushed), confirmed via before/after `b1_employee_id`. The remaining **32 still fail** with the identical `[B1 -1101] Critical cache refresh failure` seen before — a genuine, current XENDEMO Service Layer instability, not a code issue (verified: none of the remaining failures cite JobTitle anymore); stopped after two bounded retry passes rather than hammering an unstable external system. **Leave balances / department-approver step-count checks — blocked, needs elevated access**: confirmed `xeno_odoo_ro` cannot self-grant new table permissions (a GRANT attempt returned no Python exception but `fn_my_permissions` proved it was a silent no-op — checked directly rather than trusting the absence of an error) — need either elevated HRSystem SQL Server credentials, or susu to run two `GRANT SELECT` statements directly, to unblock the `leave_balances` dump and the `department_approvers`/`employee_approvers` step-count check from plan #38. |
| 16 | Parallel-run Odoo vs. XENHR with real users (start per-feature, see note) | NOT STARTED | 3-5d | |

## Phase 4 — Cutover

| # | Task | Status | Est. |
|---|---|---|---|
| 19 | Cutover runbook: maintenance/downtime window, go/no-go criteria, and a **rollback plan** (revert path + data back-out) | NOT STARTED | 1d |
| 20 | User training / change management (comms, quick-ref docs, at least one walkthrough session) | NOT STARTED | 1-2d |
| 17 | Archive XENHR repos read-only, stop hosting Laravel/React | NOT STARTED | 0.5-1d |
| 21 | Post-cutover monitoring & alerting: biometric/attendance import health, SAP B1 push health, approval-queue backlog | NOT STARTED | 1d |
| 30 | Per-request Day/Half-Day/Hour duration picker for Annual/Personal/Sick | **DONE — 2026-07-13** | ~1d | **New — direct request**. Verified the advance-notice/backdate rules from #24 were already live and matched XENHR's real `advance_days`/`max_backdate_days` exactly (checked `/api/leave-types` directly: Annual 7d, Sick 3d backdate, Personal immediate — nothing to change there). The new part: XENHR's `/api/leave-base-types` shows each leave type can have multiple duration granularities at once (Full-day/Part-time/First-half/Second-half), but Odoo's `hr.leave.type.request_unit` is a single fixed setting (day *or* half-day *or* hour) — not a per-request choice. Confirmed with susu this needed a real per-request picker, not just switching to Odoo's 'hour' mode. **Shipped in `xeno_leave`**: `xeno_flexible_duration` (Boolean) on `hr.leave.type`, set on Annual/Personal/Sick only (Unpaid/Maternity stay day-only, matching their single "Full-day Leave" base type in the live API); `xeno_request_unit` (Selection day/half_day/hour) on `hr.leave`, shown as a radio picker right after the leave-type field, visible/required only when the type is flexible. The core trick: Odoo's `request_unit_half`/`request_unit_hours` booleans (which already drive every downstream date/duration computation and view-field visibility) are themselves computed from `leave_type_request_unit`, a `related` field pointing at the type's fixed setting — overriding that one field to fall back to the per-request choice when flexible made the *entire* rest of Odoo's leave engine respect it automatically, with no changes needed to core duration/date logic. **Two real bugs hit and fixed**: (1) simply redeclaring a `related` field with `compute=` in a downstream module is silently ignored ("selection attribute will be ignored as the field is related") — must explicitly pass `related=None` to actually clear the inherited relation; (2) flipping `xeno_flexible_duration` on the leave *type* via normal ORM `write()` cascaded a recompute into every existing leave record of that type, including already-approved ones, and crashed on Odoo's own `_check_date_state` constraint ("modification is not allowed in the current state") — fixed by setting the three real types' flag via raw SQL instead of the ORM, which doesn't trigger that cascade (normal per-request use is unaffected since new requests start in the editable 'confirm' state). **Verified**: 11 scenarios via a throwaway employee/allocations — Annual/Personal/Sick × Day/Half-Day/Hour all produce correct `number_of_days`/`number_of_hours` (1.0/8h, 0.5/4h, 0.25/2h for a 2h request), Unpaid/Maternity correctly ignore the picker entirely (stay day-only even when `xeno_request_unit` is set to something else), and the #24 advance-notice check still correctly rejects an Annual Leave request made too soon regardless of which duration unit is chosen — confirming the two features compose correctly. |
| 31 | Restrict leave approval to the configured approver; lock content editing to HR post-submission; requester keeps only Cancel/Delete | **DONE — 2026-07-13** | ~1d | **New — direct request, driven by a real bug susu found in testing**: employees could approve/refuse their own leave requests. Root cause (confirmed by reading Odoo's actual `_get_next_states_by_state`/`_check_approval_update`): Odoo's stock `can_approve`/`can_validate` grant approval rights to *any* Time Off Officer over *any* leave, including their own — and since this migration's "My Team Leaves" configured approvers (`xeno.leave.approver`, task #11) necessarily hold that Officer role for Odoo's own state machine to permit the transition at all, any configured approver (or any other Officer) could approve/refuse leaves that weren't theirs to decide, including self-approval. **Shipped in `xeno_leave`**: overrode `_compute_can_approve`/`_compute_can_validate`/`_compute_can_refuse`/`_compute_can_back_to_approve` to narrow (never widen) Odoo's own result — never the requester's own leave, and when `xeno.leave.approver` has a specific rule configured for the employee, only that approver; **falls back to Odoo's normal group-based access when nothing is configured** (matching the approver model's own documented intent — a missing config must never silently block everyone from approving). Also fixed a real gap in Odoo core: `action_refuse()` doesn't check `can_refuse` internally at all (unlike `action_approve()`, which does check `can_validate`/`can_approve`) — added an explicit check. **Edit lock**: new `xeno_can_edit_content` computed field (true only for HR Officers, or while the record has no id yet i.e. still being drafted) drives both a `write()` override (blocks any non-HR write to the leave's actual content — type, dates, duration, reason — once saved) and matching view readonly conditions (combined with, not replacing, each field's existing state-based readonly). **Requester's self-service is deliberately preserved for exactly two actions, matching how Odoo's own state machine already splits this**: delete a still-pending (`confirm`) request outright (Odoo has no separate "cancel" concept before a decision is made), or Cancel an already-approved/refused one via the existing wizard (`can_cancel`, already correctly scoped to "your own leave" in core, untouched). **Critical bug found and fixed along the way, unrelated to today's actual ask but blocking every test of it**: `xeno_audit_log`'s mixin (task #14) read every stored field of an audited record — including `hr.leave.private_name`, which is ACL-restricted to a higher HR group — in the *acting user's own permission context* while building the audit snapshot, not via `sudo()`. This meant **any regular employee creating their own leave request has been hitting an AccessError since that module was deployed**, not just today's new checks. Fixed by reading all audited field values via `record.sudo()` (the audit write itself was already correctly sudo'd; only the *read* was missed). **Verified**: 11 scenarios via throwaway employees/approver-config — self-approval blocked, a non-configured officer blocked once an approver *is* configured, the configured approver can still approve and refuse, content edits by the requester blocked post-submission, the same edit by HR succeeds, a still-pending request can be deleted by its requester, and an approved one can still be cancelled by its requester. **Follow-up, same day**: susu reported an employee could still edit after submitting — traced to the test account (`erptest@xenoptics.com`) being a member of the Time Off Officer group, so it was being treated as an HR member and allowed to edit *by design*, not a leak (re-verified the model-level lock across all 10 lockable fields individually plus a form-style multi-field write — all correctly rejected for a plain employee). Removed `erptest` from the Time Off Officer group (kept its Attendance Officer membership) so it behaves as a regular employee for testing; re-verified as `erptest`: create works, post-submit edit rejected, self-approval rejected. **Second follow-up, same day — a real UI leak this time**: susu reported pending leaves still looked editable in the browser. The server-side `write()` block was working (a save attempt did fail), but the form's fields rendered as editable because `_compute_xeno_can_edit_content` checked `not leave.id` — and in the web client's form-editing context, records are wrapped in virtual `NewId` objects which evaluate falsy *even when they wrap a real saved record*, so every saved leave looked like a fresh draft to the compute and the view's readonly conditions never engaged. Fixed by checking `leave._origin.id` instead (`_origin` resolves a NewId back to its underlying saved record; empty for genuinely new drafts, which correctly stay editable). Verified all 4 contexts via `record.new(origin=...)` — the exact web-client simulation: saved record direct read = locked, virtual-NewId-wrapping-saved = locked (this was the bug), brand-new draft = editable, virtual record as HR = editable. **Lesson recorded: any compute that means "is this a saved record?" must test `_origin.id`, never bare `id`, if a form view depends on it.** |
| 32 | Redesign leave request form to match XENHR's Apply for Leave dialog | **DONE — 2026-07-13** | ~0.5d | **New — direct request with a screenshot of XENHR's dialog as the spec.** Two visible changes on the `hr.leave` form (both the full form and the Time Off dashboard dialog, which inherits it): (1) a **balance summary strip** under the leave type — Total / Used / Pending / Remaining (Remaining in green), live-computed per employee+type from validated allocations, validated leaves, and confirm/validate1 pending leaves — mirroring the strip in XENHR's dialog; (2) the duration picker reworked from generic Day/Half-Day/Hours radios into a **"Leave Type Basis" pill picker with XENHR's exact four base types** — Full-day Leave / Part-time Leave / First-half Day Leave / Second-half Day Leave (`/api/leave-base-types` names verbatim). First/Second-half auto-set the AM/PM period (onchange in the form, normalized in `create()` for API callers too — the basis dictates the period, callers can't disagree), so the now-redundant period selectors are hidden for flexible types; Part-time maps to hour mode with the time range fields. Pill + strip styling shipped as SCSS in `xeno_leave`'s backend assets. **Data migration**: `xeno_request_unit`'s selection keys changed (day/half_day/hour → full_day/part_time/first_half/second_half), 9 existing rows migrated via SQL (half_day rows split into first/second-half by their stored AM/PM period). **Verified**: all four bases as `erptest` compute correct durations (full 1d/8h; first-half 0.5d AM-only UTC window; second-half 0.5d PM-only; part-time 9:00-12:00 → 3h/0.375d), and the balance strip's four numbers exactly match independently-queried allocation/leave sums (including 1.5d of susu's real pending UI test requests, correctly counted); SCSS confirmed present in the compiled asset bundle and the new elements in the rendered form arch. **Follow-up crash, same day, root-caused and fixed**: susu hit an "Oops" crash when changing the Time Off Type in the request dialog as `erptest`. Reproduced server-side (the HTTP log showed only 200s — Odoo wraps RPC errors in JSON, so the traceback never hits werkzeug's access log): `hr.leave.type.name_search` → `get_allocation_data` → reading `employee.company_id` triggers the ORM's prefetch of *all* stored `hr.employee` fields → for a non-HR user this goes through `hr.employee.public`'s `_check_private_fields`, which rejects any custom stored field it doesn't recognize — `AccessError` on `xeno_employee_code` (#27) + the 5 pre-existing `b1_*` SAP fields. **Latent since those fields were added; never surfaced because no genuinely non-privileged user had ever opened the dropdown** (erptest was an HR officer until earlier today). Fixed the right way: `groups="hr.group_hr_user"` on all 6 fields — which excludes them from non-HR prefetch *and* closes a real data-exposure issue (`b1_salary`/`b1_bank_account` were technically readable by any internal user until now). Checked all usage sites first: every view/filter/export touching these fields is already HR-gated, Odoo auto-hides group-restricted fields from views, and the SAP crons run as superuser — no fallout. Verified post-fix as `erptest`: `name_search` works for empty/partial/exact queries with correct per-type remaining balances, and HR accounts still read the fields fine. **Rule for future fields: any custom stored field on `hr.employee` must carry a `groups=` restriction, or it breaks every employee-touching screen for regular employees.** |
| 33 | Nest "Directory" under "Employees" in the sidebar | **DONE — 2026-07-14** | trivial | **New — direct request.** `hr.menu_hr_employee` ("Directory") was a top-level sidebar entry, a sibling of `hr.menu_hr_employee_payroll` ("Employees") rather than a child of it. Overrode `parent_id`/`sequence` on the existing core menu record in `xeno_resignation` (already the module touching this navigation area). Verified: "Directory" now nests under "Employees," confirmed via the actual menu tree read back after upgrade, not just that the module installed. |

## Phase 2b — Gaps found by the 2026-07-14 full-inventory re-check

Source: `XENHR_ODOO_GAP_ANALYSIS.md` (feature-by-feature diff of both systems
— every custom Odoo module read in full vs. XENHR's verified ~192-route /
34-model / 30-page inventory). 17 of 32 feature areas confirmed at parity, 5
deliberate non-migrations; these are the genuinely new items:

| # | Task | Status | Est. | Notes |
|---|---|---|---|---|
| 34 | Employee self-service accounts + login convention | DECISIONS MADE (2026-07-14) — account creation deliberately on hold | 1–2d | **Cutover-blocker, previously untracked**: XENHR logs in by employee_code; most Odoo employees have no `res.users` account. Current state checked directly: 55 of 56 active linked employees have no login (only 1, susu's own account, exists); only 13 of those 55 even have a real `work_email` set. **Decided with susu**: login = bare `employee_code` (internal user, not portal). **Deliberately not executed yet**: susu chose to hold off creating the 55 real accounts entirely rather than generate them now, since task #19 (training/change management) hasn't started — creating live logins (with or without passwords) for real employees who haven't been told about this migration risks confused support requests. Re-open once #19's timing is decided. |
| 35 | "My Attendance" employee self-view | BUILT (2026-07-14) — deploy pending | 0.5–1d | `/odoo/attendances/my` + export in `xeno_attendance` v1.1.0: renders via `restrict_codes=[caller's own xeno_employee_code]` only (ignores `employee=` param), "My Attendance" menu for all internal users, friendly not-linked page. Deploy + upgrade module on server; useful to users once #34 gives them logins. |
| 36 | Bring `xeno_theme_slate` under version control + kill duplicate holiday model | DONE (2026-07-14, Session A cont'd) | 0.5–1d | Cloned the full `erp_xenoptics` GitLab group (`xenhr_backend`/`xenhr_frontend`/`xenhr_application`) and grepped for `theme_slate`/`xeno_theme` — **absent**, confirming the live server copy really was the only artifact. `xenhr_application` (mobile) **confirmed to exist** as a real GitLab repo (React Native, Android + app.json present). Pulled the live module into `/home/susu/odoo-hr-migration/xeno_theme_slate` via `sudo tar` over SSH. **Duplicate holiday model killed**: found `xeno.hr.holiday` (a separate, un-maintained model backing the "My Profile" dashboard widget's mini-calendar) held only **2 fake test rows** ("วันหยุดทดสอบ"/"วันหยุดเดือนหน้า") while the real holidays — 44 rows, built by #29/`xeno_holidays` — live in `resource.calendar.leaves`. The widget was silently showing 2 test entries and missing every real Thai holiday. Repointed `my_profile.js`'s query to `resource.calendar.leaves` (`resource_id=False` for company-wide, `date_from`/`date_to` range), deleted `models/hr_holiday.py` + `views/hr_holiday_views.xml` + its manifest/access-CSV entries, bumped to v1.1.0. Verified after upgrade: Odoo auto-pruned the model/view/menu/access/field records for the removed model, no errors; a direct `resource.calendar.leaves` query returned real July/August 2026 Thai holidays (King's Birthday, Asarnha Bucha Day, etc.). Announcements model (also in this module) left untouched — out of scope for this fix. |
| 37 | Foreigner details migration + permit/visa expiry alerts | DONE (2026-07-14, Session A cont'd) | 1d | New module `xeno_foreigner_details`: adds passport issue place/date + visa issue date (core 19 already has passport/visa/permit no+expiry — passport on hr.version); overrides core's `notify_expiring_contract_work_permit` cron method with **windowed** once-per-date alerts for permit+visa+passport (core's exact-day match would skip anything imported already inside the 60d notice window). Installed on server, then imported HRSystem's `employee_foreigner_details`: of 11 rows only **2 had `is_foreigner=1`** with real data (100202 Nyein Su Su, 200002 Wa Wa Lwin — the other 9 were empty placeholder rows), resolved via `xeno_employee_code`, HR Responsible set to the real `hr.manager@xenoptics.com` role account (105/107 employees otherwise default to OdooBot, not an established convention). **Known gap, flagged not fixed**: HRSystem's `visa_issue_place` column has no Odoo-side field. |
| 38 | Read XENHR's `AttendanceAutoRule` + `leave_approval_steps` logic | **DONE (2026-07-14)** | 0.5d | **(a) OT boundary settled, no risk**: `AttendanceAutoRule` has nothing to do with overtime tiers or late penalties — it's a per-employee/per-group "mark present if no punch that day" auto-fill toggle, cron-driven (`attendance:process-auto-rules`). Its `rule` enum also allows `ignore`, but grep across the whole app confirms `ignore` is never read anywhere outside the model/migration/controller — dead/unused value. Plan #13 can proceed without touching the OT-tiering out-of-scope boundary. **(b) approval-step scope-down: safe for the observed common case, one residual risk flagged**: `leave_approval_steps` is a real ordered multi-step chain (`ApproverConfigController::buildChain()` — employee-level custom approvers **or** department-level approvers, each a list of ordered steps, always capped with a final unassigned "HR Manager" step), with sequential gating (`step_order` N blocked until N-1 approved), and mid-flight insert/remove-approver endpoints. Notably, the "personal supervisor" step is **dead code** — commented out in `buildChain()`, never actually inserted — so in practice a chain today is [0 or more department/employee-configured steps] + [HR]. `xeno_leave`'s single-approver-then-HR design matches this **as long as every department/employee is configured with exactly one custom-approver step**, which is the observed pattern; if any department was ever configured with 2+ ordered steps, that sequencing would be lost. **Checked in Session A (2026-07-14) — a real, live gap, not just a theoretical risk**: 4 of 13 departments have exactly 2 active approver steps configured — **Human Resources** (dept_id 1: "HR" then "HR 1"), **Admin** (dept_id 6: "HR" then "Head of Department"), **Account** (dept_id 8: "HR" then "Head of Department"), **R&D** (dept_id 9: "HR" then "Head of Department") — covering 24 of 56 active employees (43%), and 5 individual employees within Admin/R&D (300025/300026/300013/300021/200023) have personal 2-step overrides ("Team Supervisor"/"Receptionist" then "HR") — no extra headcount beyond the 24. Since `buildChain()` always appends the final "HR Manager" step regardless, these are genuinely **3-step** chains live today. `xeno_leave`'s current single-approver-then-HR design (2 steps) would silently skip the middle step for this ~43% of active headcount. **Decision made, built, and data-migrated (2026-07-15, see #11 v2)**: `xeno_leave` now supports real N-step chains (`xeno.leave.approval.step`), deployed to production, and the real per-department/per-employee chains for HR/Admin/Account/R&D (this row's original finding) plus Engineering/Software/Quality/Production are now live in `xeno.leave.approver`, closing this gap for those 8 departments + the 5 employee overrides + Thin Zar's own MD override. **New residual gap found during that work, not present before**: HRSystem's Marketing/Sales/Logistics/Procurement/IT departments don't cleanly map 1:1 onto Odoo's actual department list (Marketing+Sales both only match Odoo's single "Sales & Marketing"; Logistics+Procurement both only match "Purchasing"; "IT" has no Odoo equivalent at all) — populating these needs a decision on department-taxonomy reconciliation first, not a guess that could silently misroute one of two real configs. **Also confirmed, not assumed**: the "unauthenticated email-link approve" is real and worse than a vague risk — `routes/api.php` explicitly comments it "Public route... no auth required"; `GET /leaves/{user}/{leave}/approve-step-email` takes zero token/signature, only route-model-bound integer IDs, so anyone who learns/guesses a user+leave ID pair can execute an approval *as that user* with no credential at all. Confirms #39's "recommend drop" stance with a concrete mechanism, not a guess. |
| 39 | Outgoing-email parity check | **DONE — 2026-07-14/15** | 0.5d | Live SMTP (Office365) confirmed working. Found and fixed a real duplicate-email bug: Odoo core's `_action_validate`/`action_refuse` unconditionally `message_post` a second "Your leave has been accepted/refused" notification to the requestor, on top of `xeno_leave`'s own polished final-notice email — fixed via a `message_post` override on `hr.leave` that strips `partner_ids` specifically when it matches core's hardcoded self-notify call signature, leaving every other `message_post` untouched. Deployed to `192.168.60.33` (module upgrade + full container restart, since the fix is a Python override and workers cache imports), verified live with a throwaway test leave + `mail.notification` inspection: exactly 1 notification generated per approval (core's suppressed, ours `sent`), test data cleaned up after. XENHR's **unauthenticated** email-link approve was **not** replicated — #38's per-step approval emails (v2 chain, this session) use a signed/expiring HMAC token bound to the exact leave/step/action/user instead. |
| 40 | Per-employee attendance export parity | NOT STARTED | 0.5d | `sp_employee_attendance` body never captured; confirm bymonth's `employee=` filter covers it. |
| 41 | Audit coverage extension | MOSTLY DONE (2026-07-14) | 0.5d | Verified `b1_salary`/`b1_bank_account` are stored hr.employee fields → **already audited** by the existing mixin (it enumerates `self._fields` at runtime, so cross-module fields are covered). Added mixin to `xeno.attendance.code.map` + `xeno.attendance.viewer` (audit v1.1.0). Remaining: announcements once #36 lands; upgrade module on server. |

Folded into #18 instead of new tasks: employee taxonomy (Level/Type/Group)
mapping, per-employee `work_shift_id` → `resource_calendar_id` assignment,
and the `leave_balances` source-table hunt (lead: `xenhr_backend` migrations
— the `/leave-balances` routes + Export classes prove it exists).

## Infra track (parallel, not blocking migration)

| Task | Status | Notes |
|---|---|---|
| Switch SAP B1 sync target to `XENDEMO` for safe testing | **DONE** (2026-07-09) | `sapb1.company_db` set via `ir.config_parameter` |
| Diagnose+fix config-cache staleness bug | **DONE** (2026-07-09) | Root cause: Odoo `ormcache`s config params per-process; fix is `docker compose restart odoo` after any `sapb1.*` change. Verified with a self-cleaning push test. See memory: `odoo-sapb1-config-cache-incident`. |
| Clean up stray test record pushed to production `XEN` | **DONE** (2026-07-09) | Deleted `EmployeesInfo(51)` from XEN + unlinked Odoo record 54 |
| `XENDEMO` clone found stale (39 employees vs. `XEN`'s 47) | **FLAGGED — now a Phase 3 blocker** | Ask B1 admin to refresh the clone **before** #18/#16 (heavier testing) — reconciliation against a stale clone is worthless. Was "consider"; upgraded to blocker. |

## Overall progress

- Total tracked tasks: 26 migration (+ 4 infra tasks) — up from 17 (+4)
  after adding data-reconciliation, cutover runbook/rollback, training,
  monitoring, a SAP B1 write-path security review, Thai translations for
  the custom modules, per-leave-type request timing limits, the 3
  missing leave types, and a leave balance grid/pivot view.
- Done: 11 of 26 migration tasks (#1 discovery, #3 My Team Attendance, #4 My
  Team Leaves v1, #5 attendance inline edit, #6 HR override, #11 approval
  chains v1, #8 multi-language parity, #22 SAP B1 security review, #24 leave
  timing limits, #25 missing leave types, #26 leave balance grid), 3 of 4
  infra tasks. #10 (biometric scanner) in progress — spike, hardening,
  export, and code-remap all shipped; only "Odoo-first" sourcing remains,
  blocked on #18.
- 2026-07-10 (later session): deployed and verified #5 (attendance inline
  edit), which a prior session had staged but couldn't ship without server
  access — see memory `odoo-attendance-inline-edit`.
- Overnight session 2026-07-09/10: shipped 2 working Odoo modules
  (`xeno_attendance`, `xeno_leave`) to the live server, both tested with
  throwaway data and cleaned up — see memory `odoo-attendance-bymonth` and
  `odoo-leave-approval-extensions` for full detail, caveats, and bugs found.

## Review notes (opus)

- **Resequenced Phase 2** (kept stable IDs): pulled #10 biometric import to
  the front as a scoping spike — it's the attendance source of truth and is
  high-uncertainty, so #3/#5/#9/#13 shouldn't be built on assumptions about
  it. Moved #11 (approval chains) ahead of #4 and #6, which depend on it.
- **Adjusted estimates** that looked light given the state-machine/round-trip
  work involved: #11 1-2d→2-3d, #6 1-2d→2-3d, #7 2-3d→3-4d, #13 1d→1-2d.
- **Added missing tasks a real cutover needs**: #18 master/historical data
  migration + reconciliation (the old plan only kept "test data"); #19
  cutover runbook w/ downtime window + rollback; #20 user training / change
  mgmt; #21 post-cutover monitoring; #22 security review of the new SAP B1
  write paths (justified by today's stray-write-to-prod incident).
- **Phase 3 can overlap Phase 2**: parallel-run should start per-feature as
  items land, not big-bang. Upgraded the stale-`XENDEMO`-clone flag to a
  hard blocker for reconciliation/testing.
- **Out-of-scope caveat**: OT tiering is deferred, but it's a soft blocker
  for #13 — the attendance→OT boundary must be decided now so #13 doesn't
  stall or silently absorb OT scope.
- **Biggest open risk**: #10 biometric import is still TBD and is the source
  of truth for every attendance feature; leaving it unscoped while sizing the
  attendance work is the largest schedule risk in the plan.
