Internal · technical design

Design — driver layer and azh §302

The design, now that both unknowns are closed. Neither came back badly. maptara.claim.batch is real and substantially built; the §302 Datenmapping is a finished 34-field spec, not the hole it was budgeted as.

2026-09-19 · supersedes the "unestimated" framing in the plan · goal · file map · symbol map
What the two audits actually found — both better than assumed

maptara.claim.batch: real. 317 lines, 22 fields, including le_ik, submitted_date, state, claim_request_ids, account_move_ids, copayment_amount/insurance_amount, billing_type_id. State machine already present: action_confirmaction_validateaction_cancel. Plus a batching wizard, a QWeb report and 387 lines of tests. The single missing capability is file generation — a grep for export/serialise/xml/302 in that model returns nothing.

The §302 mapping: a finished spec. 34 fields in 5 blocks, each with a named Maptara source and the azh XML element. Its own ✅ section states it plainly: "Alle benötigten Daten (VNummer, BSNR, LANR, Verordnungsdatum etc.) sind bereits in Maptara vorhanden." Trigger is a manual button, explicitly not automatic on posting.

So azh is not a rewrite and the mapping is not a research project. It is a serialiser over an existing model, against schemas already on disk with worked examples beside them.

  1. The four layers
  2. The driver contract
  3. ConnectionConfig
  4. SftpDriver
  5. azh §302 — data model
  6. azh §302 — the 34 fields
  7. azh §302 — files and lifecycle
  8. Egeko, on the contract
  9. Open decisions

01 · The four layers

base  (Odoo core) L3 · TRANSPORT DRIVERS depends: ['base'] — and nothing else driver_sftp driver_soap driver_rest ConnectionConfig · connect · put · list · get · probe knows nothing about healthcare · never touches the ORM THE PRODUCT maptara_base → maptara_san_sale · san_claim · document_manager_api 12 124 ln · drivers deliberately do NOT depend on this maptara_san_provider_base L2 · maptara.provider.driver — AbstractModel · 7 outbound ops + submit_batch · capability flags · declared inbound hole L1 · provider row · credentials (groups=) · branches · maptara.provider.activity audit log provider_egeko eKV wire format · per order ⇢ driver_soap provider_azh  NEW §302 wire format · per batch ⇢ driver_sftp plugins reach the drivers directly — the contract routes, it does not proxy bytes The rule A driver knows nothing: not the domain, not the provider, not where the password came from.
Fig 1 — the four layers. Drivers hang off Odoo core, not off the product, so any tree can reach them. L2 is the contract that routes an operation to a plugin; L1 is the provider identity and audit log that already exists. The two plugins differ in one structural way: Egeko works per order, azh works per batch — which is why submit_batch exists.

The rule, unchanged: a driver knows nothing — not the domain, not the provider, not where the password came from. It moves bytes and reports what happened.

02 · The driver contract

maptara_san_provider_base/models/provider_driver.py — an AbstractModel, the declared version of today's getattr(self, f'_{op}_{provider_type}') convention. Roughly 200 lines.

class ProviderDriver(models.AbstractModel):
    _name = 'maptara.provider.driver'
    _description = 'Contract every provider plugin implements'

    # ---- capability declaration ----------------------------------
    def _capabilities(self):
        """Return a dict the UI and the cron can interrogate.
        Replaces 'call it and see if getattr returned None'."""
        return {
            'send_estimate': False, 'resend_estimate': False,
            'poll_state':    False, 'fetch_decision':  False,
            'fetch_documents': False, 'send_message':  False,
            'submit_batch':  False,
            'appendix_mimetypes': (),      # was a constant in the BASE
            'inbound': (),                 # reserved — see below
        }

    # ---- probe ---------------------------------------------------
    def probe(self, config):
        """-> {'ok': bool, 'stage': 'config'|'endpoint'|'credentials',
               'url': str, 'message': str}
        Never raises. Creates no Vorgang. Safe against production."""
        raise NotImplementedError

    # ---- outbound, per record ------------------------------------
    def send_estimate(self, activity, documents=(), mdm_attachments=()):
        """Attachments are ARGUMENTS. Today they are smuggled through
        two undocumented context keys and silently default to empty."""
        raise NotImplementedError

    def resend_estimate(self, activity, documents=(), mdm_attachments=()):
        raise NotImplementedError

    def poll_state(self, activity):
        """-> bool: True iff the insurance decision moved.
        The only return value any caller consumes."""
        raise NotImplementedError

    def fetch_decision(self, activity):     raise NotImplementedError
    def fetch_documents(self, activity):    raise NotImplementedError
    def send_message(self, activity, subject, body):  raise NotImplementedError

    # ---- outbound, per batch  (new — azh §302) -------------------
    def submit_batch(self, batch):
        """-> maptara.provider.activity. One submission, many invoices."""
        raise NotImplementedError

    # ---- inbound: declared, not implemented ----------------------
    # fetch_orders()    Direktauftrag        ticket #1274
    # fetch_requests()  Versorgungsanfrage   ticket #1276
    # fetch_messages()  inbound messages     ticket #1275
    # Blocked on a product decision, NOT on this design. The hole is
    # declared so adding them later is not a second pass at the contract.
