> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# JSON config sync

> Declarative JSON loader for backend POS configuration — shared configs, per-terminal settings, printers, print routes, discount presets, void reasons, special-request tags, side-dish groups, and picking-sale reasons.

The JSON config loader keeps backend POS configuration in a single declarative document. Apply the JSON and the database converges on the described state: records are created, updated, or deleted to match, and the most recently applied document is replayed automatically on module install and upgrade so configuration survives migrations.

Implemented in `nu_restaurant_pos/lib/config_loader.py` and `nu_restaurant_pos/models/nu_pos_config_sync.py`.

## What it manages

| Section                | Odoo model                        | Match key                   |
| ---------------------- | --------------------------------- | --------------------------- |
| `printers`             | `nu.pos.printer`                  | `key` (loader-owned xmlid)  |
| `print_routes`         | `nu.pos.print.route`              | `key` (loader-owned xmlid)  |
| `discount_presets`     | `nu.pos.discount.preset`          | `key` (loader-owned xmlid)  |
| `void_reasons`         | `nu.pos.void.reason`              | `key` (loader-owned xmlid)  |
| `special_request_tags` | `nu.pos.special.request.tag`      | `key` (loader-owned xmlid)  |
| `side_dish_groups`     | `nu.pos.side.dish.group`          | `key` (loader-owned xmlid)  |
| `picking_sale_reasons` | `nu.pos.picking.sale.reason`      | `key` (loader-owned xmlid)  |
| `shared_configs`       | `nu.pos.config`                   | `name` + `company`          |
| `pos_configs`          | `pos.config` (`nu_*` fields only) | `name` + optional `company` |

<Note>
  Catalog records (the first seven sections) and shared configs are matched by xmlids stamped under the synthetic module `__nu_pos_config_json__`. The loader only ever sees records it owns — anything created manually in the Odoo UI is invisible to it and will never be modified or deleted.
</Note>

## Document shape

```json theme={null}
{
  "version": "1.0",
  "printers": [
    {
      "key": "kitchen",
      "name": "Kitchen Printer",
      "company": "base.main_company",
      "connection_type": "network",
      "ip_address": "192.168.1.50",
      "port": 9100,
      "active": true
    }
  ],
  "print_routes": [
    {
      "key": "kitchen_food",
      "name": "Kitchen — Food",
      "route_type": "kitchen_station",
      "printer": "kitchen",
      "pos_categories": ["point_of_sale.pos_category_misc"]
    }
  ],
  "discount_presets": [
    {
      "key": "ten_off",
      "name": "10% Off",
      "discount_type": "fixed_percent",
      "value": 10.0,
      "applies_to": "both"
    }
  ],
  "void_reasons": [
    { "key": "wrong_item", "name": "Wrong item", "applies_to": "all" }
  ],
  "special_request_tags": [
    { "key": "no_onion", "name": "No onion" },
    { "key": "no_onion_extra", "name": "Extra no onion", "parent": "no_onion" }
  ],
  "side_dish_groups": [
    {
      "key": "fries_or_salad",
      "name": "Fries or salad",
      "lines": [
        { "product": "product.product_product_4", "use_product_price": true,
          "surcharge": 0, "sequence": 10 }
      ]
    }
  ],
  "picking_sale_reasons": [
    { "key": "staff_meal", "name": "Staff meal",
      "expense_account": "account.a_expense" }
  ],
  "shared_configs": [
    {
      "name": "Main floor",
      "company": "base.main_company",
      "printing_enabled": true,
      "send_changes_to_kitchen": true,
      "printers": ["kitchen"],
      "print_routes": ["kitchen_food"]
    }
  ],
  "pos_configs": [
    {
      "name": "Terminal 1",
      "shared_config": "Main floor",
      "nu_lock_mode": "session",
      "nu_login_method": "pin"
    }
  ]
}
```

### Keys vs xmlids

* **`key`** — a string you choose. Stable within a section, scoped to the loader. Used to reference sibling records inside the document (e.g. `print_routes.printer` references a `printers` key).
* **External xmlid** — full `module.name` reference to a record outside the JSON (companies, accounts, journals, POS categories, products, users, floors, tags, fiscal positions).

