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.
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_confirm → action_validate → action_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.
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.
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.
getattr returned". That is the entire upgrade, and it is roughly 200 lines.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.
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.
maptara_driver_sftp, depends: ['base'], external_dependencies: {'python': ['paramiko']} — declared honestly, which would be a first in this repo for a transport.
| Method | Contract |
|---|---|
| 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. |
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.
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.
| Model | Status | What changes |
|---|---|---|
| maptara.claim.batch | exists — 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.submission | new — Block A | sendungs_id (YYYYMMDD + Kundennummer + sequence), sendungs_zeitpunkt, state, batch_ids, activity_id, the generated files, and the parsed return files. |
| maptara.provider.activity | exists | 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. |
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.
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.
| Block | Fields | Source | Notes |
|---|---|---|---|
| A — Sendungsebene | 3 | generated at transmission | SendungsId, SendungsZeitpunkt, Übermittlungsstatus. Straight onto the new submission model. |
| B — Rechnungskopf | 10 | account.move |
Rechnungsnummer → Sondererfassung1 (needs NOVENTI's formal confirmation), Abrechnungsart 1=Erst/2=Folge, VerordnungsId from the prescription barcode, Genehmigungsdatum/-nummer, Zuzahlung. |
| C — Versichertendaten | 8 | patient | VNummer, VStatus, name, DOB, address. All present today. |
| D — Kostenträger & Arzt | 3 | master data | KostentraegerIk, BSNR, LANR. |
| E — Positionsdaten | 10 | account.move.line, 1–n |
Anzahl, EinzelPreisBetrag, MwStKennzeichen, LEGS, HilfsmittelKennzeichen, Versorgungszeitraum, ZusatzText. |
| Total | 34 | Zero new custom fields required. That is the spec's own finding, not an assumption. | |
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.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.
| Aspect | Value (from the spec) |
|---|---|
| Protocol / host | SFTP · edx.azh.de:22 · directory to_azh/ |
| Files per submission | Begleitdatei <SendungsId>.xml · Datendatei(en) + PDFs inside <SendungsId>.zip |
| Encoding | UTF-8 |
| Limit | 3 000 Verordnungen per Datendatei — so splitting is a requirement, not an optimisation |
| Auth | User/password or SSH key — key preferred for automation |
| Returns | *EPO.xml · *AVO.xml · *NVD.xml · *DIFF.xml |
*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.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.
The rewrite is one method plus consequences. generate_document_template — 263 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.
| Step | What |
|---|---|
| 1. Oracle first | Wire 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 method | Read 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 transport | 185–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 duplication | Six copy-pasted credential/constructor blocks become one ConnectionConfig. |
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.
Seven from the spec's own "🔲 Noch offen" list, plus two of ours. None of them block starting; each blocks a specific piece.
| # | Decision | Blocks | Who |
|---|---|---|---|
| 1 | SFTP 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 |
| 2 | LEGS keys per Kostenträger — which Leistungserbringergruppenschlüssel apply? | Block E serialisation of real data. Not the code. | NOVENTI / Kerstin |
| 3 | EinlieferungsArt — paper only (2), or digital PDFs too (1 / 3)? | Whether the zip carries PDFs at all. Changes scope materially. | product |
| 4 | Sondererfassung1 = Rechnungsnummer — formal confirmation | One field. Cheap to change, embarrassing to get wrong at volume. | NOVENTI |
| 5 | Where the button lives — action menu on filtered §302 invoices? | UI only. Spec already recommends the action bar. | product |
| 6 | Batch selection — all open invoices, or checkbox selection? | Spec recommends checkboxes for control. Take the recommendation. | product |
| 7 | Barcode printing — Code 39 VerordnungsId onto the paper Muster 16 | Work 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 |
| 8 | Versorgungsanfrage → which Odoo object? | Inbound only. Nothing in this design. | product |
| 9 | MIP protocol — the per-customer md5 key suggests signed requests, not REST | Nothing now. MIP is out of scope by your decision. | later |
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.