# Engineer assignment and work queues - Phase 3A.2

This phase extends `apps.service` immediately after intake. Engineer identity is
the existing User; no Engineer/Employee/Profile entity exists. It adds explicit
assignment, reassignment, unassignment, retained history and operational queues.
Phase 3A.3 extends it with [engineer diagnosis](ENGINEER_DIAGNOSIS.md), and Phase 3A.4
adds [engineer repair](ENGINEER_REPAIR.md). Scheduling remains out of scope.

## Model and database

`ServiceEngineerAssignment` has UUID id, protected ServiceCase/engineer/assigned_by
references, aware assigned_at, nullable ended_at/ended_by, optional end_reason and
note, and created_at/updated_at. A current assignment has `ended_at IS NULL`.
There is no cached current-engineer pointer on ServiceCase.

PostgreSQL enforces one current row per case with a partial unique constraint.
An open period has no ended_by or end_reason. A closed period requires ended_by
and ended_at >= assigned_at; its reason may be empty. All references use PROTECT.
Services validate active actors and timezone-aware, non-future timestamps.
Notes and reasons are trimmed, limited to 2,000 characters, and must never contain
passwords, screen PINs, account credentials or OTPs.

Ordinary save, instance delete and QuerySet delete reject. Assignment identity,
case, assigned_at, assigned_by and note never change through supported operations;
after closure, ended_at, ended_by and end_reason are also immutable. History is not
deleted or rewritten during reassignment, cancellation or upstream deactivation.

`service.0002_engineer_assignment` adds the history model, permission and minimal
ASSIGNED state/constraints. Phase 3A.1 and other historical migrations are unchanged.
The migration does not seed roles or assign capabilities to existing users.

## Eligibility and authorization boundary

Canonical Django permission: **`service.handle_servicecase`** (ServiceCase Meta
permission). A user needs an active organizational assignment and an active role
assignment on that SAME path, whose active Role grants this permission. The role
assignment must belong to the organizational assignment's user. No role name,
primary-posting flag, staff flag, Group or direct native permission grants eligibility.
The permission must exist unambiguously within the service app.

The User, Company, center and center's Region must be active, and hierarchy Company
ownership must agree. Every supplied source-path dimension must be active and
consistent. The case must currently be RECEIVED, ASSIGNED, DIAGNOSING, DIAGNOSED, REPAIRING or REPAIRED. Customer/Device/catalog
activity is an intake prerequisite, not an additional engineer-posting requirement.

These are ServiceCenter duty eligibility rules, using the existing center scope
semantics; they are not a new authorization adapter for arbitrary business records:

| Source scope | Eligible for the case's center |
| --- | --- |
| Company only | Same Company |
| Region only | Center currently in that Region |
| Exact Center, with its matching Region | Yes |
| Exact Center + Department | Yes, all dimensions active and consistent |
| Department only | No |
| Region + Department | No |
| Different Company or sibling Center | No |

No Department name/code implies engineering. Permission from one organizational
path cannot borrow scope from another; inactive granting roles cannot borrow an
active non-granting role's flag. SQL Exists predicates keep those joins coupled.

**Superusers require a real eligible posting to receive work.** Superuser bypass
in the frozen authorization engine remains unchanged; administrative authority
does not automatically make a User an operational engineer. ServiceCase remains
unsupported by that engine. No Phase 1/2 code or auth backend changes are made.

Services validate integrity and actor attribution, not complete caller authorization.
Future request handlers must authorize both mutations and disclosure of query
results separately. Admin continues to use trusted native model permissions.

## Services and lifecycle

Import from `apps.service.engineer_services`:

```python
assign_service_case_engineer(*, service_case, engineer, assigned_by, note="",
                             expected_updated_at=_UNSET)
reassign_service_case_engineer(*, service_case, new_engineer, reassigned_by,
                               reason="", note="",
                               expected_current_assignment_id=_UNSET)
unassign_service_case_engineer(*, service_case, unassigned_by, reason="",
                               expected_current_assignment_id=_UNSET)
```

Each returns the created or ended history row. Omit optional preconditions rather
than importing the private sentinel. Reassignment/unassignment accept the expected
current UUID (or its string); explicit None asserts absence and does not suppress
the requirement for a current assignment. Admin always supplies preconditions.