Every external xmlid is resolved during pre-flight validation; an unknown xmlid fails the whole document before any write.

### Referencing records by ID

Every field that accepts an external xmlid also accepts a positive integer record ID. The loader dispatches by Python type — strings containing a dot are treated as xmlids, integers as record IDs. Lists may mix both freely.

```json theme={null}
{
  "shared_configs": [{
    "name": "La Porteña",
    "company": 1,
    "reservation_advance_account": "__custom__.account_reservation_advance",
    "shared_floors": [109, 110, "__custom__.floor_terraza"]
  }]
}
```

**Rules:**

| Value                                                                             | Treated as                          |
| --------------------------------------------------------------------------------- | ----------------------------------- |
| Positive integer (`109`)                                                          | Record ID — resolved via `browse()` |
| String with a dot (`"__custom__.floor_deck"`)                                     | xmlid — resolved via `env.ref()`    |
| String without a dot, empty string, `0`, negative integer, boolean, float, `null` | Rejected at pre-flight              |

Numeric strings like `"109"` are explicitly rejected — the loader does not coerce types.

**Trade-off:** Record IDs are bound to a specific database. If you ever migrate or restore the database to a new environment, the same numeric ID may point to a different record. xmlids do not have this problem because they resolve through `ir.model.data`. Prefer IDs when you maintain a single environment; prefer xmlids when the same config travels between environments.

## Apply, dry-run, export

Three entry points, all gated by `base.group_system`:

<Steps>
  <Step title="Admin form">
    Settings → POS → **JSON Config Sync**. Upload the file, then click **Dry-run preview** to see the create/update/delete plan, or **Apply** for the real run. **Export current state** downloads the current DB as JSON.
  </Step>

  <Step title="Python / CLI">
    ```python theme={null}
    env['nu.pos.config.sync'].apply_from_path('/abs/path/to/config.json')
    env['nu.pos.config.sync'].apply_from_data(data_dict, dry_run=True)
    env['nu.pos.config.sync'].export_to_json()
    ```
  </Step>

  <Step title="Module install / upgrade">
    The `post_init_hook` (install) and `migrations/<version>/post-migrate.py` (upgrade) call `apply_active_attachment()`, which replays the most recently uploaded JSON.
  </Step>
</Steps>

<Warning>
  Only system administrators can apply, dry-run, or export. The check is enforced in the model — UI access alone is not sufficient.
</Warning>

## Active attachment & upgrade replay

`action_apply` (and the programmatic equivalents) persists the raw JSON as a single `ir.attachment` named `active_config.json` attached to the `nu.pos.config.sync` model with `res_id=0`. The post-init hook and the version migration script both call `apply_active_attachment()`, which:

1. Loads the active attachment (no-op if none exists).
2. Runs the loader against the live database.
3. Logs the resulting plan.

This is how configuration survives module upgrades: the last applied JSON is the source of truth, and every upgrade reconverges the database to it. Errors in the replay are logged but do not abort the upgrade.

## Execution order

The loader runs everything inside a single savepoint:

1. **Validate** — shape, required keys, selection values, all external xmlids resolve.
2. **Plan** — diff current xmlid-owned records against the JSON to count create/update/delete per section.
3. **Catalog upserts** — printers, routes, presets, void reasons, tags, side-dish groups, picking-sale reasons.
4. **Shared configs (pass 1)** — everything except terminal-dependent fields (`cashier_pos_configs`, `shared_floors`).
5. **`pos.config` updates** — link terminals to their `nu_shared_config_id` and apply `nu_*` fields.
6. **Shared configs (pass 2)** — terminal-dependent fields, now that the terminal linkage exists.
7. **Catalog orphan deletes** — remove loader-owned records not present in the JSON.
8. **Shared-config deletes** — remove loader-owned `nu.pos.config` records not present in the JSON, only if no `pos.config` references them.

The two-pass shared-config write avoids constraint failures: fields like `shared_floors` require linked terminals, which don't exist until step 5.

## Safety rules

