# `PhoenixKitManufacturing.Machines`
[🔗](https://github.com/BeamLabEU/phoenix_kit_manufacturing/blob/0.4.1/lib/phoenix_kit_manufacturing/machines.ex#L1)

Context module for managing machines, plus thin read wrappers over the
`machine_type` / `operation` `phoenix_kit_entities` records used to tag
and describe them.

Machines and types have a many-to-many relationship via a join table, so
a machine can be tagged with several types at once (e.g. both "CNC" and
"Milling"). Machines use hard-delete (simple reference data); machine
type/operation CRUD moved to the generic entities admin UI
(`/admin/entities/machine_type/data`, `/admin/entities/operation/data`)
as part of the entities migration — see
`dev_docs/ENTITIES_MIGRATION_SPEC.md`. This module keeps only the
read-side other module code (pickers, the machine form,
`Web.DashboardLive`'s stat tile) still needs, resolved through
`PhoenixKitManufacturing.EntitiesRegistry`.

## Activity logging

Every mutating function accepts `opts \ []`. When `actor_uuid:` is
present in opts, the mutation is logged via `PhoenixKit.Activity.log/1`
under the `"manufacturing"` module key. Logging failures never crash the
primary operation — both `PhoenixKit.Activity.log/1` and this module's
`maybe_log_activity/5` rescue internally, so on a host that has not yet
run core's activity migration the mutation still succeeds and the failure
degrades to a `Logger.warning`.

## Usage from IEx

    alias PhoenixKitManufacturing.Machines

    {:ok, mill} = Machines.create_machine(%{name: "CNC-01", code: "M-001"})
    [%{uuid: type_uuid} | _] = Machines.list_machine_types(status: "published")
    {:ok, _} = Machines.sync_machine_types(mill.uuid, [type_uuid])

    Machines.list_machines(type_uuid: type_uuid)
    Machines.count_machines()

# `list_machine_types_opts`

```elixir
@type list_machine_types_opts() :: [
  locale: String.t() | nil,
  status: String.t() | nil
]
```

# `list_machines_opts`

```elixir
@type list_machines_opts() :: [status: String.t(), type_uuid: String.t()]
```

# `opts`

```elixir
@type opts() :: keyword()
```

# `status_filter`

```elixir
@type status_filter() :: [{:status, String.t()}]
```

# `change_machine`

```elixir
@spec change_machine(PhoenixKitManufacturing.Schemas.Machine.t(), map()) ::
  Ecto.Changeset.t()
```

Returns an `Ecto.Changeset` for tracking machine changes.

# `count_machine_types`

```elixir
@spec count_machine_types(status_filter()) :: non_neg_integer()
```

Returns the total count of machine types.

A thin `EntitiesRegistry` wrapper kept alongside `list_machine_types/1`
(rather than removed with the rest of the machine-type CRUD) because
`Web.DashboardLive`'s stat tile still calls it — nothing in the entities
migration replaces that caller.

## Options

  * `:status` — filter by exact status, same as `list_machine_types/1`.

# `count_machines`

```elixir
@spec count_machines(status_filter()) :: non_neg_integer()
```

Returns the total count of machines.

# `count_machines_with_operation`

```elixir
@spec count_machines_with_operation(String.t()) :: non_neg_integer()
```

Same as `count_machines_with_type/1`, for the `operation` entity.

# `count_machines_with_type`

```elixir
@spec count_machines_with_type(String.t()) :: non_neg_integer()
```

Returns the number of machines with `type_uuid` currently assigned.

Intended as the host app's `reverse_references` `count_fn` for the
`machine_type` entity (an advisory "used by N machines" hint on the
entities trash UI) — see `dev_docs/IMPLEMENTATION_PLAN_E.md`'s ANDI
follow-up task. Not called anywhere in this module itself.

# `create_machine`

```elixir
@spec create_machine(map(), opts()) ::
  {:ok, PhoenixKitManufacturing.Schemas.Machine.t()}
  | {:error, Ecto.Changeset.t()}
```

Creates a machine.

Required: `:name`. Optional: `:code`, `:manufacturer`, `:serial_number`,
`:description`, `:location_note`, `:status`, `:data`, `:metadata`.

# `delete_machine`

```elixir
@spec delete_machine(PhoenixKitManufacturing.Schemas.Machine.t(), opts()) ::
  {:ok, PhoenixKitManufacturing.Schemas.Machine.t()}
  | {:error, Ecto.Changeset.t()}
```

Hard-deletes a machine. Cascades to type assignments.

# `get_machine`

```elixir
@spec get_machine(String.t()) :: PhoenixKitManufacturing.Schemas.Machine.t() | nil
```

Fetches a machine by UUID. Returns `nil` if not found.

Does not preload linked machine types — see `list_machines/1` moduledoc.

# `has_operation?`

```elixir
@spec has_operation?(String.t(), String.t()) :: boolean()
```

Returns true if the machine has the given operation linked.

# `has_type?`

```elixir
@spec has_type?(String.t(), String.t()) :: boolean()
```

Returns true if the machine has the given type assigned.

# `linked_operation_overrides`

```elixir
@spec linked_operation_overrides(String.t()) :: %{
  required(String.t()) =&gt; integer() | nil
}
```

Returns a `%{operation_uuid => time_norm_seconds}` map of a machine's
current operation links, for initializing the operations section of the
machine form.

The map's *keys* are the full linked-operation set — every linked
operation appears, whether or not it carries an override — which is
exactly the shape `sync_machine_operations/3` needs for its "before"
side of the diff.

# `linked_type_uuids`

```elixir
@spec linked_type_uuids(String.t()) :: [String.t()]
```

Returns a list of type UUIDs linked to a machine.

# `linked_type_uuids_by_machine`

```elixir
@spec linked_type_uuids_by_machine([String.t()]) :: %{
  required(String.t()) =&gt; [String.t()]
}
```

Batch-resolves linked machine-type UUIDs for a list of machines in a
single query, e.g. `%{machine_uuid => [type_uuid, ...]}`. Machines with
no linked types are absent from the result map (not present with `[]`).

Used in place of the `preload: :machine_types` removed when
`machine_type_uuid` became a soft reference (see
`Schemas.MachineTypeAssignment` moduledoc) — callers resolve type names
from the returned UUIDs themselves (e.g. `list_machine_types/1`).

# `list_machine_operations`

```elixir
@spec list_machine_operations(String.t()) :: [
  %{
    operation: PhoenixKitManufacturing.EntitiesRegistry.record() | nil,
    time_norm_seconds: integer() | nil
  }
]
```

Lists the operations linked to a machine, each paired with its
per-machine time-norm override.

Returns `%{operation: EntitiesRegistry.record() | nil, time_norm_seconds:
integer() | nil}` maps, ordered by the linked operation's name (resolved
from `EntitiesRegistry` — `operation_uuid` is a soft reference, see
`Schemas.MachineOperation` moduledoc, and carries no name of its own).
`operation` is `nil` for a dangling link (the linked entity-data record
was hard-removed out from under a soft reference — an accepted risk of
the entities migration, see `dev_docs/ENTITIES_MIGRATION_SPEC.md` §5);
such rows sort first. `time_norm_seconds` is the raw `MachineOperation`
override as stored — `nil` means "no override, use the operation's own
`base_time_norm_seconds`"; resolving that fallback is left to the caller
(this function doesn't look at `operation.base_time_norm_seconds`
itself).

# `list_machine_types`

```elixir
@spec list_machine_types(list_machine_types_opts()) :: [
  PhoenixKitManufacturing.EntitiesRegistry.record()
]
```

Lists machine types via `EntitiesRegistry`.

## Options

  * `:locale` — resolves each record's `:name` for this locale (a bare
    Gettext code or BCP-47 dialect); `nil` (default) resolves the
    primary-language title.
  * `:status` — filter by exact status (e.g. `"published"`). `nil`
    (default, unlike the old `"active"`-by-convention behavior) returns
    every cached status — callers that only want published records must
    pass `status: "published"` explicitly.