| Operation | Transition | History |
| --- | --- | --- |
| Assign | RECEIVED -> ASSIGNED | Create one current row; never silently replace |
| Reassign | ASSIGNED -> ASSIGNED | Close old and open new at the same timestamp |
| Unassign | ASSIGNED -> RECEIVED | End current row |
| Cancel | RECEIVED/ASSIGNED -> CANCELLED | End current row, if any |

Same-engineer reassignment and unassignment/reassignment without a current row
reject. New assignment/reassignment validates fresh eligibility under locks.
Phases 3A.3/3A.4 extend the invariant: ASSIGNED, DIAGNOSING, DIAGNOSED, REPAIRING
and REPAIRED have exactly one current assignment; RECEIVED and CANCELLED have none. Reassign and
unassign reject during DIAGNOSING (abandon first) and after DIAGNOSED. The case lock coordinates all writers.
Failure after closure but before insertion/status persistence rolls everything back.

Existing `apps.service.services.cancel_service_case` adds optional `cancelled_by`.
An ASSIGNED case requires an explicit persisted active actor. Cancellation closes
its current assignment with that actor, cancellation reason and the exact same
timestamp as case cancellation. Admin always passes request.user. The old actorless
API remains valid for RECEIVED cases, which have no assignment to attribute, and
idempotent already-cancelled calls. It never invents the creator as cancellation
actor. No new case-level cancellation attribution column is introduced.

All supplied actors must be existing active default-database Users. Unassignment
and cancellation do not require the former engineer or organizational hierarchy
to remain active, so explicit operational recovery stays possible.

Upstream User, organization or RBAC deactivation **does not automatically end**
service assignments or change case status. No frozen lifecycle hooks are modified.
Current/history lookups retain those records. Fresh eligibility and operational
engineer queues exclude invalid paths. Administrators can inspect retained current
assignments and explicitly reassign/unassign/cancel. Restoring a valid path makes
the retained assignment visible in the operational queue again.

Intake metadata and children remain editable only while RECEIVED, preserving the
existing intake policy. ASSIGNED is not a diagnosis or repair state.

## Lock protocol

Inside one default PostgreSQL Read Committed transaction:

1. Actor and candidate User rows FOR SHARE, deduplicated in UUID order. Re-read
   active state. User UPDATE used by frozen org/RBAC writers conflicts with SHARE.
2. For assign/reassign, case Company FOR SHARE. This coordinates all supported
   hierarchy lifecycle/movement without an exclusive company lock.
3. Candidate's organizational paths in that Company FOR SHARE, UUID order.
4. Roles referenced by those paths FOR SHARE, UUID order. Role lifecycle and
   supported permission replacement hold Role UPDATE. No role-assignment row lock
   is acquired before these dependencies.
5. ServiceCase FOR UPDATE; validate current state and fresh SQL eligibility.
6. Current ServiceEngineerAssignment FOR UPDATE; compare precondition and mutate.

Unassignment/cancellation need only actor SHARE -> case UPDATE -> history UPDATE;
they never acquire eligibility dependency locks afterward. Intake metadata/children
already coordinate on the same case row. No current engineer User UPDATE is taken
behind a case lock. Shared actor locks permit independent writes using the same
administrator. The candidate User plus Company/Role locks stabilize all supported
eligibility writers; raw M2M/table mutations are outside this protocol.

Races use actual PostgreSQL blocking observations (`pg_blocking_pids`) on separate
connections. They cover duplicate assignment, assign/cancel in both orders,
reassign/cancel in both orders, competing reassignment, unassign/reassign, stale
requests, User/RBAC/org/center/company changes, scope moves, permission revocation,
upstream history retention and rollback after closing an old assignment.
This is not a universal deadlock guarantee for arbitrary outer transactions with
pre-acquired locks or unsupported raw writers.

## Query APIs and performance

Import from `apps.service.engineer_queries`:

```python
current_engineer_assignment(service_case)       # history row or None
current_engineer(service_case)                  # User or None
engineer_assignment_history(service_case)       # lazy, including ended/inactive
eligible_engineers_for_service_case(service_case)  # lazy User queryset
unassigned_service_cases_for_center(service_center)
assigned_service_cases_for_engineer(engineer)
service_center_engineer_work_queue(service_center)
```

Eligibility uses current SQL state, not cached object attributes. QuerySet
construction is lazy; invalid/unsaved inputs return empty results. Exists avoids
duplicates with multiple granting roles and paths. History orders assigned_at, UUID;
eligible Users order username, UUID. Queues order received_at, job_number, UUID.

