Factorizer/Cluster ERP WikiCluster ERP Türkçe
Home / Platform

Explanation

Soft delete and deactivation

How Cluster ERP removes records from use without deleting them, records who and why, keeps unique values free, and where rows are still physically removed.

In Cluster ERP a "delete" from the user's side deactivates a record: the row stays and gets a deactivation time, actor, and reason. This page explains the core mechanism in core/models.py, core/change_history.py, core/bulk_operations.py, and the start-up column sweep, and it lists the places that still remove rows. Reactivation, the reason catalogue, and the retention purge belong to the records module; see records.

In short

  • deleted_at IS NULL means active; a value means deactivated (src/cluster_erp/core/models.py:49-57).
  • A deactivation records deleted_by, deactivation_reason, and deactivation_note on the row (src/cluster_erp/core/models.py:58-72, src/cluster_erp/core/change_history.py:242-259).
  • The reason and note travel as the request headers X-Deact-Reason and X-Deact-Note (src/cluster_erp/server/dependencies.py:64-72).
  • Bulk deactivation loads the objects and sets deleted_at through the ORM, so the change history and the reason stamp apply to every row (src/cluster_erp/core/bulk_operations.py:20-55).
  • Unique constraints apply only to live rows, through partial indexes with WHERE deleted_at IS NULL, for example src/cluster_erp/core/currencies.py:61-74.

How it works

The deactivation flow

The sequence diagram shows one deactivation request.

sequenceDiagram
 participant C as Client
 participant D as get_db_session
 participant S as Service
 participant H as before_flush hook
 participant DB as PostgreSQL
 C->>D: DELETE with X-Deact-Reason, X-Deact-Note
 D->>D: set_deactivation_context(reason, note)
 D->>S: session
 S->>S: obj.deleted_at = now
 S->>H: flush
 H->>H: stamp deleted_by, deactivation_reason, deactivation_note
 H->>DB: UPDATE row and INSERT core_change_history (action deactivate)

In prose:

  1. The session dependency reads the two headers, URL-decodes them, and stores them in the request context (src/cluster_erp/server/dependencies.py:64-72).
  2. A service sets deleted_at. Examples: AttachmentService.delete and NoteService.delete (src/cluster_erp/core/attachments.py:155-161, src/cluster_erp/core/notes.py:94-100).
  3. At flush, the change-history hook detects the transition from NULL to a value (src/cluster_erp/core/change_history.py:229-239). It stamps deleted_by with the actor, and deactivation_reason and deactivation_note from the context, when they are empty (src/cluster_erp/core/change_history.py:242-259, src/cluster_erp/core/change_history.py:336-337).
  4. The hook classifies the change as deactivate; a transition back to NULL is activate (src/cluster_erp/core/change_history.py:197-213). See change history.

Bulk deactivation

bulk_soft_delete(session, model_class, ids, deleted_by=None) selects the live rows among ids, sets deleted_at (and deleted_by when given) on each, flushes, and returns the count (src/cluster_erp/core/bulk_operations.py:20-55). Its docstring states why it does not issue one UPDATE: a Core update bypasses the before_flush hook, so the history and the reason stamp would be lost (src/cluster_erp/core/bulk_operations.py:26-34). 54 files outside core/bulk_operations.py call it (counted with grep -rln bulk_soft_delete). BulkDeleteRequest and BulkDeleteResponse are the shared request and response schemas (src/cluster_erp/core/bulk_operations.py:12-17).

Deactivation columns on every table

At start-up the server runs an idempotent SQL block that adds deactivation_reason VARCHAR(64) and deactivation_note TEXT to every public table that has a deleted_at column but lacks them (src/cluster_erp/server/app.py:1111-1149, src/cluster_erp/server/app.py:1152-1166). The block takes a transaction-level advisory lock so that two workers starting together do not deadlock (src/cluster_erp/server/app.py:1111-1115, src/cluster_erp/server/app.py:1121).

Live-row uniqueness

A deactivated row must not block its unique value. Models declare unique indexes with postgresql_where=text("deleted_at IS NULL"). 120 lines in src/cluster_erp/modules and src/cluster_erp/core contain both postgresql_where and deleted_at IS NULL (counted with grep). Example: uq_core_currencies_code_live (src/cluster_erp/core/currencies.py:59-74). When an insert still collides, the error handler answers DB_UNIQUE_VIOLATION with HTTP 409 and names the constraint (src/cluster_erp/server/error_handler.py:352-390).

Reactivation and passive lists

The records module serves generic endpoints that list deactivated records of an entity, resolve labels, and reactivate a record (src/cluster_erp/modules/records/lifecycle_routes.py:114, src/cluster_erp/modules/records/lifecycle_routes.py:175, src/cluster_erp/modules/records/lifecycle_routes.py:224). The server builds the map from (module, entity) to model at start-up (src/cluster_erp/server/app.py:1090-1099).

Physical removal

The retention purge in the records module removes deactivated rows and change-history rows older than the configured window. Its docstring calls it "the ONLY place a record is ever physically deleted" (src/cluster_erp/modules/records/retention/purge.py:1-7). The server runs it daily (src/cluster_erp/server/app.py:404-416). See scheduled jobs and retention.

orm_delete_where(session, model_class, *where) deletes rows through the ORM, so each removal still gets a delete change-history row. Its docstring limits it to child rows whose recoverable unit is the parent record (src/cluster_erp/core/bulk_operations.py:58-75). It is called from calendars, costing, and production (src/cluster_erp/modules/calendars/services.py:186, src/cluster_erp/modules/costing/costing_analyses/services.py:318, src/cluster_erp/modules/production/work_centers/services.py:159).

Why it is built this way

The project's written engineering rules forbid hard delete: a delete is a removal from use with a reason, and history is kept. The comment in src/cluster_erp/core/models.py:49-53 restates this for the columns. The bulk_soft_delete docstring gives the reason for going through the ORM (src/cluster_erp/core/bulk_operations.py:26-34).

Rules and boundaries

  • A deactivation reason is a code from the catalogue in core_deactivation_reasons; a free-text note is required only when the reason demands it (src/cluster_erp/core/models.py:62-64). The catalogue is documented on records.
  • The change history also treats an is_active flag going from true to false as a deactivation when deleted_at did not move (src/cluster_erp/core/change_history.py:205-212).

Interaction with other parts

Limits and open points

  • Whether every module service filters deleted_at IS NULL in its default lists was not audited for this page.
  • Whether the rows removed by sa.delete on analytics summary cells and intranet analytics events count as records under the rule is not determined from code.

Terms introduced

Term Code identifier Definition Turkish term Source
deactivation deleted_at Removing a record from use by setting its deactivation time; the row stays. kullanımdan kaldırma src/cluster_erp/core/models.py:49-57
deactivation reason deactivation_reason A catalogue entry that says why a record was removed from use. devre dışı bırakma nedeni src/cluster_erp/core/models.py:62-68
live-row uniqueness postgresql_where partial index A unique index that ignores deactivated rows. canlı satır benzersizliği src/cluster_erp/core/currencies.py:61-74

Verified against commit 92f8051 on 2026-11-13.

Verified against commit 92f8051 · 2026-11-13