<Steps>
  <Step title="Terminals are never created or deleted">
    `pos_configs` is update-only. An entry whose `name` doesn't match an existing `pos.config` is skipped and recorded as a warning. The loader never creates or removes physical terminals.
  </Step>

  <Step title="Hand-managed records are untouchable">
    A printer (or any catalog record) created in the Odoo UI has no xmlid under `__nu_pos_config_json__`. The loader doesn't see it, doesn't update it, doesn't delete it.
  </Step>

  <Step title="Shared configs linked to terminals are not deleted">
    If a JSON-removed shared config still has a `pos_config_ids` link, the loader skips the delete and emits a warning instead of breaking running terminals.
  </Step>

  <Step title="Pre-flight references all resolve">
    Every xmlid in the document is checked before any write. A single bad reference fails the whole document; nothing partially applies.
  </Step>

  <Step title="Whole apply is transactional">
    `apply()` opens a savepoint. If any step raises, the database rolls back to the pre-apply state.
  </Step>
</Steps>

## Plan output

Both `action_dry_run` and `action_apply` return a plan dict and render it in the **Last result** panel. Example:

```text theme={null}
printers: created=0, deleted=0, updated=2
print_routes: created=1, deleted=0, updated=1
discount_presets: created=0, deleted=0, updated=0
void_reasons: created=0, deleted=1, updated=0
shared_configs: created=0, deleted=0, updated=1
pos_configs: created=0, skipped=0, updated=2

Warnings:
  - Skipping delete of shared_config 'Main floor': linked to 2 pos.config terminal(s).
```

## Authoring workflow

A typical lifecycle for managing config in a deployment:

<Steps>
  <Step title="Bootstrap from the live database">
    Click **Export current state** to download a JSON snapshot of the current configuration. Commit it to your config repo.
  </Step>

  <Step title="Edit declaratively">
    Add, remove, or change entries. Keys must be stable — renaming a `key` is treated as a delete + create.
  </Step>

  <Step title="Dry-run">
    Upload and click **Dry-run preview**. Review the create/update/delete counts and any warnings before applying.
  </Step>

  <Step title="Apply">
    Click **Apply**. The plan is rendered and the JSON becomes the new active attachment, ready to replay on the next module upgrade.
  </Step>
</Steps>

## Adding a new field

To add a new field to one of the managed models:

1. Add the field to the model in `nu_restaurant_pos/models/`.
2. Map it in `_vals_from_entry()` (write) and `_export_catalog_entry()` (read) in `config_loader.py`, or in the corresponding `SHARED_*` tuple for `nu.pos.config` and `POS_CONFIG_FIELDS` for `pos.config`.
3. If the field references another record by xmlid, add it to `_validate_references()` so pre-flight catches bad references.
4. If the field is a `Selection`, add it to `_validate_selections()`.
5. Add a test in `nu_restaurant_pos/tests/test_config_loader.py`.

<Note>
  Catalog sections that need cross-record references (like `special_request_tags.parent` or `side_dish_groups.lines.product`) are written in two passes inside the loader. Follow the existing patterns in `_upsert_special_request_tags` and `_upsert_side_dish_groups` rather than inlining the logic.
</Note>

## Adding a new section

Adding a brand-new catalog section requires more than `_vals_from_entry`:

1. Add the section name to `ALLOWED_TOP_LEVEL` and `CATALOG_SECTIONS`.
2. Map it to its model in `CATALOG_MODELS`.
3. Implement `_vals_from_entry` and `_export_catalog_entry` cases.
4. Add reference and selection validators if the section has them.
5. If the section needs multi-pass writes (e.g. self-references), add a dedicated `_upsert_*` function and dispatch to it from `_execute()`.

## Reference

* Loader: `nu_restaurant_pos/lib/config_loader.py`
* UI wizard + replay: `nu_restaurant_pos/models/nu_pos_config_sync.py`
* Form view & menu: `nu_restaurant_pos/views/nu_pos_config_sync_views.xml`
* Post-init hook: `nu_restaurant_pos/__init__.py` (`_post_init_apply_config`)
* Upgrade replay: `nu_restaurant_pos/migrations/12.0.1.14.0/post-migrate.py`
* Tests: `nu_restaurant_pos/tests/test_config_loader.py`
