# Inventory and immutable stock ledger — Phase 3B.2

Baseline: `b90f236`, 1,395 frozen tests. `apps.inventory` is separate from the
global parts masters because these are company-owned, authorized operational
transactions with permanent stock evidence. No SparePart quantity or duplicate
part definition is introduced.

## Models and stock equation

All six records have UUID IDs and creation/update timestamps; relationships use
PROTECT. Ordinary save/delete APIs are blocked. Domain services own mutations.

| Record | Purpose and principal fields |
| --- | --- |
| InventoryLocation | Company, optional ServiceCenter, company-unique canonical code, name, type, active flag |
| StockPosition | Unique location/part lock anchor; **no balance fields** |
| SerializedStockUnit | Company, part, permanent identifier, registering actor, state, current location and movement |
| StockMovement | Company, part, positive whole quantity, source/destination, kind, actor, posting time, reference, note, UUID command key |
| StockLedgerEntry | Movement, company, part, location, signed quantity delta |
| StockMovementUnit | Immutable movement-to-physical-unit linkage |

There are six concrete inventory models; StockPosition is internal coordination,
not a stock balance or business document. Warehouse and service-center store are
usable location types. Quarantine and defective locations retain physical stock
without making it operationally available. A STORE requires a ServiceCenter;
multiple stores per center are supported. Location company, center, code and type
are immutable; name and lifecycle changes use explicit services. A nonempty
location cannot be deactivated. Organization deactivation masks operational
eligibility without erasing inventory evidence.

`on_hand(location, part) = SUM(StockLedgerEntry.quantity_delta)` in SQL. A receipt
adds one positive entry. A location movement adds equal negative source and
positive destination entries, atomically. No physical quantity is created by a
movement. This foundation uses RECEIPT and MOVE types; formal receiving/transfer
documents are the next checkpoint. There is no generic arbitrary ledger-edit API.

Quantities are discrete whole components, between 1 and 1,000,000,000 per movement;
booleans, fractions, strings and nonpositive values are rejected. Reference text
is required (128 characters); optional notes are bounded to 2,000 characters and
must not contain credentials. Command keys are UUIDs unique within Company;
repeating a command is rejected without adding stock. Inventory mutation requires
active actors, Company/center hierarchy, locations, parts and part categories.

## Serialization and policy integration

NOT_SERIALIZED permits anonymous quantity only. REQUIRED_SERIAL requires exactly
one distinct registered unit per quantity. OPTIONAL_SERIAL allows a mixture;
anonymous and identified quantities are checked separately, so an anonymous move
cannot spend a serial-controlled unit. Units move once, retain identity and have
immutable links to every physical movement. Registration alone is not a receipt.

Identifiers are trimmed, bounded to 128 printable characters, and case/punctuation
preserving. No guessed case-folding or destructive separator normalization is
performed. `(SparePart, identifier)` remains globally reserved across companies,
including after movement. A unit never changes Company or part. Registration is
company-authorized; receipt/movement also validates each unit's Company, part,
state and current location under locks.

The user explicitly approved a narrow integration hardening of
`apps.parts.services.update_spare_part`: a **different** serialization policy is
permitted only if there are no serialized units and no posted stock movements for
that part. Either permanently locks policy, independently of current quantity.
The same-policy update and unrelated metadata updates remain allowed. Drafts and
StockPosition anchors do not count as posted history. A rolled-back posting or
registration does not lock policy. There is no conversion mechanism, synthetic
serial number or rewriting of history. The existing pre-inventory policy-change
baseline test remains untouched.

The guard runs under the existing SparePart exclusive lock. Posting and unit
registration hold a shared lock on that same row through commit, so first stock
posting/registration versus policy change has one serial outcome. If the policy
change commits first, the posting validates the new policy. If stock or a unit
commits first, a different policy is rejected. Draft documents introduced by later
checkpoints must not pre-create authoritative units merely to represent text on
an unposted document.

## Services and query APIs

`apps.inventory.services` exposes:

- `create_location`, `update_location`, `deactivate_location`, `reactivate_location`.
- `register_serialized_unit` (REGISTERED, no stock effect).
- `receive_stock` (REGISTERED units become IN_STOCK, positive receipt).
- `move_stock` (source to destination, units retain IN_STOCK with a new location).