# `list_machines`

```elixir
@spec list_machines(list_machines_opts()) :: [
  PhoenixKitManufacturing.Schemas.Machine.t()
]
```

Lists all machines, ordered by name.

Does **not** preload linked machine types — `machine_type_uuid` is a
soft reference (see `Schemas.MachineTypeAssignment` moduledoc), not an
Ecto association, so there is nothing for `preload:` to resolve. Callers
that need type names for a batch of machines should use
`linked_type_uuids_by_machine/1`.

## Options

  * `:status` — filter by status.
  * `:type_uuid` — filter to only machines that have this type assigned.

# `location_label`

```elixir
@spec location_label(PhoenixKitManufacturing.Schemas.Machine.t(), opts()) ::
  String.t() | nil
```

Resolves a human-readable location label for a machine, trying (in
order):

  1. `space_uuid` — `PhoenixKitLocations.Spaces.full_path/2`, e.g.
     `"Main Warehouse / Floor 2 / Rack 5"`.
  2. `location_uuid` — the translated name of the `Location` itself (no
     specific space picked).
  3. `location_note` — legacy freeform text for machines that predate the
     `location_uuid`/`space_uuid` link (see `Schemas.Machine`).
  4. `nil` — no location data at all.

`phoenix_kit_locations` is a soft cross-module reference (no FK — see
`Schemas.Machine`'s moduledoc): a uuid pointing at data this call can't
reach (record deleted, table not migrated on this host, …) is treated as
"no answer" and falls through to the next step rather than raising, hence
the `rescue` around each cross-module read.

## Options

  * `:locale` — forwarded to `Spaces.full_path/2` / used to pick the
    translated `Location` name, same `_name` -> `name` -> primary-name
    fallback chain as `PhoenixKitLocations.Web.Components.PlacePicker`.
    `nil` (default) always shows the primary-language name.

# `log_module_toggle`

```elixir
@spec log_module_toggle(:enabled | :disabled, opts()) :: :ok
```

Logs a module enable/disable toggle. Called from the `enable_system` /
`disable_system` module lifecycle functions.

# `merged_field_template`

```elixir
@spec merged_field_template([String.t()]) :: [map()]
```

Merges the `field_template` rows of every published machine type in
`type_uuids` into a single ordered list, for rendering the dynamic
`metadata` inputs on the machine form.

`type_uuids` is expected to already be filtered down to "linked to this
machine" (e.g. `MapSet.to_list/1` of the toggled type badges on the
form) — this function does no linking lookup of its own, it only merges.

