# Service parts foundation — Phase 3B.1

`apps.parts` owns global definitions of service parts. It depends on the existing
catalog for product identity and reuses the abstract organization timestamp base
and shared-row lock helper. It does not alter organization, authorization,
catalog ownership, device identity, or any Phase 3A workflow.

## Records

All three records have UUID v4 `id`, `created_at` and `updated_at`. All references
below use `PROTECT`. No business records are seeded by the migration.

| Record | Other fields |
| --- | --- |
| PartCategory | `code`, `name`, `description`, `is_active` |
| SparePart | `part_code`, `name`, `description`, required `category`, `manufacturer_part_number`, required `serialization_policy`, `is_active` |
| SparePartCompatibility | required `spare_part`, nullable `product_model`, nullable `product_variant`, `is_active` |

Category code and internal part code are independently globally unique, trimmed,
uppercased, at most 64 characters, start with an ASCII letter/digit, and contain
only ASCII letters/digits/underscores/hyphens. Codes remain reserved while inactive.
Codes and UUIDs cannot be changed through the supported services or Admin.
Names are required, trimmed and at most 200 characters. Optional descriptions are
trimmed and limited to 4,000 characters. Manufacturer part number is optional,
trimmed, case-preserving, at most 128 characters, and not a unique internal identity.
The part's category can be explicitly corrected through the update service.
All newly created masters are active; subsequent lifecycle changes are explicit.

## Serialization policy

| Value | Meaning for future inventory |
| --- | --- |
| NOT_SERIALIZED | Quantity-based inventory |
| OPTIONAL_SERIAL | Individual serial tracking permitted, not required |
| REQUIRED_SERIAL | Every physical unit must have an individual identifier before serialized inventory operations |

There is no implicit policy default: the creator chooses. Policy may be explicitly
edited during this master-only phase. Future inventory must introduce its own
policy-change protections once physical units or transactions exist. This is
independent of the frozen DeviceIdentificationPolicy. No physical stock-unit,
serial value, quantity or transaction is created here.

Phase 3B integration hardening now enforces the explicitly approved policy lock:
`update_spare_part` rejects a different policy once any serialized unit or posted
inventory movement exists for the part, permanently even after stock reaches zero.
Drafts without authoritative units or posted history do not lock policy. The check
runs under the existing part lock, coordinated with inventory posting/registration.
Same-policy and other metadata updates remain supported. No policy conversion,
synthetic identifier, or historical rewrite is introduced. See
[inventory ledger](INVENTORY_LEDGER.md). This is a narrow integration guard, not a
redesign of the global part domain; pre-inventory policy-change tests are unchanged.

## Compatibility semantics

Each mapping has **exactly one** endpoint: ProductModel OR ProductVariant. For a
variant mapping the parent model is derived from the existing catalog FK, so a
mismatched redundant model/variant pair cannot be represented.

| Configuration for a part and model | Model-only selection | Variant selection |
| --- | --- | --- |
| Active model-wide mapping | Included | Included for all valid variants of that model, including future variants |
| Active exact variant mappings only | Excluded | Included only for explicitly mapped variants |
| No active mapping | Excluded | Excluded |

Mapping every current variant **never** implies model-wide compatibility. A part
can be model-wide for one model and restricted to variants of another. It cannot
have both active modes for the same parent model. An empty replacement explicitly
removes all effective compatibility. Duplicate input selections are deduplicated.

Replacement retires unselected rows (`is_active=False`), creates missing rows,
and reactivates previously retained rows when selected again. UUIDs, endpoint
references and creation timestamps are retained; mappings are never deleted.
Retired rows can coexist with a different current mode without contradiction.
This retains mapping identities, not a complete chronological journal of every
configuration interval; no future operational evidence should use it as one.

## Lifecycle and operational eligibility

Part/category lifecycle operations change only the explicitly selected master's
flag. Category deactivation masks its parts without overwriting individual part
flags. Category reactivation restores eligibility for otherwise active parts;
it does not reactivate individually deactivated parts. Part reactivation under
an inactive category remains operationally unavailable.