TODAY — a naming convention AFTER — a declared interface activity.send_cost_estimate() _provider_method('send_cost_estimate') getattr(self, f'_{op}_{provider_type}', None) found → call it attachments arrive via context keys None → 3 distinguishable errors or _log_unroutable in background What it cannot express · "does this provider support send_message?" — call it and see · which MIME types a plugin accepts → a constant in the BASE · a typed return value — only poll_state's bool is consumed · attachments → two undocumented context keys, silently empty · one operation per record — no batch shape at all activity.send_cost_estimate(documents=..., mdm_attachments=...) driver = self._driver() # resolved once, typed if not driver._capabilities()['send_estimate']: refuse early driver.send_estimate(activity, documents, mdm_attachments) plugin maps the payload · hands bytes to its driver returns an audit record — it does not write status itself What it adds · capability flags — ask before calling · MIME types declared by the plugin, not the base · attachments as arguments — cannot be silently empty · submit_batch for §302 — one submission, up to 3 000 Verordnungen · a declared hole for inbound (#1274 / #1276 / #1275)
Fig 2 — the same dispatch, declared instead of guessed. The mechanism on the left already works and is well tested; the 17-line comment above it in the source is an incident report explaining why. What it cannot do is answer questions — every capability check today is "call it and see what getattr returned". That is the entire upgrade, and it is roughly 200 lines.
Two deliberate changes from today's behaviour

1. submit_batch is new. Every existing operation acts on one maptara.provider.activity tied to one order. §302 is one submission carrying up to 3 000 Verordnungen. Forcing that through send_estimate would break the audit model; giving it its own operation costs one method.

2. Attachments become arguments. Today they travel as selected_document_ids and selected_mdm_attachment_ids in the context. Absent them, the payload ships with an empty appendix and nobody is told. That is a silent-data-loss bug wearing a convention's clothes.

03 · ConnectionConfig

Plain Python in the driver module. No ORM. That is what makes drivers testable with no database.

@dataclass(frozen=True)
class ConnectionConfig:
    host: str                 # or full endpoint URL for SOAP/REST
    port: int = 22
    username: str = ''
    secret: str = ''          # password or key material
    key_path: str = ''
    timeout: int = 30
    retries: int = 3
    dry_run_dir: str = ''     # write locally instead of transmitting

    @classmethod
    def resolve(cls, env, param_key, env_var, **kw):
        """Endpoint precedence, promoted out of the egeko module:
              explicit argument  >  environment variable  >  ir.config_parameter  >  REFUSE
        There is NO default. A staging DB restored from production
        would otherwise file real claims with real Krankenkassen."""

Credentials are built by the caller from whatever model it owns — maptara.service.provider for ECE, vdms.backend for VDMS. The driver never runs a search(). The moment it owns a credential model it has an opinion about who is calling it and stops being reusable.

04 · SftpDriver

maptara_driver_sftp, depends: ['base'], external_dependencies: {'python': ['paramiko']} — declared honestly, which would be a first in this repo for a transport.

MethodContract
connect(config)Context manager. Host-key policy is explicit and configured, never AutoAddPolicy by accident.
put(local_bytes, remote_path)Atomic where the server allows it: upload to a temp name, then rename. A half-written Datendatei that azh picks up is worse than no file.
list(remote_dir, pattern)For the return files — *EPO.xml, *AVO.xml, *NVD.xml, *DIFF.xml.
get(remote_path)Returns bytes. Archiving is the caller's business.
probe(config)Open a session, list the outbound directory, close. Three stages like the SOAP probe: config / endpoint / credentials.
Dry run is a first-class mode, not a test helper

If config.dry_run_dir is set, put() writes to a local directory and returns as though it succeeded. azhDirekt has no test environment at all — the vendor does not provide one. Until the SFTP account exists (offered 2026-08-04, still unclaimed), the dry run is the only way to exercise the pipeline end to end, and it stays useful afterwards for reproducing what was actually sent.