The unassigned queue contains RECEIVED cases at that exact center with no current
assignment and active, consistent Company/Center/Region. The engineer queue contains
ASSIGNED, DIAGNOSING, DIAGNOSED, REPAIRING and REPAIRED cases for that current engineer, with a currently valid same-path posting.
The center engineer queue adds an exact center predicate. A User with legitimate
postings in several Companies may have work in each; each case independently requires
its own Company's valid path. These trusted queries are not caller authorization.

Retained assignments that lose eligibility appear in historical/current queries
and Admin, not unassigned queues: they still require explicit operational recovery.
The older intake `active_service_cases_for_service_center` keeps its documented
RECEIVED-only historical semantics; use these new APIs for operational work queues.

Query-count tests measure one SQL statement for each evaluated eligibility,
unassigned queue, engineer queue and current assignment lookup. Related Case
Company/Center/Customer/Device/warranty displays and history actors are joined;
tests access those relations within the same bounds. Construction takes zero
queries. No per-engineer Python checks, materialized candidate ID lists or caching
are introduced. These are representative query-count guarantees, not production
latency/load benchmarks.

## Admin

ServiceCase lists current engineer and status, provides company/center filters,
and supports engineer username/name/email search (including history). A readonly
history table is displayed on each case; dedicated assignment Admin rows are
audit-only with no arbitrary Add/change/delete. The case status and identity
remain readonly. A small separate Admin form performs assign/reassign/unassign
through services using request.user and native change_servicecase permission.

Signed case/current-assignment revisions prevent stale forms and cross-case token
reuse. Assign also passes locked case updated_at, preventing assign/unassign/assign
ABA overwrites. Reassign/unassign pass expected current assignment UUID under lock.
CSRF, active staff access and native permissions remain enforced. Displayed history
is HTML-escaped; generic change log messages contain no notes or device identifiers.
Changelist current-engineer display uses one prefetch rather than an N+1 loop.
The existing cancellation action passes the request actor and case revision.

## Limitations and security

The cross-table status/current-assignment equivalence is enforced by supported
transactional services, not a normal PostgreSQL CHECK constraint or trigger. Raw
update/bulk writes/private persistence can violate it; services fail closed on
detected inconsistent state rather than silently repairing history. Database
constraints still protect uniqueness, temporal/null shape and FK integrity.

No duty-capacity limit, skill matching, dispatch, repair workflow, REST API, final UI
or notifications exist. Upstream deactivation is not automatic service unassignment.
No secrets or real customer/device fixtures are added; `.env` stays ignored.
Free text cannot automatically prove absence of credentials; operators must follow
the prohibition. Native Admin permissions authorize trusted cross-company
administration, not end-user company-scoped access.

## Verification

Baseline: clean commit `2538931`, 726 existing tests. No existing tests or frozen
Phase 1/2 source/migrations were modified.

Full regression: **805 passed, 0 failed, 0 skipped** in **443.168 seconds**.
The 79 new tests comprise 56 model/service/query/Admin tests and 23 real PostgreSQL
concurrency tests. A fresh test database applied the complete migration graph.

`manage.py check`, `makemigrations --check`, `migrate`, `showmigrations service`,
`git diff --check`, status and diff review passed. Both service migrations are
applied, and no additional migration is pending. A bounded changed-file secret
pattern/artifact scan found no candidates. `.env` remains ignored. Changes remain
unstaged and uncommitted; no commit or push was performed.

Phase 3A.4 retains assignments on repair success, unsuccessful completion and
abandonment. REPAIRING cancellation abandons repair and ends the assignment;
REPAIRED cancellation is denied. Reassignment/unassignment remain unavailable
during or after repair. See [ENGINEER_REPAIR.md](ENGINEER_REPAIR.md).

Phase 3A.5 retains the current engineer assignment through QC_PENDING, QC_IN_PROGRESS
and QC_PASSED and includes those states in general engineer queues. QC duty uses
the same posting-scope predicate with `service.perform_quality_control`; it grants
no engineer duty and cannot inspect its own repair. See [QUALITY_CONTROL.md](QUALITY_CONTROL.md).

Phase 3A.6 retains technical assignment responsibility through READY_FOR_DELIVERY.
Successful physical handover ends the current assignment with its actor, delivery
timestamp and delivery reason in the same transaction. DELIVERED/CLOSED leave
active work queues; history is preserved and reassignment remains unavailable.
See [SERVICE_HANDOVER.md](SERVICE_HANDOVER.md).