As with existing service taxonomy configuration, inactive masters/catalog records
may be configured. Configuration is distinct from operational selection. A
deactivation racing with configuration can leave a retained mapping, but cannot
make an inactive dependency eligible. Neither lifecycle nor replacement deletes
retained mapping rows.

Operational compatibility requires the mapping and part to be active, the part
category to be active, and the ProductModel, Brand and ProductCategory to be
active. Variant selection additionally requires the exact ProductVariant active.
All checks use fresh database state, never cached attributes on supplied objects.
Frozen catalog cascades are unchanged: model deactivation deactivates variants;
model reactivation does not automatically reactivate those variants. Explicitly
reactivating valid catalog dependencies restores retained compatibility.

## Public services

Import from `apps.parts.services`. References must be persisted model instances
from the default database; unsaved, wrong-type and missing references are rejected.

```python
create_part_category(*, code, name, description="")
update_part_category(*, part_category, name=UNSET, description=UNSET,
                     expected_revision=None)
deactivate_part_category(*, part_category, expected_revision=None)
reactivate_part_category(*, part_category, expected_revision=None)

create_spare_part(*, part_code, name, category, serialization_policy,
                 description="", manufacturer_part_number="",
                 product_models=(), product_variants=())
update_spare_part(*, spare_part, name=UNSET, category=UNSET,
                 serialization_policy=UNSET, description=UNSET,
                 manufacturer_part_number=UNSET, expected_revision=None,
                 product_models=UNSET, product_variants=UNSET)
deactivate_spare_part(*, spare_part, expected_revision=None)
reactivate_spare_part(*, spare_part, expected_revision=None)

set_spare_part_compatibility(*, spare_part, product_models, product_variants,
                            expected_revision=None)
revision(record)
```

`UNSET` means preserve the current field; it is an internal default, not a value
clients need to supply. Both compatibility collections must be provided together
to replace configuration. Omitting both from update preserves configuration.
Create/update can change metadata and configuration atomically in one operation,
which Admin uses to preserve lock ordering and complete rollback.