05 · azh §302 — data model

The spec's Block A is a Sendung: an envelope over N invoices, generated at transmission. maptara.claim.batch already models the invoice set. So one new model, not a new subsystem.

ModelStatusWhat changes
maptara.claim.batchexists — 317 ln, 22 fields Add: submission_state (offen / übermittelt / Fehler) and the lock against double transmission that design question #3 calls for. Do not disturb the existing state machine or the 387 lines of tests around it.
maptara.claim.submissionnew — Block A sendungs_id (YYYYMMDD + Kundennummer + sequence), sendungs_zeitpunkt, state, batch_ids, activity_id, the generated files, and the parsed return files.
maptara.provider.activityexists One row per submission, via submit_batch. Request/response payloads land in the existing audit fields — which is exactly why their "XML File" labels need generalising.
Added 22 Sep for CR #1319 — shape the submission for Release 2 now, at zero cost

The direct-DTA route (07) needs six distinct IK kinds, a route discriminator and a bundling signature on the snapshot. Adding them while maptara.claim.submission is being created costs nothing; adding them afterwards is a migration. So the model gains, in October, populated trivially for azh:

ik_liefer · ik_abrechnung · ik_zahlung · ik_zertifikat · ik_kostentraeger · ik_das · route (azh | dta) · bundling_signature (computed; for azh a constant per Kundennummer)

Separate status fields, never merged — technical transmission, business acceptance, clarification, payment — are the same lesson as Egeko's four status buckets and go on the model for the same reason.

Design question #1 from the spec — positions as sub-lines or a separate table? The spec recommends a separate position table, and it is right, but we do not need one: positions come from account.move.line, which already exists and already carries LEGS. Serialise from the invoice lines; do not copy them into a staging table that can drift.

06 · azh §302 — the 34 fields

Straight from Kerstin's document. Every row has a source; the ✅ section confirms all of it already exists in Maptara. The XSDs — azh_HIMI_Begleitdatei_v2.4.xsd (9 elements) and azh_HIMI_Datendatei_v2.4.xsd (79 elements) — are on disk with worked examples.

