From bb2d16b148075f266ed1c96bf2c09f3007a787c5 Mon Sep 17 00:00:00 2001 From: sauterbe <49244843+sauterbe@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:15:32 +0200 Subject: [PATCH 1/5] fix(workflows): prepayment cleanup cancelled from a list it never filtered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every predicate and the write itself were fiction against the core: - `paymentMethod`, `paymentStatus` and `maxAgeDays` are not filters on SalesOrder, and `status: "open"` is not one of its options (draft / confirmed / fulfilled / closed / cancelled). In the flat shape none of them filtered anything, so the list came back as the WHOLE order book. - `status` is `access: readOnly`. The old `body: {status: "cancelled"}` could never have cancelled anything — cancelling is the `cancel` process step. - the only guard asked whether the order was ALREADY cancelled, which does not protect against a row that was never supposed to be in the list. Rebuilt against what the core has, all measured on mvp: - `status` and `dates.issued` filter server-side in the bracketed shape. The operator is `lessThan`, not `lt` — upstream answers 400 and names the eight it accepts. A live run caught that; the static check could not. - `payment.method` comes back as `{id, href}` with no name, so the method is resolved through the PaymentMethod catalogue (one call, `Vorkasse` -> paym_21). - `payment.status` is null on a list, so "still unpaid" is read off `trafficLights`, where it is present. - cancelling goes through the `cancel` process step. Measured effect on mvp: the server-side filters return 100 orders, none with the wrong status and none newer than the cutoff, and the guard narrows those to 3 — prepayment and unpaid. The old version would have looped the entire order book. --- .../workflows/prepayment_order_cleanup.json | 159 +++++++++++++----- 1 file changed, 121 insertions(+), 38 deletions(-) diff --git a/library/workflows/prepayment_order_cleanup.json b/library/workflows/prepayment_order_cleanup.json index 6d95436..aef1f75 100644 --- a/library/workflows/prepayment_order_cleanup.json +++ b/library/workflows/prepayment_order_cleanup.json @@ -28,11 +28,14 @@ "Order cleanup" ], "node_titles": { - "n_trig": "Every day at 09:00", - "n_query": "Load open prepayment orders", - "n_loop": "For each prepayment order, one by one", - "n_guard": "Is the order not yet cancelled?", - "n_cancel": "Cancel the order with a reason" + "n_trig": "Start every day at 09:00", + "n_zahlarten": "Load the payment methods", + "n_vorkasse": "Id of the 'prepayment' method (adjust here)", + "n_query": "Load old orders that were never released", + "n_loop": "Check each order", + "n_guard": "Prepayment and still unpaid?", + "n_cancel": "Cancel the order", + "n_behalten": "Paid or a different method — left alone" } } }, @@ -56,7 +59,7 @@ } }, { - "id": "n_query", + "id": "n_zahlarten", "type": "wfNode", "position": { "x": 480, @@ -65,8 +68,51 @@ "data": { "typeId": "business-entity", "config": { - "title": "Offene Vorkasse-Aufträge laden", - "varName": "openPrepayments", + "title": "Zahlungsarten laden", + "varName": "zahlarten", + "entityKey": "PaymentMethod", + "entityLabel": "Payment method", + "operations": [ + "list" + ], + "params": { + "list": { + "query": { + "page[size]": 100 + } + } + } + } + } + }, + { + "id": "n_vorkasse", + "type": "wfNode", + "position": { + "x": 880, + "y": 300 + }, + "data": { + "typeId": "expression", + "config": { + "title": "ID der Zahlungsart 'Vorkasse' (hier anpassen)", + "varName": "vorkasseId", + "expression": "next((z.get('id') for z in (zahlarten or []) if (z.get('name') or '').strip().lower() == 'vorkasse'), None)" + } + } + }, + { + "id": "n_query", + "type": "wfNode", + "position": { + "x": 1280, + "y": 300 + }, + "data": { + "typeId": "business-entity", + "config": { + "title": "Alte, nie freigegebene Aufträge laden", + "varName": "offeneAuftraege", "entityKey": "SalesOrder", "entityLabel": "Sales order", "operations": [ @@ -75,10 +121,17 @@ "params": { "list": { "query": { - "paymentMethod": "prepayment", - "status": "open", - "paymentStatus": "unpaid", - "maxAgeDays": 7 + "filter[0][key]": "status", + "filter[0][op]": "equals", + "filter[0][value]": "draft", + "filter[1][key]": "dates.issued", + "filter[1][op]": "lessThan", + "filter[1][value]": { + "mode": "date", + "expr": "today(-7)" + }, + "page[number]": 1, + "page[size]": 100 } } } @@ -89,15 +142,15 @@ "id": "n_loop", "type": "wfNode", "position": { - "x": 880, + "x": 1680, "y": 300 }, "data": { "typeId": "loop", "config": { - "title": "Für jeden Vorkasse-Auftrag einzeln", - "varName": "order", - "items": "{{ openPrepayments }}" + "title": "Jeden Auftrag prüfen", + "varName": "Auftrag", + "items": "{{ offeneAuftraege }}" } } }, @@ -105,14 +158,14 @@ "id": "n_guard", "type": "wfNode", "position": { - "x": 1280, + "x": 2080, "y": 300 }, "data": { "typeId": "condition", "config": { - "title": "Ist der Auftrag noch nicht storniert?", - "expression": "order.get('status') != 'cancelled'" + "title": "Vorkasse und noch nicht bezahlt?", + "expression": "bool(vorkasseId) and ((auftrag.get('payment') or {}).get('method') or {}).get('id') == vorkasseId and not any(t.get('id') == 'payment' and t.get('state') == 'fullyPaid' for t in (auftrag.get('trafficLights') or []))" } } }, @@ -120,63 +173,93 @@ "id": "n_cancel", "type": "wfNode", "position": { - "x": 1680, - "y": 300 + "x": 2480, + "y": 140 }, "data": { "typeId": "business-entity", "config": { - "title": "Auftrag mit Begründung stornieren", - "varName": "stornieren", + "title": "Auftrag stornieren", + "varName": "storniert", "entityKey": "SalesOrder", "entityLabel": "Sales order", "operations": [ - "update" + "cancel" ], "params": { - "update": { + "cancel": { "path": { "uuid": { "mode": "ref", "from": "n_loop", "path": "id" } - }, - "body": { - "status": "cancelled", - "cancellationReason": "No prepayment received within 7 days" } } } } } + }, + { + "id": "n_behalten", + "type": "wfNode", + "position": { + "x": 2480, + "y": 620 + }, + "data": { + "typeId": "output", + "config": { + "title": "Bezahlt oder andere Zahlungsart — unverändert", + "varName": "behalten", + "value": "Auftrag bleibt bestehen (bezahlt oder keine Vorkasse)." + } + } } ], "edges": [ { "id": "e1", "source": "n_trig", - "target": "n_query", + "target": "n_zahlarten", "targetHandle": "in:list" }, { "id": "e2", - "source": "n_query", - "target": "n_loop", - "sourceHandle": "data:list" + "source": "n_zahlarten", + "sourceHandle": "data:list", + "target": "n_vorkasse" }, { "id": "e3", - "source": "n_loop", - "target": "n_guard", - "sourceHandle": "a" + "source": "n_vorkasse", + "target": "n_query", + "targetHandle": "in:list" }, { "id": "e4", + "source": "n_query", + "sourceHandle": "data:list", + "target": "n_loop" + }, + { + "id": "e5", + "source": "n_loop", + "sourceHandle": "a", + "target": "n_guard" + }, + { + "id": "e6", "source": "n_guard", - "target": "n_cancel", "sourceHandle": "a", - "targetHandle": "in:update" + "target": "n_cancel", + "targetHandle": "in:cancel" + }, + { + "id": "e7", + "source": "n_guard", + "sourceHandle": "b", + "target": "n_behalten" } ] } From 78e709c36716675a35dc70e196a811bd742374c9 Mon Sep 17 00:00:00 2001 From: sauterbe <49244843+sauterbe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:10:38 +0200 Subject: [PATCH 2/5] fix(workflows): two more templates rebuilt against what the core has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same family of defect as prepayment_order_cleanup: invented filter keys and values, and — the conceptual one — a state transition modelled as a field write. `status` is readOnly on every document; releasing is an ACTION. auto_release_paid_orders `status: "paid_awaiting_release"` is not one of the options (draft / confirmed / fulfilled / closed / cancelled), and the old graph WROTE `status` twice, which could never have worked. "Paid" is not filterable on SalesOrder and `payment.status` is null on a list, so it is read off `trafficLights`, where it is present. Releasing now goes through the `release` step, and a second guard refuses to release an order that still carries a hold — the old version checked a `stockAvailable` field that does not exist. daily_opos_digest `paymentStatus: "open"` was wrong twice: the key is `payment.status` and its options are unpaid / partiallyPaid / paid. Both verified against mvp: `SalesOrder status=draft` returns 50 rows, all draft; `SalesInvoice payment.status=unpaid` returns 50, all unpaid; and of the 50 orders, 2 are paid and unheld — the ones the workflow would release. Two further rebuilds are finished but NOT shipped here, because the core cannot serve them yet (see the PR): filtering DeliveryNote by `shipped` and Return by `checked` returns records in a different state. --- .../workflows/auto_release_paid_orders.json | 141 +++++++++--------- library/workflows/daily_opos_digest.json | 10 +- 2 files changed, 79 insertions(+), 72 deletions(-) diff --git a/library/workflows/auto_release_paid_orders.json b/library/workflows/auto_release_paid_orders.json index b35ea4c..e3d8c48 100644 --- a/library/workflows/auto_release_paid_orders.json +++ b/library/workflows/auto_release_paid_orders.json @@ -28,13 +28,14 @@ "Prepayment" ], "node_titles": { - "n_trig": "When a payment is booked", - "n_query": "Load paid orders awaiting release", - "n_loop": "For each order, one by one", - "n_cond": "Does stock cover the order?", - "n_release": "Release the order for picking", - "n_park": "Park the order until stock arrives", - "n_notify": "Draft a shortage note for inventory planning" + "n_trig": "When an order is paid", + "n_query": "Load orders not yet released", + "n_loop": "Check each order", + "n_bezahlt": "Fully paid?", + "n_frei": "No hold (stock, credit limit, delivery block)?", + "n_release": "Release the order", + "n_blockiert": "Paid but held — not released", + "n_offen": "Not paid yet — nothing to do" } } }, @@ -64,8 +65,8 @@ "data": { "typeId": "business-entity", "config": { - "title": "Bezahlte Aufträge laden, die auf Freigabe warten", - "varName": "ordersAwaitingRelease", + "title": "Noch nicht freigegebene Aufträge laden", + "varName": "offeneAuftraege", "entityKey": "SalesOrder", "entityLabel": "Sales order", "operations": [ @@ -74,7 +75,11 @@ "params": { "list": { "query": { - "status": "paid_awaiting_release" + "filter[0][key]": "status", + "filter[0][op]": "equals", + "filter[0][value]": "draft", + "page[number]": 1, + "page[size]": 100 } } } @@ -91,14 +96,14 @@ "data": { "typeId": "loop", "config": { - "title": "Für jeden Auftrag einzeln", - "varName": "order", - "items": "{{ ordersAwaitingRelease }}" + "title": "Jeden Auftrag prüfen", + "varName": "Auftrag", + "items": "{{ offeneAuftraege }}" } } }, { - "id": "n_cond", + "id": "n_bezahlt", "type": "wfNode", "position": { "x": 1280, @@ -107,73 +112,51 @@ "data": { "typeId": "condition", "config": { - "title": "Reicht der Bestand für den Auftrag?", - "expression": "bool(order.get('stockAvailable'))" + "title": "Vollständig bezahlt?", + "expression": "any(t.get('id') == 'payment' and t.get('state') == 'fullyPaid' for t in (auftrag.get('trafficLights') or []))" } } }, { - "id": "n_release", + "id": "n_frei", "type": "wfNode", "position": { "x": 1680, "y": 160 }, "data": { - "typeId": "business-entity", + "typeId": "condition", "config": { - "title": "Auftrag zur Pickliste freigeben", - "varName": "releaseOrder", - "entityKey": "SalesOrder", - "entityLabel": "Sales order", - "operations": [ - "update" - ], - "params": { - "update": { - "path": { - "uuid": { - "mode": "ref", - "from": "n_loop", - "path": "id" - } - }, - "body": { - "status": "released_for_picking" - } - } - } + "title": "Keine Sperre (Bestand, Kreditlimit, Liefersperre)?", + "expression": "not (auftrag.get('holds') or [])" } } }, { - "id": "n_park", + "id": "n_release", "type": "wfNode", "position": { - "x": 1680, - "y": 440 + "x": 2080, + "y": 60 }, "data": { "typeId": "business-entity", "config": { - "title": "Auftrag bis zum Wareneingang parken", - "varName": "parkOrder", + "title": "Auftrag freigeben", + "varName": "freigegeben", "entityKey": "SalesOrder", "entityLabel": "Sales order", "operations": [ - "update" + "release" ], "params": { - "update": { + "release": { "path": { "uuid": { "mode": "ref", "from": "n_loop", "path": "id" } - }, - "body": { - "status": "paid_awaiting_stock" } } } @@ -181,19 +164,34 @@ } }, { - "id": "n_notify", + "id": "n_blockiert", "type": "wfNode", "position": { "x": 2080, - "y": 300 + "y": 380 }, "data": { - "typeId": "agent", + "typeId": "output", "config": { - "title": "Notiz zum Fehlbestand entwerfen", - "varName": "shortageNote", - "model": "gpt-5.1", - "prompt": "Draft a concise note for inventory planning: which items are short for order {{ order.number }} and how many units. Plain, factual, no fluff.\n" + "title": "Bezahlt, aber gesperrt — nicht freigegeben", + "varName": "blockiert", + "value": "Auftrag ist bezahlt, aber eine Ampel blockiert die Freigabe." + } + } + }, + { + "id": "n_offen", + "type": "wfNode", + "position": { + "x": 1680, + "y": 560 + }, + "data": { + "typeId": "output", + "config": { + "title": "Noch nicht bezahlt — nichts zu tun", + "varName": "nochOffen", + "value": "Auftrag ist noch nicht vollständig bezahlt." } } } @@ -208,34 +206,39 @@ { "id": "e2", "source": "n_query", - "target": "n_loop", - "sourceHandle": "data:list" + "sourceHandle": "data:list", + "target": "n_loop" }, { "id": "e3", "source": "n_loop", - "target": "n_cond", - "sourceHandle": "a" + "sourceHandle": "a", + "target": "n_bezahlt" }, { "id": "e4", - "source": "n_cond", - "target": "n_release", + "source": "n_bezahlt", "sourceHandle": "a", - "targetHandle": "in:update" + "target": "n_frei" }, { "id": "e5", - "source": "n_cond", - "target": "n_park", + "source": "n_bezahlt", "sourceHandle": "b", - "targetHandle": "in:update" + "target": "n_offen" }, { "id": "e6", - "source": "n_park", - "target": "n_notify", - "sourceHandle": "data:update" + "source": "n_frei", + "sourceHandle": "a", + "target": "n_release", + "targetHandle": "in:release" + }, + { + "id": "e7", + "source": "n_frei", + "sourceHandle": "b", + "target": "n_blockiert" } ] } diff --git a/library/workflows/daily_opos_digest.json b/library/workflows/daily_opos_digest.json index 7cff032..c0d064f 100644 --- a/library/workflows/daily_opos_digest.json +++ b/library/workflows/daily_opos_digest.json @@ -28,7 +28,7 @@ ], "node_titles": { "n_trig": "Every business day at 07:00", - "n_query": "Load open items from Xentral", + "n_query": "Load unpaid invoices", "n_compose": "Compose the open-items digest", "n_out": "Return the digest draft" } @@ -70,7 +70,7 @@ "data": { "typeId": "business-entity", "config": { - "title": "Offene Posten aus Xentral laden", + "title": "Unbezahlte Rechnungen laden", "varName": "openItems", "entityKey": "SalesInvoice", "entityLabel": "Sales invoice", @@ -80,7 +80,11 @@ "params": { "list": { "query": { - "paymentStatus": "open" + "filter[0][key]": "payment.status", + "filter[0][op]": "equals", + "filter[0][value]": "unpaid", + "page[number]": 1, + "page[size]": 100 } } } From 9364c90149c34bc2fdf9d9df033a67f35285b870 Mon Sep 17 00:00:00 2001 From: sauterbe <49244843+sauterbe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:46:12 +0200 Subject: [PATCH 3/5] fix(workflows): the last two templates, once the core could serve them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were rebuilt earlier and held back because the core returned records in a different state than the filter asked for. agent-cores #89 fixed that, so they ship now. return_to_credit_note `SalesReturn` does not exist — a read of it answers 404. The entity is `Return`. It filtered `creditNoteCreated`, which is not a field; the readable `resolution.creditNote` decides that on the record instead. And the credit note does not have to be assembled from `customerId`/`positions` (neither exists on the model): `Return` carries a `createCreditNote` action. The lifecycle filter is `progress`, not `status` — `status` merges in `cancelled` and cannot answer `checked` (agent-cores #89). invoice_after_shipment `invoiced` is not a filter; `documents.salesInvoices` is readable, so "not yet invoiced" is decided on the record. The invoice is not assembled by hand either — DeliveryNote carries `createSalesInvoice`. Both now do the same thing the whole set was missing: they ask the ERP to perform the transition instead of writing a status field that is readOnly. Server-side filters verified against mvp: `Return progress=checked` returns 7 records, all checked (it used to return 32 that were all settled), and `DeliveryNote status=shipped` now resolves to upstream `sent` and returns 50, all sent (it used to return 1, and that one read as delivered). Requires a CORES_VERSION carrying agent-cores #89. Without it the two filters return the wrong state — which is exactly why these two waited. --- library/workflows/invoice_after_shipment.json | 102 +++++++++------- library/workflows/return_to_credit_note.json | 110 ++++++++++-------- 2 files changed, 120 insertions(+), 92 deletions(-) diff --git a/library/workflows/invoice_after_shipment.json b/library/workflows/invoice_after_shipment.json index 70fe328..fb65b74 100644 --- a/library/workflows/invoice_after_shipment.json +++ b/library/workflows/invoice_after_shipment.json @@ -29,10 +29,11 @@ ], "node_titles": { "n_trig": "When a delivery note is shipped", - "n_query": "Load shipped, uninvoiced delivery notes", - "n_loop": "For each delivery note, one by one", - "n_invoice": "Create the invoice for the delivery note", - "n_mail": "Draft the customer mail for the invoice" + "n_query": "Load shipped delivery notes", + "n_loop": "Check each delivery note", + "n_offen": "Not invoiced yet?", + "n_invoice": "Create the invoice from the delivery note", + "n_schon": "Already invoiced — skipped" } } }, @@ -62,8 +63,8 @@ "data": { "typeId": "business-entity", "config": { - "title": "Versendete, nicht fakturierte Lieferscheine laden", - "varName": "shippedDeliveryNotes", + "title": "Versendete Lieferscheine laden", + "varName": "versendeteLieferscheine", "entityKey": "DeliveryNote", "entityLabel": "Delivery note", "operations": [ @@ -72,8 +73,11 @@ "params": { "list": { "query": { - "status": "shipped", - "invoiced": false + "filter[0][key]": "status", + "filter[0][op]": "equals", + "filter[0][value]": "shipped", + "page[number]": 1, + "page[size]": 100 } } } @@ -90,43 +94,48 @@ "data": { "typeId": "loop", "config": { - "title": "Für jeden Lieferschein einzeln", - "varName": "deliveryNote", - "items": "{{ shippedDeliveryNotes }}" + "title": "Jeden Lieferschein prüfen", + "varName": "Lieferschein", + "items": "{{ versendeteLieferscheine }}" } } }, { - "id": "n_invoice", + "id": "n_offen", "type": "wfNode", "position": { "x": 1280, "y": 300 }, + "data": { + "typeId": "condition", + "config": { + "title": "Noch nicht fakturiert?", + "expression": "not ((lieferschein.get('documents') or {}).get('salesInvoices'))" + } + } + }, + { + "id": "n_invoice", + "type": "wfNode", + "position": { + "x": 1680, + "y": 160 + }, "data": { "typeId": "business-entity", "config": { - "title": "Rechnung zum Lieferschein erstellen", - "varName": "createInvoice", - "entityKey": "SalesInvoice", - "entityLabel": "Sales invoice", + "title": "Rechnung zum Lieferschein erzeugen", + "varName": "rechnung", + "entityKey": "DeliveryNote", + "entityLabel": "Delivery note", "operations": [ - "create" + "createSalesInvoice" ], "params": { - "create": { - "body": { - "customerId": { - "mode": "ref", - "from": "n_loop", - "path": "customerId" - }, - "positions": { - "mode": "ref", - "from": "n_loop", - "path": "positions" - }, - "deliveryNoteRef": { + "createSalesInvoice": { + "path": { + "uuid": { "mode": "ref", "from": "n_loop", "path": "id" @@ -138,19 +147,18 @@ } }, { - "id": "n_mail", + "id": "n_schon", "type": "wfNode", "position": { "x": 1680, - "y": 300 + "y": 480 }, "data": { - "typeId": "agent", + "typeId": "output", "config": { - "title": "Kunden-Mail zur Rechnung entwerfen", - "varName": "invoiceMail", - "model": "gpt-5.1", - "prompt": "Draft a short, friendly mail to the customer for delivery note {{ deliveryNote.number }}: thank them for the order, mention that invoice {{ createInvoice.documentNumber }} is attached. Return the mail text only.\n" + "title": "Bereits fakturiert — übersprungen", + "varName": "bereitsFakturiert", + "value": "Zu diesem Lieferschein existiert schon eine Rechnung." } } } @@ -165,21 +173,27 @@ { "id": "e2", "source": "n_query", - "target": "n_loop", - "sourceHandle": "data:list" + "sourceHandle": "data:list", + "target": "n_loop" }, { "id": "e3", "source": "n_loop", - "target": "n_invoice", "sourceHandle": "a", - "targetHandle": "in:create" + "target": "n_offen" }, { "id": "e4", - "source": "n_invoice", - "target": "n_mail", - "sourceHandle": "data:create" + "source": "n_offen", + "sourceHandle": "a", + "target": "n_invoice", + "targetHandle": "in:createSalesInvoice" + }, + { + "id": "e5", + "source": "n_offen", + "sourceHandle": "b", + "target": "n_schon" } ] } diff --git a/library/workflows/return_to_credit_note.json b/library/workflows/return_to_credit_note.json index bbe82d5..7df8ea7 100644 --- a/library/workflows/return_to_credit_note.json +++ b/library/workflows/return_to_credit_note.json @@ -28,11 +28,12 @@ "Credit note" ], "node_titles": { - "n_trig": "When a return is marked as checked", - "n_query": "Load checked returns without a credit note", - "n_loop": "For each checked return, one by one", - "n_create": "Create a credit note with the checked positions", - "n_mail": "Draft the customer mail for the credit note" + "n_trig": "When a return is checked", + "n_query": "Load checked returns", + "n_loop": "Check each return", + "n_offen": "No credit note yet?", + "n_create": "Create the credit note from the return", + "n_schon": "Already credited — skipped" } } }, @@ -62,18 +63,21 @@ "data": { "typeId": "business-entity", "config": { - "title": "Geprüfte Retouren ohne Gutschrift laden", - "varName": "checkedReturns", - "entityKey": "SalesReturn", - "entityLabel": "Sales return", + "title": "Geprüfte Rücksendungen laden", + "varName": "gepruefteRuecksendungen", + "entityKey": "Return", + "entityLabel": "Return", "operations": [ "list" ], "params": { "list": { "query": { - "status": "checked", - "creditNoteCreated": false + "filter[0][key]": "progress", + "filter[0][op]": "equals", + "filter[0][value]": "checked", + "page[number]": 1, + "page[size]": 100 } } } @@ -90,46 +94,51 @@ "data": { "typeId": "loop", "config": { - "title": "Für jede geprüfte Retoure einzeln", - "varName": "returnOrder", - "items": "{{ checkedReturns }}" + "title": "Jede Rücksendung prüfen", + "varName": "Ruecksendung", + "items": "{{ gepruefteRuecksendungen }}" } } }, { - "id": "n_create", + "id": "n_offen", "type": "wfNode", "position": { "x": 1280, "y": 300 }, + "data": { + "typeId": "condition", + "config": { + "title": "Noch keine Gutschrift erzeugt?", + "expression": "not ((ruecksendung.get('resolution') or {}).get('creditNote'))" + } + } + }, + { + "id": "n_create", + "type": "wfNode", + "position": { + "x": 1680, + "y": 160 + }, "data": { "typeId": "business-entity", "config": { - "title": "Gutschrift mit geprüften Positionen anlegen", - "varName": "createCreditNote", - "entityKey": "SalesCreditNote", - "entityLabel": "Sales credit note", + "title": "Gutschrift aus der Rücksendung erzeugen", + "varName": "gutschrift", + "entityKey": "Return", + "entityLabel": "Return", "operations": [ - "create" + "createCreditNote" ], "params": { - "create": { - "body": { - "customerId": { - "mode": "ref", - "from": "n_loop", - "path": "customerId" - }, - "positions": { + "createCreditNote": { + "path": { + "uuid": { "mode": "ref", "from": "n_loop", - "path": "checkedPositions" - }, - "invoiceRef": { - "mode": "ref", - "from": "n_loop", - "path": "invoiceId" + "path": "id" } } } @@ -138,19 +147,18 @@ } }, { - "id": "n_mail", + "id": "n_schon", "type": "wfNode", "position": { "x": 1680, - "y": 300 + "y": 480 }, "data": { - "typeId": "agent", + "typeId": "output", "config": { - "title": "Kunden-Mail zur Gutschrift entwerfen", - "varName": "mailEntwurf", - "model": "gpt-5.1", - "prompt": "Draft a short mail to the customer: the return was checked, credit note {{ createCreditNote.documentNumber }} is attached, refund in 3-5 business days. Return the mail text only.\n" + "title": "Bereits gutgeschrieben — übersprungen", + "varName": "bereitsGutgeschrieben", + "value": "Zu dieser Rücksendung existiert schon eine Gutschrift." } } } @@ -165,21 +173,27 @@ { "id": "e2", "source": "n_query", - "target": "n_loop", - "sourceHandle": "data:list" + "sourceHandle": "data:list", + "target": "n_loop" }, { "id": "e3", "source": "n_loop", - "target": "n_create", "sourceHandle": "a", - "targetHandle": "in:create" + "target": "n_offen" }, { "id": "e4", - "source": "n_create", - "target": "n_mail", - "sourceHandle": "data:create" + "source": "n_offen", + "sourceHandle": "a", + "target": "n_create", + "targetHandle": "in:createCreditNote" + }, + { + "id": "e5", + "source": "n_offen", + "sourceHandle": "b", + "target": "n_schon" } ] } From 8e1c9f4d761cff58eadcc7d0aec222aacec239e5 Mon Sep 17 00:00:00 2001 From: sauterbe <49244843+sauterbe@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:58:20 +0200 Subject: [PATCH 4/5] fix(workflows): low-stock proposal reads stock where it actually lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `belowMinStock: true` is not a field on Product. In the flat shape it filtered nothing, so the workflow proposed purchase orders for the whole catalogue. The real constraint is that `stock` is a detail-only section: a product LIST always returns it null, so "below minimum" cannot be decided from a list at all. What IS on the list is `logistics.minimumStockQuantity` and `suppliers` — so the loop spends a `StockLevel` call only on articles that carry a minimum. Measured on mvp: 100 active products in one call, 10 of them carry a minimum, so the per-article call is paid 10 times rather than 100. All 10 are below minimum and NONE has a default supplier, so the run ends with zero purchase orders and ten records on the "below minimum, no supplier" branch. That branch is the point. A purchase order needs a supplier; without one the old shape would have produced an order with no recipient, or dropped the article silently. Now it is visible. Also fixed while building: the loop referenced `{{ artikel_liste }}`, and the renderer derives `artikelListe` — camelCase, not snake_case. The list resolved to nothing, the loop skipped, and its whole body (including the StockLevel read) never rendered. The renderer says so itself in the skip message; the static validator reports it as `loop_items_unresolved`. --- .../workflows/low_stock_reorder_proposal.json | 243 +++++++++++++++--- 1 file changed, 212 insertions(+), 31 deletions(-) diff --git a/library/workflows/low_stock_reorder_proposal.json b/library/workflows/low_stock_reorder_proposal.json index 550b571..3979dc9 100644 --- a/library/workflows/low_stock_reorder_proposal.json +++ b/library/workflows/low_stock_reorder_proposal.json @@ -27,11 +27,18 @@ "Stock" ], "node_titles": { - "n_trig": "Every day at 06:00", - "n_query": "Load products below minimum stock", - "n_group": "Group products by main supplier", - "n_loop": "For each supplier, one by one", - "n_po": "Create a draft purchase order" + "n_trig": "Start every night", + "n_query": "Load active articles", + "n_loop": "Check each article", + "n_hat_min": "Is a minimum stock maintained at all?", + "n_bestand": "Load the article's stock", + "n_unter": "Below minimum?", + "n_hat_lief": "Is a supplier set?", + "n_merken": "Collect the shortfall per supplier", + "n_ohne_lief": "Below minimum but no supplier", + "n_gruppen": "Build one proposal per supplier", + "n_loop2": "One order per supplier", + "n_po": "Create the purchase order as a draft" } } }, @@ -64,8 +71,8 @@ "data": { "typeId": "business-entity", "config": { - "title": "Artikel unter Mindestbestand laden", - "varName": "lowStockProducts", + "title": "Aktive Artikel laden", + "varName": "artikelListe", "entityKey": "Product", "entityLabel": "Product", "operations": [ @@ -74,7 +81,11 @@ "params": { "list": { "query": { - "belowMinStock": true + "filter[0][key]": "status", + "filter[0][op]": "equals", + "filter[0][value]": "active", + "page[number]": 1, + "page[size]": 100 } } } @@ -82,34 +93,162 @@ } }, { - "id": "n_group", + "id": "n_loop", "type": "wfNode", "position": { "x": 880, "y": 300 }, + "data": { + "typeId": "loop", + "config": { + "title": "Jeden Artikel prüfen", + "varName": "Artikel", + "items": "{{ artikelListe }}" + } + } + }, + { + "id": "n_hat_min", + "type": "wfNode", + "position": { + "x": 1280, + "y": 140 + }, + "data": { + "typeId": "condition", + "config": { + "title": "Ist überhaupt ein Mindestbestand gepflegt?", + "expression": "float(((artikel.get('logistics') or {}).get('minimumStockQuantity')) or 0) > 0" + } + } + }, + { + "id": "n_bestand", + "type": "wfNode", + "position": { + "x": 1680, + "y": 60 + }, + "data": { + "typeId": "business-entity", + "config": { + "title": "Bestand des Artikels laden", + "varName": "bestand", + "entityKey": "StockLevel", + "entityLabel": "Stock level", + "operations": [ + "list" + ], + "params": { + "list": { + "query": { + "filter[0][key]": "product", + "filter[0][op]": "equals", + "filter[0][value]": { + "mode": "ref", + "from": "n_loop", + "path": "id" + }, + "page[number]": 1, + "page[size]": 50 + } + } + } + } + } + }, + { + "id": "n_unter", + "type": "wfNode", + "position": { + "x": 2080, + "y": 60 + }, + "data": { + "typeId": "condition", + "config": { + "title": "Unter Mindestbestand?", + "expression": "sum(float(((s.get('available') or {}).get('value')) or 0) for s in (bestand or [])) < float(((artikel.get('logistics') or {}).get('minimumStockQuantity')) or 0)" + } + } + }, + { + "id": "n_hat_lief", + "type": "wfNode", + "position": { + "x": 2480, + "y": 0 + }, + "data": { + "typeId": "condition", + "config": { + "title": "Ist ein Lieferant hinterlegt?", + "expression": "bool(((artikel.get('suppliers') or [{}])[0].get('supplier') or {}).get('id'))" + } + } + }, + { + "id": "n_merken", + "type": "wfNode", + "position": { + "x": 2880, + "y": 0 + }, "data": { "typeId": "code", "config": { - "title": "Artikel nach Hauptlieferant gruppieren", - "varName": "supplierGroups", - "code": "rows = lowStockProducts if isinstance(lowStockProducts, list) else []\ngroups = {}\nfor p in rows:\n groups.setdefault(p.get('mainSupplierId'), []).append(p)\nresult = [{'supplierId': k, 'items': v} for k, v in groups.items()]\nlog(f\"{len(result)} supplier group(s)\")\n" + "title": "Fehlmenge je Lieferant sammeln", + "varName": "gesammelt", + "code": "# Group what is missing by supplier — one purchase order per supplier, not per\n# article. `bedarf` is keyed by supplier id and survives the loop in `context`.\nverfuegbar = sum(float(((s.get('available') or {}).get('value')) or 0) for s in (bestand or []))\nminimum = float(((artikel.get('logistics') or {}).get('minimumStockQuantity')) or 0)\nfehlmenge = max(1.0, minimum - verfuegbar)\nlieferant = ((artikel.get('suppliers') or [{}])[0].get('supplier') or {}).get('id')\ncontext.setdefault('bedarf', {}).setdefault(lieferant, []).append({\n 'product': artikel.get('id'),\n 'quantity': fehlmenge,\n})\nlog(f\"{artikel.get('id')}: {verfuegbar} von {minimum} -> {fehlmenge} nachbestellen\")\nresult = fehlmenge\n" } } }, { - "id": "n_loop", + "id": "n_ohne_lief", + "type": "wfNode", + "position": { + "x": 2880, + "y": 260 + }, + "data": { + "typeId": "output", + "config": { + "title": "Unter Mindestbestand, aber kein Lieferant", + "varName": "ohneLieferant", + "value": "Artikel liegt unter Mindestbestand, hat aber keinen hinterlegten Lieferanten — es kann keine Bestellung erzeugt werden." + } + } + }, + { + "id": "n_gruppen", "type": "wfNode", "position": { "x": 1280, - "y": 300 + "y": 620 + }, + "data": { + "typeId": "code", + "config": { + "title": "Bestellvorschläge je Lieferant bilden", + "varName": "vorschlaege", + "code": "# One entry per supplier, in the shape the create node writes.\nresult = [\n {'supplier': sid, 'items': items}\n for sid, items in (context.get('bedarf') or {}).items()\n]\nlog(f\"{len(result)} Bestellvorschlag/-vorschlaege\")\n" + } + } + }, + { + "id": "n_loop2", + "type": "wfNode", + "position": { + "x": 1680, + "y": 620 }, "data": { "typeId": "loop", "config": { - "title": "Für jeden Lieferanten einzeln", - "varName": "supplierGroup", - "items": "{{ supplierGroups }}" + "title": "Je Lieferant eine Bestellung", + "varName": "Vorschlag", + "items": "{{ vorschlaege }}" } } }, @@ -117,13 +256,13 @@ "id": "n_po", "type": "wfNode", "position": { - "x": 1680, - "y": 300 + "x": 2080, + "y": 620 }, "data": { "typeId": "business-entity", "config": { - "title": "Bestellvorschlag als Entwurf anlegen", + "title": "Bestellung im Entwurf anlegen", "varName": "bestellung", "entityKey": "PurchaseOrder", "entityLabel": "Purchase order", @@ -133,15 +272,14 @@ "params": { "create": { "body": { - "supplierId": { + "supplier": { "mode": "ref", - "from": "n_loop", - "path": "supplierId" + "from": "n_loop2", + "path": "supplier" }, - "status": "draft", - "positions": { + "items": { "mode": "ref", - "from": "n_loop", + "from": "n_loop2", "path": "items" } } @@ -161,19 +299,62 @@ { "id": "e2", "source": "n_query", - "target": "n_group", - "sourceHandle": "data:list" + "sourceHandle": "data:list", + "target": "n_loop" }, { "id": "e3", - "source": "n_group", - "target": "n_loop" + "source": "n_loop", + "sourceHandle": "a", + "target": "n_hat_min" }, { "id": "e4", + "source": "n_hat_min", + "sourceHandle": "a", + "target": "n_bestand", + "targetHandle": "in:list" + }, + { + "id": "e5", + "source": "n_bestand", + "sourceHandle": "data:list", + "target": "n_unter" + }, + { + "id": "e6", + "source": "n_unter", + "sourceHandle": "a", + "target": "n_hat_lief" + }, + { + "id": "e7", + "source": "n_hat_lief", + "sourceHandle": "a", + "target": "n_merken" + }, + { + "id": "e8", + "source": "n_hat_lief", + "sourceHandle": "b", + "target": "n_ohne_lief" + }, + { + "id": "e9", "source": "n_loop", - "target": "n_po", + "sourceHandle": "b", + "target": "n_gruppen" + }, + { + "id": "e10", + "source": "n_gruppen", + "target": "n_loop2" + }, + { + "id": "e11", + "source": "n_loop2", "sourceHandle": "a", + "target": "n_po", "targetHandle": "in:create" } ] From 8d9d1b8c143fb4f5408ac69d66be471b37cdf9a8 Mon Sep 17 00:00:00 2001 From: sauterbe <49244843+sauterbe@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:03:24 +0200 Subject: [PATCH 5/5] fix(workflows): dunning escalation, now that the level is writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I had recommended withdrawing this template, on the grounds that dunning has no public API. That was wrong — `dunningSettings` sits in `UpdateInvoiceData` and the level is writable (agent-cores #90). The old version was still fiction: it filtered `paymentStatus` and `minOverdueDays`, neither of which is a filter, and wrote `body: {dunningLevel: 3}`, which is not a field. Rebuilt against what the core has: - `payment.status = unpaid` plus `dates.issued lessThan today(-14)` server-side. - Due date is computed from the document date and the invoice's own payment terms, because `payment.dueDate` comes back EMPTY on a list — measured, null on all 100 unpaid invoices on mvp. `dates.issued` and `payment.terms.dueDays` are both there. - The level walks the upstream ladder (paymentReminder1 -> reminder1..3 -> debtCollection -> lossOfReceivables) and stops at the top instead of writing past it. - A dunning block is honoured, and every skip says why in the run log. The write is `body: {dunning: }` — one whole object bound at the TOP level, because a `{mode: ref}` nested deeper inside a body is not resolved and would travel to the ERP verbatim. The date maths uses the runtime's `days_since` helper: a plain `from datetime import ...` in a code box is refused by the AST allowlist (`unsafe_code`), which the static validator caught before this ever ran. Measured on mvp: 100 unpaid invoices issued before the cutoff, all on `reminder3`, all overdue — every one would escalate to `debtCollection`, none is blocked or already at the top. Both descriptions now say what the workflow does NOT do: it records the level, it does not SEND a reminder. No endpoint does. Needs a CORES_VERSION carrying agent-cores #90. --- .../workflows/opos_dunning_escalation.json | 163 +++++++----------- 1 file changed, 64 insertions(+), 99 deletions(-) diff --git a/library/workflows/opos_dunning_escalation.json b/library/workflows/opos_dunning_escalation.json index 0e7ae62..29f090d 100644 --- a/library/workflows/opos_dunning_escalation.json +++ b/library/workflows/opos_dunning_escalation.json @@ -10,7 +10,7 @@ "locales": { "de": { "name": "Mahnstufen automatisch eskalieren", - "description": "Geht jeden Morgen die Liste der offenen Posten durch und setzt für\njede überfällige Rechnung die passende Mahnstufe abhängig von der\nÜberfälligkeit (30/60 Tage) — so eskaliert das Mahnwesen von selbst\nund kein offener Posten bleibt unbemerkt liegen.\n\n**So funktioniert der Algorithmus**\n\n```\nOffene Rechnungen (mind. 14 Tage überfällig)\n └─ für jede Rechnung → wie lange überfällig?\n ├─ ab 60 Tagen → Mahnstufe 3 setzen\n ├─ ab 30 Tagen → Mahnstufe 2 setzen\n └─ sonst (14–29 T.) → Mahnstufe 1 setzen\n```\n\n**Was Schritt für Schritt passiert**\n\n1. Der Workflow startet jeden Tag um 07:30 Uhr.\n2. Alle offenen Rechnungen, die mindestens 14 Tage überfällig\n sind, werden geladen.\n3. Für jede überfällige Rechnung einzeln wird geprüft, wie lange\n sie überfällig ist.\n4. Ab 60 Tagen wird Mahnstufe 3 gesetzt, ab 30 Tagen Mahnstufe 2,\n darunter (14 bis 29 Tage) Mahnstufe 1.\n\n**Was verändert wird**\n\n- Ausschließlich das Feld „Mahnstufe\" an den betroffenen\n Rechnungen wird gesetzt.\n- Es werden keine Mahnungen verschickt und keine Belege erzeugt —\n der Versand bleibt beim regulären Mahnlauf bzw. den\n Mitarbeitenden.\n\n**Voraussetzungen und Grenzen**\n\n- Zahlungsstatus und Fälligkeitsdaten der Rechnungen müssen\n gepflegt sein.\n- Rechnungen, die weniger als 14 Tage überfällig sind, werden\n bewusst nicht angefasst.\n- Die Mahnstufe wird absolut gesetzt (nicht „+1\") —\n Wiederholungsläufe sind dadurch unkritisch, eine manuell höher\n gesetzte Stufe kann aber wieder heruntergestuft werden.\n", + "description": "Geht jeden Morgen die Liste der offenen Posten durch und setzt für\njede überfällige Rechnung die passende Mahnstufe abhängig von der\nÜberfälligkeit (30/60 Tage) — so eskaliert das Mahnwesen von selbst\nund kein offener Posten bleibt unbemerkt liegen.\n\n**So funktioniert der Algorithmus**\n\n```\nOffene Rechnungen (mind. 14 Tage überfällig)\n └─ für jede Rechnung → wie lange überfällig?\n ├─ ab 60 Tagen → Mahnstufe 3 setzen\n ├─ ab 30 Tagen → Mahnstufe 2 setzen\n └─ sonst (14–29 T.) → Mahnstufe 1 setzen\n```\n\n**Was Schritt für Schritt passiert**\n\n1. Der Workflow startet jeden Tag um 07:30 Uhr.\n2. Alle offenen Rechnungen, die mindestens 14 Tage überfällig\n sind, werden geladen.\n3. Für jede überfällige Rechnung einzeln wird geprüft, wie lange\n sie überfällig ist.\n4. Ab 60 Tagen wird Mahnstufe 3 gesetzt, ab 30 Tagen Mahnstufe 2,\n darunter (14 bis 29 Tage) Mahnstufe 1.\n\n**Was verändert wird**\n\n- Ausschließlich das Feld „Mahnstufe\" an den betroffenen\n Rechnungen wird gesetzt.\n- Es werden keine Mahnungen verschickt und keine Belege erzeugt —\n der Versand bleibt beim regulären Mahnlauf bzw. den\n Mitarbeitenden.\n\n**Voraussetzungen und Grenzen**\n\n- Zahlungsstatus und Fälligkeitsdaten der Rechnungen müssen\n gepflegt sein.\n- Rechnungen, die weniger als 14 Tage überfällig sind, werden\n bewusst nicht angefasst.\n- Die Mahnstufe wird absolut gesetzt (nicht „+1\") —\n Wiederholungsläufe sind dadurch unkritisch, eine manuell höher\n gesetzte Stufe kann aber wieder heruntergestuft werden.\n\n**Was dieser Workflow NICHT tut**\n\nEr hebt die Mahnstufe an der Rechnung an — er **versendet keine Mahnung**.\nDafür gibt es keinen öffentlichen Endpunkt. Der Versand bleibt der\nregulären Mahnläufe-Funktion in Xentral überlassen; dieser Workflow hält\nnur den Stand fest.\n\nDie Fälligkeit wird aus Belegdatum und Zahlungsziel der Rechnung\ngerechnet, weil `payment.dueDate` auf einer Liste leer zurückkommt.\nRechnungen mit gesetzter Mahnsperre werden übersprungen.\n", "recommended_when": "Sobald regelmäßig offene Posten anfallen", "tags": [ "Finanzen", @@ -20,7 +20,7 @@ }, "en": { "name": "Auto-escalate dunning levels", - "description": "Walks the open-items list every morning and sets the appropriate\ndunning level on each overdue invoice depending on how long it has\nbeen overdue (30/60 days) — dunning escalates on its own and no open\nitem goes unnoticed.\n\n**How the algorithm works**\n\n```\nOpen invoices (at least 14 days overdue)\n └─ for each invoice → how long overdue?\n ├─ 60+ days → set dunning level 3\n ├─ 30+ days → set dunning level 2\n └─ else (14–29 days) → set dunning level 1\n```\n\n**What happens step by step**\n\n1. The workflow starts every day at 07:30.\n2. All open invoices that are at least 14 days overdue are\n loaded.\n3. Each overdue invoice is checked individually for how long it\n has been overdue.\n4. From 60 days on dunning level 3 is set, from 30 days on\n dunning level 2, below that (14 to 29 days) dunning level 1.\n\n**What gets changed**\n\n- Only the \"dunning level\" field on the affected invoices is set.\n- No dunning letters are sent and no documents are created —\n sending remains with the regular dunning run or with the staff.\n\n**Prerequisites and limits**\n\n- Payment status and due dates of the invoices must be\n maintained.\n- Invoices less than 14 days overdue are deliberately left\n untouched.\n- The dunning level is set absolutely (not \"+1\") — re-runs are\n therefore harmless, but a manually raised level may be lowered\n again.\n", + "description": "Walks the open-items list every morning and sets the appropriate\ndunning level on each overdue invoice depending on how long it has\nbeen overdue (30/60 days) — dunning escalates on its own and no open\nitem goes unnoticed.\n\n**How the algorithm works**\n\n```\nOpen invoices (at least 14 days overdue)\n └─ for each invoice → how long overdue?\n ├─ 60+ days → set dunning level 3\n ├─ 30+ days → set dunning level 2\n └─ else (14–29 days) → set dunning level 1\n```\n\n**What happens step by step**\n\n1. The workflow starts every day at 07:30.\n2. All open invoices that are at least 14 days overdue are\n loaded.\n3. Each overdue invoice is checked individually for how long it\n has been overdue.\n4. From 60 days on dunning level 3 is set, from 30 days on\n dunning level 2, below that (14 to 29 days) dunning level 1.\n\n**What gets changed**\n\n- Only the \"dunning level\" field on the affected invoices is set.\n- No dunning letters are sent and no documents are created —\n sending remains with the regular dunning run or with the staff.\n\n**Prerequisites and limits**\n\n- Payment status and due dates of the invoices must be\n maintained.\n- Invoices less than 14 days overdue are deliberately left\n untouched.\n- The dunning level is set absolutely (not \"+1\") — re-runs are\n therefore harmless, but a manually raised level may be lowered\n again.\n\n**What this workflow does NOT do**\n\nIt raises the dunning level on the invoice — it does **not send a\nreminder**. There is no public endpoint for that. Sending stays with\nXentral's own dunning run; this workflow only records the step.\n\nDue date is computed from the document date and the payment terms,\nbecause `payment.dueDate` comes back empty on a list. Invoices with a\ndunning block are skipped.\n", "recommended_when": "As soon as open items accumulate regularly", "tags": [ "Finance", @@ -28,13 +28,13 @@ "Open items" ], "node_titles": { - "n_trig": "Every day at 07:30", - "n_query": "Load overdue open invoices", - "n_loop": "For each overdue invoice, one by one", - "n_rules": "How long has the invoice been overdue?", - "n_dun_3": "Set dunning level 3 (60+ days)", - "n_dun_2": "Set dunning level 2 (30+ days)", - "n_dun_1": "Set dunning level 1 (14+ days)" + "n_trig": "Start every day", + "n_query": "Load unpaid, overdue invoices", + "n_loop": "Check each invoice", + "n_pruefen": "Overdue? Then work out the next dunning level", + "n_moeglich": "Is there a next level?", + "n_set": "Raise the dunning level", + "n_nichts": "No escalation needed" } } }, @@ -67,8 +67,8 @@ "data": { "typeId": "business-entity", "config": { - "title": "Überfällige offene Rechnungen laden", - "varName": "overdueInvoices", + "title": "Unbezahlte, ueberfaellige Rechnungen laden", + "varName": "rechnungen", "entityKey": "SalesInvoice", "entityLabel": "Sales invoice", "operations": [ @@ -77,8 +77,17 @@ "params": { "list": { "query": { - "paymentStatus": "open", - "minOverdueDays": 14 + "filter[0][key]": "payment.status", + "filter[0][op]": "equals", + "filter[0][value]": "unpaid", + "filter[1][key]": "dates.issued", + "filter[1][op]": "lessThan", + "filter[1][value]": { + "mode": "date", + "expr": "today(-14)" + }, + "page[number]": 1, + "page[size]": 100 } } } @@ -95,82 +104,55 @@ "data": { "typeId": "loop", "config": { - "title": "Für jede überfällige Rechnung einzeln", - "varName": "invoice", - "items": "{{ overdueInvoices }}" + "title": "Jede Rechnung pruefen", + "varName": "Rechnung", + "items": "{{ rechnungen }}" } } }, { - "id": "n_rules", + "id": "n_pruefen", "type": "wfNode", "position": { "x": 1280, "y": 300 }, "data": { - "typeId": "rule-group", + "typeId": "code", "config": { - "title": "Wie lange ist die Rechnung überfällig?", - "rules": [ - { - "label": "60+ days", - "expression": "invoice.get('overdueDays', 0) >= 60" - }, - { - "label": "30+ days", - "expression": "invoice.get('overdueDays', 0) >= 30" - } - ] + "title": "Faellig? Dann die naechste Mahnstufe bestimmen", + "varName": "naechsteStufe", + "code": "# Faellig? `payment.dueDate` kommt auf der Liste leer zurueck, also aus dem\n# Belegdatum und dem Zahlungsziel der Rechnung selbst rechnen. `days_since` ist\n# der Datums-Helfer der Laufzeit - ein eigener Import waere nicht erlaubt.\nausgestellt = ((rechnung.get('dates') or {}).get('issued')) or ''\nziel = int((((rechnung.get('payment') or {}).get('terms') or {}).get('dueDays')) or 0)\nueberfaellig = (days_since(ausgestellt) - ziel) if ausgestellt else None\n\nstufen = ['paymentReminder1', 'reminder1', 'reminder2', 'reminder3',\n 'debtCollection', 'lossOfReceivables']\naktuell = (rechnung.get('dunning') or {}).get('level')\nnummer = rechnung.get('number')\n\nresult = None\nif bool((rechnung.get('dunning') or {}).get('blocked')):\n log(f\"{nummer}: Mahnsperre gesetzt - uebersprungen\")\nelif ueberfaellig is None or ueberfaellig <= 0:\n log(f\"{nummer}: noch nicht faellig\")\nelif not aktuell:\n result = {'level': stufen[0]}\nelif aktuell in stufen and stufen.index(aktuell) + 1 < len(stufen):\n result = {'level': stufen[stufen.index(aktuell) + 1]}\nelse:\n log(f\"{nummer}: hoechste Stufe ({aktuell}) bereits erreicht\")\nif result:\n log(f\"{nummer}: {ueberfaellig} Tage ueberfaellig, {aktuell or 'keine'} -> {result['level']}\")\n" } } }, { - "id": "n_dun_3", + "id": "n_moeglich", "type": "wfNode", "position": { "x": 1680, - "y": 580 + "y": 300 }, "data": { - "typeId": "business-entity", + "typeId": "condition", "config": { - "title": "Mahnstufe 3 setzen (ab 60 Tagen)", - "varName": "mahnstufe3", - "entityKey": "SalesInvoice", - "entityLabel": "Sales invoice", - "operations": [ - "update" - ], - "params": { - "update": { - "path": { - "uuid": { - "mode": "ref", - "from": "n_loop", - "path": "id" - } - }, - "body": { - "dunningLevel": 3 - } - } - } + "title": "Gibt es eine naechste Stufe?", + "expression": "bool(naechsteStufe)" } } }, { - "id": "n_dun_2", + "id": "n_set", "type": "wfNode", "position": { - "x": 1680, - "y": 300 + "x": 2080, + "y": 160 }, "data": { "typeId": "business-entity", "config": { - "title": "Mahnstufe 2 setzen (ab 30 Tagen)", - "varName": "mahnstufe2", + "title": "Mahnstufe hochsetzen", + "varName": "gemahnt", "entityKey": "SalesInvoice", "entityLabel": "Sales invoice", "operations": [ @@ -186,7 +168,11 @@ } }, "body": { - "dunningLevel": 2 + "dunning": { + "mode": "ref", + "from": "n_pruefen", + "path": "" + } } } } @@ -194,36 +180,18 @@ } }, { - "id": "n_dun_1", + "id": "n_nichts", "type": "wfNode", "position": { - "x": 1680, - "y": 20 + "x": 2080, + "y": 520 }, "data": { - "typeId": "business-entity", + "typeId": "output", "config": { - "title": "Mahnstufe 1 setzen (ab 14 Tagen)", - "varName": "mahnstufe1", - "entityKey": "SalesInvoice", - "entityLabel": "Sales invoice", - "operations": [ - "update" - ], - "params": { - "update": { - "path": { - "uuid": { - "mode": "ref", - "from": "n_loop", - "path": "id" - } - }, - "body": { - "dunningLevel": 1 - } - } - } + "title": "Keine Eskalation noetig", + "varName": "keineEskalation", + "value": "Rechnung nicht faellig, gesperrt oder bereits auf der hoechsten Mahnstufe." } } } @@ -238,35 +206,32 @@ { "id": "e2", "source": "n_query", - "target": "n_loop", - "sourceHandle": "data:list" + "sourceHandle": "data:list", + "target": "n_loop" }, { "id": "e3", "source": "n_loop", - "target": "n_rules", - "sourceHandle": "a" + "sourceHandle": "a", + "target": "n_pruefen" }, { "id": "e4", - "source": "n_rules", - "target": "n_dun_3", - "sourceHandle": "r0", - "targetHandle": "in:update" + "source": "n_pruefen", + "target": "n_moeglich" }, { "id": "e5", - "source": "n_rules", - "target": "n_dun_2", - "sourceHandle": "r1", + "source": "n_moeglich", + "sourceHandle": "a", + "target": "n_set", "targetHandle": "in:update" }, { "id": "e6", - "source": "n_rules", - "target": "n_dun_1", - "sourceHandle": "else", - "targetHandle": "in:update" + "source": "n_moeglich", + "sourceHandle": "b", + "target": "n_nichts" } ] }