Types are read via `EntitiesRegistry.list(:machine_type, nil, status:
"published")` — locale isn't threaded through (unlike
`list_machine_types/1`) because this function never reads a record's
`:name`/`:titles`, only `metadata["field_template"]`, so the registry's
locale-dependent title resolution is irrelevant here. Records come back
ordered by `position` (drag-order in the entities admin UI;
creation-order immediately after the V5 migration seed, since every
migrated record starts at `position: 0` — see the E-plan's "Решения по
открытым вопросам" #5) — so the merge order (and therefore which type
wins a key collision) follows that order, **not** the order of
`type_uuids`. When two linked types both define a `field_template` row
with the same `key`, the earlier one in registry order wins and the
later row is dropped silently — this is a deliberate "first wins" merge,
not an error. Callers rendering the merged template SHOULD hint which
type a field came from when a collision is possible (e.g. a "from <type
name>" caption next to the label) — this function only resolves the
winner, it doesn't surface which types lost.

# `sync_machine_operations`

```elixir
@spec sync_machine_operations(
  String.t(),
  %{required(String.t()) =&gt; integer() | nil},
  opts()
) ::
  {:ok, :synced | :unchanged} | {:error, :operation_assignment_failed}
```

Syncs the operation links for a machine (full replace).

`overrides_map` is a `%{operation_uuid => time_norm_seconds | nil}` map:
its key set is the full desired list of linked operations, and each
value is that operation's per-machine norm override (`nil` ⇒ no
override, fall back to the operation's own `base_time_norm_seconds`).

Unlike `sync_machine_types/3` (which only needs to compare a *set* of
linked UUIDs), this compares the whole map with `Map.equal?/2` against
`linked_operation_overrides/1` — same key set *and* same values. An
unchanged set of linked operations with a changed override is still a
real sync, not a no-op, because the override value is data the caller
asked to persist.

Replaces all existing links with the given map, wrapped in a transaction
for atomicity. Logs `machine.operations_synced` only when something
actually changed; a no-op sync is silent.

# `sync_machine_types`

```elixir
@spec sync_machine_types(String.t(), [String.t()], opts()) ::
  {:ok, :synced | :unchanged} | {:error, :type_assignment_failed}
```

Syncs the type assignments for a machine (full replace).

Replaces all existing assignments with the given list of type UUIDs,
wrapped in a transaction for atomicity. Logs `machine.types_synced` only
when the assignment set actually changed; a no-op sync is silent.

# `update_machine`

```elixir
@spec update_machine(PhoenixKitManufacturing.Schemas.Machine.t(), map(), opts()) ::
  {:ok, PhoenixKitManufacturing.Schemas.Machine.t()}
  | {:error, Ecto.Changeset.t()}
```

Updates a machine with the given attributes.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