All commands require an explicit actor. Physical posting requires an explicit UUID
idempotency key and reference. Posting is atomic across header, ledger entries,
unit links and current unit position. The private `_post` primitive expects the
shared context and stock-position locks; it is not an externally authorized API.
It will be reused by document workflows rather than duplicating posting logic.

`apps.inventory.queries` exposes `authorized_locations`, `stock_on_hand`,
`available_stock`, `stock_positions_for_location`, `stock_positions_for_part`, and
`serialized_history`, all actor-scoped. Balances include history even when a part
is inactive; availability requires active usable dependencies. Reservation
subtraction is added at its later checkpoint without changing the stock ledger.
The existing query at this checkpoint has no reservations to subtract.

Collection queries are lazy SQL aggregations with deterministic ordering. Part
positions require zero construction queries and one evaluation query; a scalar
balance performs three queries including fresh scope authorization and location
lookup. Serialized history uses select_related to avoid per-row reference loads.
History is visible through authorized movement endpoints; another company's
inventory is never included through a part's global identity.

## Authorization and lock order

Normal Django permissions `manage_inventory`, `view_stock`, `receive_stock` and
`transfer_stock` are evaluated by the **existing** same-path authorization engine.
A center-bound location uses ServiceCenter as its target; a central location uses
Company. A center/region path does not imply company-wide warehouse authority.
Native staff status, Groups and direct Django permissions do not create business
scope. Admin additionally requires its normal Django model permissions. No new
scope adapter or parallel permission engine exists.

Canonical mutation order is User SHARE; Company SHARE; organizational assignments
SHARE; Roles SHARE; PartCategory SHARE; SparePart SHARE; InventoryLocations SHARE
(exclusive for location mutation); StockPositions UPDATE; serialized units UPDATE;
new immutable evidence. UUIDs are sorted within each group. Part category snapshots
are rechecked after locking. Company locking is compatible with frozen hierarchy
writers and role/assignment locks stabilize current authorization. The actor lock
also stabilizes supported role-assignment changes. Different stock positions can
proceed concurrently; the anchor's unique constraint arbitrates first creation.
There is no broad exclusive company lock for ordinary stock movements.

Services changing the part policy retain the existing dependency order and use
SparePart UPDATE. The guard never locks inventory history rows after that lock;
the existence check is stable because supported posting/registration needs SHARE.

## Database and Admin safety

New migrations add company/code uniqueness, valid types/states, positive movement
quantities, distinct source/destination, nonzero entry deltas, command uniqueness,
permanent serialized identity and unique movement-unit links. PostgreSQL composite
foreign keys enforce location/movement/unit Company consistency and entry part
consistency. The location's center Company is checked by a database trigger.

Triggers reject edits/deletes of posted movements, entries and unit links, and
prevent serialized identity deletion/change. Deferred constraint triggers require
complete matching ledger entries, policy-consistent unit counts and matching
current unit movement evidence at transaction end. Stock sufficiency and ordered
concurrency coordination remain service invariants: raw SQL is not a supported
stock-management API. Application services supplement rather than replace these
database protections. Real PostgreSQL tests observe actual blocking waits; they
do not prove formal deadlock freedom.

Admin manages locations through services and signed revisions. Foundation movement
posting signs location revisions and rechecks them under lock. Posted ledger
history is readonly; deletion is disabled. Serialized registration uses its
service; identities cannot later be edited. Scoped choices and querysets prevent
IDOR, while services reauthorize every write. CSRF and template escaping remain
native Django behavior. Actions log lifecycle operations and use fresh revisions.

## Checkpoint boundary

Focused evidence is in `apps/inventory/tests.py`, `test_admin.py` and
`test_concurrency.py`; existing `apps.parts` tests are rerun for the approved guard.
The checkpoint also requires Django checks, migration drift checks, new migration
inspection, and whitespace checks. Formal receipt/transfer documents, reservations,
case parts workflows, adjustments and counting belong to the following mandatory
checkpoints; they are not claimed complete by this ledger foundation.

Checkpoint verification: one focused run passed **170 tests, 0 failed, 0 skipped**
in 75.485 seconds: 73 new inventory tests (including 16 PostgreSQL concurrency
tests) plus all 97 frozen parts tests. Django check, migration drift check and
tracked-diff whitespace check passed. Both new migrations were inspected; fresh
PostgreSQL test-database creation applied them successfully. No baseline test or
historical migration changed. The approved policy integration is the only frozen
domain service change at this checkpoint.
