feat(vhrms): TETRA-1420 defensive Custom Field column sync on migrate #60

Merged
tant merged 1 commit from feat/TETRA-1420-defensive-custom-field-db-sync into develop 2026-04-29 18:17:01 +07:00
Owner

Jira

TETRA-1420 — Defensive sync Custom Fields → DB columns trên mỗi bench migrate

Vấn đề

Operational drift: tabCustom Field row tồn tại nhưng column thật chưa được tạo trên tab* table. Xảy ra khi:

  • bench migrate bị interrupted giữa Custom Field insert và ALTER TABLE step.
  • Custom Field được insert qua fixtures / raw SQL bỏ qua save() flow → on_update không fire.
  • Frappe upgrade làm desync giữa doc và schema.

Symptom local repro 2026-04-22: seed_employees Phase A fail với MySQLdb.OperationalError (1054, Unknown column custom_gross_salary in INSERT INTO), rồi custom_is_pregnant, custom_pregnancy_status. Cùng class risk áp dụng cho Salary Component.custom_is_pay_insurance (TETRA-1407, v0.6.0).

Cách tiếp cận

Thêm sync_custom_field_columns() vào vhrms/setup/custom_fields.py, gọi từ cả after_install + after_migrate ngay sau create_custom_fields(...):

  1. Walk get_custom_fields() (chỉ vhrms-declared fields, không police custom fields của app khác).
  2. Cho mỗi field có tabCustom Field row tồn tại + DB column thiếu → gom theo doctype, gọi frappe.db.updatedb(doctype) 1 lần per doctype.
  3. updatedb chính là API mà Frappe core Custom Field.on_update dùng — không raw ALTER TABLE, tôn trọng type mapping + default value + metadata tracking (AC #5).
  4. Idempotent: column đã có → no-op (AC #4).

Edge cases handled

  • Singleton DocType: frappe.db.has_column raise TableMissingError cho Single doctype (vd Vietnam HRMS Settings, Payroll Settings) → fallback frappe.get_meta(doctype).has_field(). Singleton lưu giá trị ở tabSingles nên không có DDL drift cần khôi phục.
  • Layout fieldtypes (Section Break, Column Break, Tab Break, Fold, HTML, Heading, Button) — không project ra DB column, skip trước khi gọi has_column.
  • DocType chưa cài (vd Asset Assignment) — skip gracefully, không raise.
  • Custom Field row chưa tồn tại — defensive sync no-op cho field đó (create_custom_fields ở step trước đã handle).

AC checklist

  • Helper sync_custom_field_columns()vhrms/setup/custom_fields.py (AC #1).
  • Wired vào after_migrate + after_install ngay sau create_custom_fields() (AC #2).
  • Idempotent — chạy lần 2 không sinh ALTER thừa (AC #4, có test guard).
  • KHÔNG dùng raw ALTER TABLE — đi qua frappe.db.updatedb (AC #5).
  • Repro test (AC #3 — drop column → bench migrate → column tạo lại): cần verify trên hr.staging.tetra.vn sau merge.

Test plan

Unit test vhrms/tests/test_ensure_columns.py (7 cases, all passing):

  • test_missing_column_triggers_updatedb — Custom Field row + column thiếu → updatedb fires.
  • test_idempotent_when_columns_present — column đã có → KHÔNG gọi updatedb.
  • test_layout_fieldtypes_skipped — Section Break / Column Break / Tab Break không có DB column → bỏ qua.
  • test_skip_when_custom_field_row_missingtabCustom Field row chưa tồn tại → defensive sync no-op cho field đó.
  • test_singleton_doctype_falls_back_to_metahas_column raise TableMissingError → fallback meta.has_field, KHÔNG re-raise.
  • test_doctype_not_installed_is_skipped — DocType không có trên site → skip, không raise.
  • test_multiple_missing_columns_one_updatedb_per_doctype — N column drift trên cùng doctype → vẫn chỉ 1 ALTER pass.
cd /home/tan/work/vhrms && uv run python -m unittest vhrms.vhrms.tests.test_ensure_columns -v
# Ran 7 tests in 0.021s — OK

Deviations from Jira description

Jira pseudocode references frappe.custom.doctype.custom_field.custom_field.create_custom_field_in_db — function này không tồn tại trong Frappe v16 vendor checkout (vendor/frappe/.../custom_field.py chỉ có create_custom_field, create_custom_fields, create_custom_field_if_values_exist). Column creation được trigger qua frappe.db.updatedb(doctype) trong Custom Field.on_update hook (line 216) — và đó cũng là API mà create_custom_fields (line 379) gọi sau khi insert tất cả Custom Field rows. Ticket spirit là "đi qua Frappe API thay vì raw ALTER" — updatedb đáp ứng đúng intent.

Version

Bump 0.9.4 → 0.9.5 (MINOR — feature add per semver).

Files

  • vhrms/__init__.py — version bump
  • vhrms/setup/custom_fields.py — thêm sync_custom_field_columns + _column_exists + _LAYOUT_FIELDTYPES
  • vhrms/setup/__init__.py — wire vào after_install + after_migrate, expose qua __all__
  • vhrms/tests/test_ensure_columns.py — 7 test cases
  • CHANGELOG.md — Unreleased entry
## Jira [TETRA-1420](https://tetravn.atlassian.net/browse/TETRA-1420) — Defensive sync Custom Fields → DB columns trên mỗi `bench migrate` ## Vấn đề Operational drift: `tabCustom Field` row tồn tại nhưng column thật chưa được tạo trên `tab*` table. Xảy ra khi: * `bench migrate` bị interrupted giữa Custom Field insert và ALTER TABLE step. * Custom Field được insert qua fixtures / raw SQL bỏ qua save() flow → `on_update` không fire. * Frappe upgrade làm desync giữa doc và schema. Symptom local repro 2026-04-22: `seed_employees` Phase A fail với `MySQLdb.OperationalError (1054, Unknown column custom_gross_salary in INSERT INTO)`, rồi `custom_is_pregnant`, `custom_pregnancy_status`. Cùng class risk áp dụng cho `Salary Component.custom_is_pay_insurance` (TETRA-1407, v0.6.0). ## Cách tiếp cận Thêm `sync_custom_field_columns()` vào `vhrms/setup/custom_fields.py`, gọi từ cả `after_install` + `after_migrate` ngay sau `create_custom_fields(...)`: 1. Walk `get_custom_fields()` (chỉ vhrms-declared fields, không police custom fields của app khác). 2. Cho mỗi field có `tabCustom Field` row tồn tại + DB column thiếu → gom theo doctype, gọi `frappe.db.updatedb(doctype)` 1 lần per doctype. 3. `updatedb` chính là API mà Frappe core `Custom Field.on_update` dùng — không raw ALTER TABLE, tôn trọng type mapping + default value + metadata tracking (AC #5). 4. Idempotent: column đã có → no-op (AC #4). ## Edge cases handled * **Singleton DocType**: `frappe.db.has_column` raise `TableMissingError` cho Single doctype (vd Vietnam HRMS Settings, Payroll Settings) → fallback `frappe.get_meta(doctype).has_field()`. Singleton lưu giá trị ở `tabSingles` nên không có DDL drift cần khôi phục. * **Layout fieldtypes** (Section Break, Column Break, Tab Break, Fold, HTML, Heading, Button) — không project ra DB column, skip trước khi gọi `has_column`. * **DocType chưa cài** (vd `Asset Assignment`) — skip gracefully, không raise. * **Custom Field row chưa tồn tại** — defensive sync no-op cho field đó (`create_custom_fields` ở step trước đã handle). ## AC checklist * [x] Helper `sync_custom_field_columns()` ở `vhrms/setup/custom_fields.py` (AC #1). * [x] Wired vào `after_migrate` + `after_install` ngay sau `create_custom_fields()` (AC #2). * [x] Idempotent — chạy lần 2 không sinh ALTER thừa (AC #4, có test guard). * [x] KHÔNG dùng raw ALTER TABLE — đi qua `frappe.db.updatedb` (AC #5). * [ ] Repro test (AC #3 — drop column → bench migrate → column tạo lại): cần verify trên hr.staging.tetra.vn sau merge. ## Test plan Unit test `vhrms/tests/test_ensure_columns.py` (7 cases, all passing): * `test_missing_column_triggers_updatedb` — Custom Field row + column thiếu → `updatedb` fires. * `test_idempotent_when_columns_present` — column đã có → KHÔNG gọi `updatedb`. * `test_layout_fieldtypes_skipped` — Section Break / Column Break / Tab Break không có DB column → bỏ qua. * `test_skip_when_custom_field_row_missing` — `tabCustom Field` row chưa tồn tại → defensive sync no-op cho field đó. * `test_singleton_doctype_falls_back_to_meta` — `has_column` raise TableMissingError → fallback `meta.has_field`, KHÔNG re-raise. * `test_doctype_not_installed_is_skipped` — DocType không có trên site → skip, không raise. * `test_multiple_missing_columns_one_updatedb_per_doctype` — N column drift trên cùng doctype → vẫn chỉ 1 ALTER pass. ```bash cd /home/tan/work/vhrms && uv run python -m unittest vhrms.vhrms.tests.test_ensure_columns -v # Ran 7 tests in 0.021s — OK ``` ## Deviations from Jira description Jira pseudocode references `frappe.custom.doctype.custom_field.custom_field.create_custom_field_in_db` — function này **không tồn tại** trong Frappe v16 vendor checkout (`vendor/frappe/.../custom_field.py` chỉ có `create_custom_field`, `create_custom_fields`, `create_custom_field_if_values_exist`). Column creation được trigger qua `frappe.db.updatedb(doctype)` trong `Custom Field.on_update` hook (line 216) — và đó cũng là API mà `create_custom_fields` (line 379) gọi sau khi insert tất cả Custom Field rows. Ticket spirit là "đi qua Frappe API thay vì raw ALTER" — `updatedb` đáp ứng đúng intent. ## Version Bump 0.9.4 → 0.9.5 (MINOR — feature add per semver). ## Files * `vhrms/__init__.py` — version bump * `vhrms/setup/custom_fields.py` — thêm `sync_custom_field_columns` + `_column_exists` + `_LAYOUT_FIELDTYPES` * `vhrms/setup/__init__.py` — wire vào after_install + after_migrate, expose qua `__all__` * `vhrms/tests/test_ensure_columns.py` — 7 test cases * `CHANGELOG.md` — Unreleased entry
Walk vhrms-declared custom fields after `create_custom_fields()` and force
`frappe.db.updatedb(doctype)` for any DocType where `tabCustom Field` rows
exist but the underlying DB column is missing. Idempotent — happy path is
a no-op. Singleton DocType guard via `frappe.get_meta(...).has_field()`
fallback when `has_column` raises `TableMissingError`.

Wired into both `after_install` and `after_migrate` hooks ngay sau
`create_custom_fields(...)` để mọi downstream patch / seeder thấy schema
đã consistent. Bump version 0.9.4 -> 0.9.5.

Refs: TETRA-1420
tant merged commit d27f27aef3 into develop 2026-04-29 18:17:01 +07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
tant/vhrms!60
No description provided.