Services return fresh persisted master objects. Mutated fields on input instances
are ignored unless explicitly supplied as parameters. Each successful mutation
refreshes `updated_at`, including compatibility and lifecycle operations. Optional
`expected_revision` is `revision(record)` (the timestamp's ISO representation),
checked under the exclusive target lock. Use it for read-edit-save workflows;
without it concurrent explicit replacements serialize, with the later writer's
whole configuration winning. Deactivate/reactivate cycles invalidate old revisions.

Ordinary validation failures raise `ValidationError`. Concurrent unique-code
creations are finally arbitrated by the database and can raise `IntegrityError`;
both are handled by Admin after rollback. Missing references and dependency
corrections fail safely and require reload/retry.

## Transactions, constraints and lock order

Each mutation is atomic, including full replacement. No lifecycle logic lives in
Admin and no lifecycle operation uses a bulk update. Compatibility replacement
and all target mutations coordinate on the spare-part row.

Locks are acquired in this order, sorting UUIDs within each group:

1. Referenced catalog Brands, shared.
2. Referenced catalog ProductCategories, shared.
3. Referenced ProductModels, shared (including parents of requested variants).
4. Referenced ProductVariants, shared.
5. Current and proposed PartCategory rows, shared.
6. The SparePart, exclusive.
7. Its existing compatibility rows, exclusive.

Only operations supplying compatibility take catalog locks. Category mutation
takes an exclusive category lock without locking or rewriting children. Part
lifecycle takes category then part locks. Create takes dependency locks before
insertion; uniqueness handles concurrent creates. Removed mappings do not need
catalog locks because their endpoints are retained and made non-operational.

Catalog parent and current part-category snapshots are rechecked after locking;
a concurrent correction causes rejection rather than acquiring a new ancestor
lock out of order. Shared dependency locks permit unrelated part mutations to
proceed concurrently and conflict with frozen catalog lifecycle writes. There are
no broad exclusive brand/company locks, callbacks or changes to frozen services.

Database constraints enforce canonical code format, global code uniqueness,
nonempty space-trimmed names, allowed serialization values, exactly one mapping
endpoint, and unique `(spare_part, product_model)` / `(spare_part, product_variant)`
even for retired mappings. Exactly-one-target plus the existing variant's parent
FK removes the need for a redundant cross-table equality constraint. FKs protect
referenced rows. Mapping uniqueness applies to each non-NULL endpoint; it does
not depend on treating NULLs as equal.

Active model-wide versus exact-variant contradictions span rows/tables and are
enforced by the locked replacement service, not a database check constraint.
Code immutability, endpoint immutability, retained deletion policy, full Unicode
trimming, text type/length validation and revision preconditions are also service
rules. Public model `save()`, instance `delete()` and queryset `delete()` reject
arbitrary mutation. As in existing domains, privileged raw SQL, bulk ORM writes
and private `_persist()` calls are outside the supported boundary; database checks
alone do not enforce every domain invariant. Do not use these to manage masters.

## Public queries and performance

Import from `apps.parts.queries`:

```python
active_part_categories()
active_spare_parts()
search_spare_parts(term)
spare_part_is_compatible_with_model(*, spare_part, product_model)
spare_part_is_compatible_with_variant(*, spare_part, product_variant)
compatible_spare_parts_for_model(product_model)
compatible_spare_parts_for_variant(product_variant)
```

Collection helpers return lazy querysets. Boolean helpers execute one SQL EXISTS
query. Each collection evaluation executes one SQL query, including access to the
returned part's category via `select_related`. SQL EXISTS/subqueries avoid duplicate
parts and Python filtering. Parts sort by unique `part_code`; categories by unique
`code`. Tests measure zero queries to construct collections and one query to
evaluate or decide, including an eight-part fixture with no query-count growth.

Active master/search helpers do not require a compatibility mapping; they are
master searches, not device selection. Search matches code, name or manufacturer
part number with ORM-bound case-insensitive substring matching. Missing saved
targets produce no match; wrong-type/unsaved references raise validation errors.
Already evaluated querysets retain Django's normal result cache; callers should
construct a new query for a fresh decision.

## Administration and authorization

Native Django Admin manages PartCategory and SparePart. Compatibility is edited
through the SparePart form's model/variant choices and inspected through a readonly
mapping Admin. Codes are editable only at creation. UUIDs, lifecycle fields and
timestamps are readonly; delete actions and endpoints are disabled. Explicit
deactivate/reactivate actions call services and retain configuration. Metadata and
compatibility saves call a single atomic service, not sequential inverted locks.

Edit forms sign model identity, UUID and the displayed master revision. Missing,
tampered, cross-record and stale tokens are rejected. The signed precondition is
checked again under lock, closing the form-validation/write race. Lifecycle
actions apply explicit current-state commands with per-record revision checks;
they are not snapshots of a previously viewed changelist. Django CSRF, escaped
templates, authentication and native model permissions remain in force. Normal
Admin logging records additions, edits and lifecycle actions.

These are global masters, so native Django permissions govern administration;
staff status alone is insufficient. No Company scope, custom RBAC adapter or new
authorization engine is added. Domain services are trusted internal write APIs,
not independently authenticated HTTP endpoints. Operational inventory permission
and scope checks belong to future inventory workflows.

## Explicit boundary and verification

RepairAction and SparePart are independent. No repair-action-to-part inference,
BOM, requirement, request, reservation, issue, consumption, return, stock unit,
inventory balance/ledger, warehouse, pricing, procurement, supplier, invoice or
Phase 3B.2 placeholder exists. Phase 3A remains frozen. Future repair/parts workflows
must add explicit transactional records and their own authorization and history.

Tests live in `apps/parts/tests.py`, `test_admin.py` and `test_concurrency.py`.
They cover the confirmed model-only exclusion rule, exact-variant union,
retention/reactivation, invalid database writes, rollback, Admin/CSRF/permissions,
signed stale edits and real PostgreSQL sessions with observed blocking waits.
Concurrency covers both orderings of configuration versus deactivation, concurrent
replacement/creation, category correction and independent-part parallelism.
The new initial migration creates only parts tables and constraints, depends on
the existing catalog migration, and is exercised on freshly created PostgreSQL
test databases by normal `manage.py test --noinput` runs.