BlockFieldsSourceNotes
A — Sendungsebene3generated at transmission SendungsId, SendungsZeitpunkt, Übermittlungsstatus. Straight onto the new submission model.
B — Rechnungskopf10account.move Rechnungsnummer → Sondererfassung1 (needs NOVENTI's formal confirmation), Abrechnungsart 1=Erst/2=Folge, VerordnungsId from the prescription barcode, Genehmigungsdatum/-nummer, Zuzahlung.
C — Versichertendaten8patient VNummer, VStatus, name, DOB, address. All present today.
D — Kostenträger & Arzt3master data KostentraegerIk, BSNR, LANR.
E — Positionsdaten10account.move.line, 1–n Anzahl, EinzelPreisBetrag, MwStKennzeichen, LEGS, HilfsmittelKennzeichen, Versorgungszeitraum, ZusatzText.
Total34Zero new custom fields required. That is the spec's own finding, not an assumption.
MAPTARA — source BLOCK azh XML element generated at transmission date + Kundennummer + sequence system timestamp · internal status A · Sendungsebene 3 fields SendungsId · SendungsZeitpunkt Begleitdatei header · Kundennummer → new model maptara.claim.submission account.move invoice number · posting date · Abrechnungsart amounts · Zuzahlung · Genehmigung + prescription document (barcode) B · Rechnungskopf 10 fields VerordnungsId · AusstellungsDatum GesamtBruttoBetrag · GenKennzeichen Sondererfassung1 ← invoice number needs NOVENTI's formal confirmation patient (res.partner) insurance number + status · name · DOB street · postcode · city C · Versichertendaten 8 VNummer · VStatus · VName · VVorname VGeburtsdatum · VAdresse{Strasse,PLZ,Ort} all present in Maptara today master data Krankenkasse IK · doctor BSNR + LANR D · Kostenträger & Arzt 3 KostentraegerIk · BSNR · LANR LEGS validity per Kostenträger still open account.move.line  1–n qty · unit price · tax code · LEGS service date · supply period · free text serialise from the lines — no staging table E · Positionsdaten 10 per line PositionsNummer · Anzahl · EinzelPreisBetrag MwStKennzeichen · LEGS · HilfsmittelKennzeichen VszBeginn · VszEnde · ZusatzText LeistungserbringungsDatum 34 fields total · 0 new custom fields required the spec's own conclusion, not an assumption Format warning The Datendatei example uses ISO dates (1969-06-21). Do NOT assume Egeko's German comma decimals carry over — derive every format from the XSD and the worked examples, and write the assertions before the serialiser.
Fig 3 — the whole submission, source to wire. Five blocks, 34 fields, each with a confirmed Maptara origin. Two are still open and both are client answers rather than code: Sondererfassung1 needs NOVENTI's confirmation, and the LEGS keys valid per Kostenträger are unlisted. Neither blocks building the serialiser — they are values it takes as data.
The one wire trap worth naming now

Egeko taught us that German number formatting on the wire is where the bugs live — 17 tests exist purely to defend de_decimal/de_price. Do not assume azh wants the same thing. The Datendatei example shows ISO dates (1969-06-21), so at minimum the date convention differs from Egeko's. Derive every format from the XSD and the worked examples, and write the assertions before the serialiser — the same order that R0 imposes on Egeko.

07 · azh §302 — files and lifecycle

AspectValue (from the spec)
Protocol / hostSFTP · edx.azh.de:22 · directory to_azh/
Files per submissionBegleitdatei <SendungsId>.xml · Datendatei(en) + PDFs inside <SendungsId>.zip
EncodingUTF-8
Limit3 000 Verordnungen per Datendatei — so splitting is a requirement, not an optimisation
AuthUser/password or SSH key — key preferred for automation
Returns*EPO.xml · *AVO.xml · *NVD.xml · *DIFF.xml
OUTBOUND — manual trigger, never automatic on posting user selects filtered §302 invoices checkbox, not "all" claim.submission NEW model · SendungsId date + Kundennr + seq serialise Begleitdatei + Datendatei split at 3 000 Verordnungen submit_batch() contract routes to azh plugin plugin → bytes SftpDriver.put() to_azh/ · atomic or dry_run_dir azh edx.azh.de provider.activity row records exactly what was sent · batch locks against resend INBOUND — polled later. THE STATE IS WRITTEN HERE, NOT ON SEND. SftpDriver.list() return directory *EPO *AVO *NVD *DIFF *EPO — accepted submission state → übermittelt · invoices stay locked *NVD — REJECTED · the path that must work state → Fehler · lock released for the affected invoices only manual correction, then resubmit — spec design question #4 *AVO / *DIFF — recorded stored on the activity, no state transition Do not import Egeko's bug _send_cost_estimate_egeko writes insurance_state = 'waiting' at maptara_provider.py:459-466 — even when the send FAILED. The order then polls an empty reference forever. Rule for azh: a send writes only "what was sent". Outcome comes from the return file.
Fig 4 — one submission, end to end. The outbound half is a straight line; the interesting half is inbound. Three of the four return types are informational — only *NVD causes a state change, and it must release the lock for the affected invoices without releasing the rest. The red panel is the mistake this design exists to avoid repeating.
to_azh/  — what one submission puts on the wire ├── <SendungsId>.xml Begleitdatei · 9 elements · EDL/Kundennummer/Datendateien/Checking └── <SendungsId>.zip ├── <SendungsId>_001.xml Datendatei · 79 elements · ≤ 3 000 Verordnungen ├── <SendungsId>_002.xml …one more per 3 000 ├── 201707050B12345001_12345_TB.pdf only if EinlieferungsArt = 1 or 3 └── Kostenuebernahmeerklaerung.pdf " (Q6 — undecided) return directory  — polled, never pushed to us ├── *EPO.xml accepted → state übermittelt ├── *NVD.xml rejected → state Fehler, lock released for those invoices only ├── *AVO.xml recorded on the activity, no transition └── *DIFF.xml recorded on the activity, no transition SendungsId = YYYYMMDD + Kundennummer + sequence · UTF-8 throughout · atomic put (temp name, then rename)

The rejection path is the one to get right. Design question #4 in the spec asks what happens on an NVD, and answers it: set the status to error, allow manual correction and resubmission. That mirrors the trap Egeko already taught us — _send_cost_estimate_egeko writes insurance_state = 'waiting' even when the send failed, so a rejected order polls forever. Do not import that bug into azh: the submission state is written from the return file, never optimistically from the send.

08 · Egeko, on the contract

The rewrite is one method plus consequences. generate_document_template263 lines at egeko/models/maptara_provider.py:180-442 — mixes ORM reads, VAT arithmetic, MDM downloads over a different transport, and opta data's wire vocabulary, with no intermediate representation and no payload test coverage.

StepWhat
1. Oracle firstWire tools/ekv_body_diff.py — 630 existing lines that diff a built payload against opta data's own reference XML and exit 1 on mismatch — into the suite. Exit criterion: a deliberately misspelled wire key turns the suite red. Today nothing does.
2. Split the methodRead ORM → build a plain dict → format for the wire. Three steps, three test seams, instead of one 263-line function with four closures defined inside a for loop.
3. Lift the transport185–230 lines into SoapDriver. Fix in transit: Transport() gets the timeout that only Client() has today; add retries; stop LogPlugin.ingress writing status='sent' into the ORM from inside the SOAP stack.
4. Collapse the duplicationSix copy-pasted credential/constructor blocks become one ConnectionConfig.
TODAY — one method, no seam AFTER — three stages, three places to assert generate_document_template egeko/models/maptara_provider.py:180-442 263 lines reads ORM fields — order, partner, patient, doctor, company VAT and discount arithmetic — net and gross, post-discount downloads MDM attachments — over a DIFFERENT transport emits opta data's wire vocabulary — ~45 keys, de-DE decimals 4 nested functions defined inside a for loop 0 tests assert the document dict 1 · read ORM → plain values no arithmetic, no formatting assert: right fields read 2 · build plain dict — the DTO VAT maths lives here assert: economics correct 3 · format dict → wire vocabulary de_decimal · de_price · keys assert: bytes, vs the oracle tools/ekv_body_diff.py — the oracle that already exists 630 lines · diffs a built payload against opta data's own reference XML · exits 1 on mismatch · currently called by NOTHING Also lifted out, in the same pass · ~200 transport lines → SoapDriver, with the timeout that only Client() has today and retries that exist nowhere · six copy-pasted credential blocks → one ConnectionConfig · LogPlugin stops writing status='sent' into the ORM from inside the SOAP stack
Fig 6 — one method becomes three, and each gets a test. The split is not for elegance: today there is nowhere to assert anything between "an order" and "bytes on the wire", which is why a misspelled wire key is caught by nothing. Stage 3 is where the existing 630-line oracle attaches — the reason R0 comes before R3 rather than after.

Preserve verbatim, all currently guarded by tests: the four status buckets (only STATUS_DECIDED may write insurance_state); de_decimal/de_price per-field precision; net and gross both post-discount; additionalFee is Mehrkosten and not the discount; positions sorted create_date descending; and dhpIdentNumer, which stays misspelled because that is opta data's typo in their own WSDL.

09 · Open decisions

Seven from the spec's own "🔲 Noch offen" list, plus two of ours. None of them block starting; each blocks a specific piece.

#DecisionBlocksWho
1SFTP account / SSH key exchange with NOVENTI (edx.azh.de)Proof only. The dry run covers building. Offered 2026-08-04 — 46 days unclaimed.us, today
2LEGS keys per Kostenträger — which Leistungserbringergruppenschlüssel apply?Block E serialisation of real data. Not the code.NOVENTI / Kerstin
3EinlieferungsArt — paper only (2), or digital PDFs too (1 / 3)?Whether the zip carries PDFs at all. Changes scope materially.product
4Sondererfassung1 = Rechnungsnummer — formal confirmationOne field. Cheap to change, embarrassing to get wrong at volume.NOVENTI
5Where the button lives — action menu on filtered §302 invoices?UI only. Spec already recommends the action bar.product
6Batch selection — all open invoices, or checkbox selection?Spec recommends checkboxes for control. Take the recommendation.product
7Barcode printing — Code 39 VerordnungsId onto the paper Muster 16Work nobody has costed. It is a report change, not an interface change, but it is real and it was not in any earlier estimate.us
8Versorgungsanfrage → which Odoo object?Inbound only. Nothing in this design.product
9MIP protocol — the per-customer md5 key suggests signed requests, not RESTNothing now. MIP is out of scope by your decision.later
The one genuinely new cost this design surfaced

Barcode printing (#7). The VerordnungsId is read from the prescription document, and the spec says a Code 39 barcode gets printed onto the paper Muster 16 from Maptara. That is a QWeb report change plus a barcode font or generator, and it appears in no estimate anyone has made so far, including mine. Small — but it was invisible until someone read the document.

Sources: .agent-work/ticket-1197/spec/datenmapping.txt (662 lines, read 2026-09-19); azh_HIMI_Begleitdatei_v2.4.xsd and azh_HIMI_Datendatei_v2.4.xsd with worked examples; maptara_san_claim/models/maptara_claim_batch.py (317 lines, AST-audited); the symbol map for Egeko. Every claim here is re-derivable from those.

Internal design document. Nothing here has been implemented.