# Invoice Match — Python service

FastAPI + Strands service that reconciles planned billing (the Project Planning
workbook) against Software invoices raised in Odoo, classifying each planned line
**green / amber / red / billed_unplanned**. This is the only component that reaches
Odoo or a model provider. See `../docs/CONTRACT.md` for the authoritative schema,
HTTP API, SSE events and matching logic.

## Layout

```
app/
  config.py        pydantic-settings over the env (CONTRACT §6)
  matching/        deterministic core — openpyxl + stdlib ONLY (unit-testable offline)
    models.py        dataclasses mirroring the DB schema
    sheet_parser.py  parse the `projects` sheet (month located by header date)
    variance.py      variance + tolerance test
    candidates.py    deterministic candidate ranking (difflib)
    classifier.py    three-tier decision (deterministic / agent / reverse)
  odoo/client.py   read-only Odoo client (JSON-RPC; oca_rest stub)
  agent/           Strands orchestrator + workers + tools + provider selection
  telemetry/otel.py OpenTelemetry setup + trace capture (flow.span SSE)
  streaming/sse.py  in-process SSE pub/sub (asyncio.Queue per run)
  db.py            MySQL snapshot writes + memory reads (+ in-memory fallback)
  runner.py        run pipeline + OFFLINE CLI
  main.py          FastAPI app (CONTRACT §3 endpoints)
tests/             self-contained pytest suite (openpyxl + pytest only)
```

The `matching` package imports **only** `openpyxl` + the standard library, so it is
offline-testable and reusable by the later Pi-track build. `config`, `odoo` and the
`agent` layer are imported lazily so the offline path stays lean.

## Run the offline CLI (no Odoo / DB / model)

Runs parse + classify deterministically (residue → amber) and prints a table + JSON.
Needs only `openpyxl` installed.

```bash
# from service/
python -m app.runner --offline \
  --sheet   ../sample_data/planning_sample.xlsx \
  --invoices ../sample_data/invoices_sample.json \
  --name-map ../sample_data/name_map_sample.json \
  --month 2026-10 \
  [--rules rules.json]
```

Generate the sample fixtures first if needed: `python ../sample_data/generate_sample.py`.

## Run the tests

Self-contained — they build tiny in-memory workbooks and invoice lists, so only
`openpyxl` + `pytest` are required (no strands / DB / Odoo).

```bash
# from service/
python -m venv .venv && . .venv/bin/activate
pip install openpyxl pytest        # or: pip install -r requirements.txt
pytest
```

## Run the API

```bash
# from service/
pip install -r requirements.txt
cp ../.env.example .env             # fill in Odoo / provider / MySQL as needed
uvicorn app.main:app --host 0.0.0.0 --port 8000
```

If `MYSQL_*` is unset the service falls back to an in-memory store; if `strands`
is unavailable the tier-2 residue stays amber (needs review) — the deterministic
tiers always run.

### Endpoints (CONTRACT §3)

| Method | Path | Notes |
| --- | --- | --- |
| GET  | `/health` | `{"status":"ok"}` |
| POST | `/runs` | multipart `file`, `month` (`YYYY-MM`), `provider` → `{run_id,status}` |
| GET  | `/runs/{id}` | full run snapshot |
| GET  | `/runs/{id}/stream` | `text/event-stream` SSE (CONTRACT §4) |
| GET  | `/runs?limit=` | run list |

## Docker

```bash
docker build -t invoice-match-service .
# runs: uvicorn app.main:app --host 0.0.0.0 --port 8000
```

## Configuration

All keys are in `../.env.example` (CONTRACT §6): matching tolerances, Odoo
(`ODOO_*`, read-only), provider keys + model ids, `MYSQL_*`, `OTEL_*`. Open
questions carry defaults: `ODOO_SOFTWARE_FILTER=journal:Software`,
`ODOO_API_STYLE=jsonrpc`, `UNRESOLVED_POLICY=amber`.
