# Editing a tender

## Editing a tender
`PATCH /tenders/{tender_id}` is a **partial, in-place** edit that returns the
re-projected tender.
**Recommended: send only what changed (sparse).** Put the lines you touched in a
top-level `positions[]`, each addressed by its `id` — no chapter path needed
(line ids are unique within a tender). This is the primary contract for keeping
an ERP in sync and the one that scales: the body is proportional to your edit,
not to the tender.

```
PATCH /tenders/63365
{ "positions": [ { "id": "10", "net_price": "1.00" }, { "id": "11", "op": "delete" } ] }
```
**Also supported: full-document round-trip.** The body is field-aligned with the
GET response, so you *can* GET a tender, change fields in the nested `chapters[]`
tree, and send it all back — read-only fields are tolerated and ignored, unknown
keys are a `400`. Prefer sparse for large tenders: a full round-trip re-sends and
re-resolves every line. (A discounted line's GET carries `list_price` +
`discount_percentage` + `net_price` together; that's accepted as long as they're
consistent — but if you *change* a price, send only the field you changed, or a
contradictory pair is a `400`.)
- **Edit a line** — top-level `positions[]` (by `id`), or under its chapter.
Price resolution: `list_price` (Brutto) is the anchor, then **at most one** of
`discount_percentage` / `net_price` resolves the other:
  - `discount_percentage` is a **manual** discount applied *on top of* any
catalogue/pricing-rule discount already on the line — i.e. on the
net-of-catalogue base, exactly as the Mercura UI applies it — not a raw
percentage off `list_price`. Editing it recomputes `net_price` off that base
(a catalogue discount is preserved).
  - `net_price` sets the exact per-unit net; the resulting `discount_percentage`
is back-computed from it. **To pin an exact net, send `net_price`.**
Also editable: `quantity`, `unit`, `additional_text`. `description` is editable
only on a free-text line — on an ARTICLE line it comes from the catalogue and
is read-only (sent values are ignored, so a GET body still round-trips).
**`manufacturer_article_number` — the line's own number.** Editable on a
free-text line, both on an edit and on an add. A line Mercura matched to no
catalogue article has no `article_number`; this is where the number it *does*
carry belongs — a sourced third-party product, an EAN, a competitor's type
code, a service code. It is stored verbatim and **never resolved** against
your article master, so any value is accepted, it reaches the ERP export, and
it comes back on the next GET. An empty string clears it; `null` changes
nothing. On an **ARTICLE** line it is ignored (so a GET body round-trips):
there the column is the matched article's own number, which an
`article_number` swap re-resolves from the catalogue, so a value sent here
would not survive. Sending a number that is not in your article master as
`article_number` is a `400` — that field resolves against the catalogue, and a
number it can never find must not arrive as one.
**Replacing the article — `article_number` on an ARTICLE line.** Send a
different catalogue number to put another article on the line, together with
a price (`list_price`, `net_price` or `discount_percentage`) — the stored one
belongs to the article you replace, so a swap without a price is a `400`.
Echoing the line's current number is not a swap and needs no price. A swap is
recorded as a **correction of Mercura's match**, exactly like a user replacing
the article in the Mercura app: the line keeps its `id` and carries the new
article, the replaced article is kept as a removed line (it comes back flagged
under `include_deleted=true`, see *Removed lines and positions*), and Mercura
learns from the correction for future matching — also when you send it after
the tender was exported.
Note on `additional_text` — it behaves differently per line type, because on
an ARTICLE line the stored column reaches **no Mercura-side output** (neither
the offer PDF nor the GAEB export, and the ERP payload omits it there):
  - on a **free-text** line it writes the line's own `additional_text`, which
several ERP integrations consume as their long-text element (an empty string
clears it);
  - on an **ARTICLE** line it writes that line's **article note** instead — the
same row `parent_line_id` creates (below) — so the text does reach the offer
PDF, the GAEB export and the parent line's ERP remark. One ERP record stays
one payload object: send the article's fields and its long text together,
without a second entry.
On an ARTICLE line an empty string (`""`) clears the note, while `null`
changes nothing — GET returns the note as its own line and `null` on the
parent, so a full round-tripped body must not delete the note it just read.
A note has three addresses — its own line `id`, its parent's `parent_line_id`,
and its parent's `additional_text` — and using more than one of them for the
same note in a single request is a `400` rather than a last-one-wins
overwrite (or, with a competing `op: "delete"`, a silent loss of the text you
just sent). Address each note once per request.
**Edge case on older data.** An ARTICLE line can still carry a value in the
`additional_text` column from before this behaviour existed. GET echoes that
value on the parent *and* the note as its own line, so replaying the whole
body addresses one note twice and hits the `400` above. It is deterministic,
and one PATCH clears it: send that line's `additional_text` **on its own**
(without also echoing the note line). That rewrites the note and drops the
dead column value, after which full-body round-trips are clean. Lines whose
column was already empty — every line written since this release — are
unaffected.
- **Annotate a line (article note)** — send `parent_line_id` (the `id` of the
line to annotate) plus `description`, and **no** `id`. The note becomes a text
line on the parent's own position — no new position, no own position number —
and its text follows the path an operator-typed note takes: into Mercura's
offer PDF and GAEB export, and folded into the parent line's remark
(`Bemerkung`) in the ERP payload.
This is the one add that needs no chapter path (the parent line already names
its position), so it is accepted in the top-level `positions[]` too. It is an
**upsert**: sending a note for a parent that already has one *replaces* that
note, so a retried PATCH cannot pile up duplicates. A note is text only —
`article_number`, prices, `quantity`, `unit` and `additional_text` on a note
are rejected with a `400` rather than silently dropped, and a note never
enters the totals. To remove one, address the note line itself with
`{ "id": "…", "op": "delete" }`, or send `additional_text: ""` on its parent.
The parent's `additional_text` (above) is the ergonomic form of the same
write: use `parent_line_id` when the note is its own record on your side, and
`additional_text` when the text lives in the same record as the article.
- **Offer an alternative or an accessory** — add a **new article line onto an
existing position**, marked with one label. It sits under the main line as a
sub-line, the way an operator's alternative does: same position, same position
number, its own price and quantity.
Two addressing forms, both accepted in the top-level `positions[]` (the parent
line already names its position, so no chapter path is needed):
  1. `parent_line_id` = the main line's `id`, plus `article_number` — the
unambiguous form, and the one to prefer.
  2. `id` = the main line's own `id`, repeated, plus `article_number` — for ERPs
whose record has a single id field. This is told apart from an *edit* of the
main line by the label: an entry carrying a writable label whose target does
not already have that role is an add.
Requirements, all `400` when missing: exactly **one** `labels[]` entry, whose
`type` is `ALTERNATIVE` or `ACCESSORY` (`CUSTOM` and `PARTS_LIST` are not
writable); a **`quantity`**; and a price (one of `list_price`,
`discount_percentage` or `net_price` — a bare `net_price` is the usual one, and
the line keeps your catalogue's gross behind it). The label's `text` is ignored — the
display text is your organisation's own, resolved from the `type`; if your
organisation has no label of that type configured, the request is a `400`
rather than an unlabelled line. The parent must be an `ARTICLE` line (not a
free-text line and not a note).
An `ACCESSORY` counts towards the tender total; an `ALTERNATIVE` never does,
and adding one leaves a partner-pinned `net_total` / `gross_total` untouched.
**This add is not an upsert.** From the next GET on, the new line has its own
`id` (`{parent}.{n}`) — address it by that id to edit or delete it. Re-sending
the same body a second time adds a second sub-line, so retry on the id, not on
the add. Echoing back a GET body is safe: a sub-line that already carries its
label reads as an ordinary edit.
- **Delete a line** — `{ "id": "…", "op": "delete" }` (soft-delete). Works in the
top-level `positions[]` or under a chapter.
- **Add a line** — include a position **without** an `id`. Every add needs a
target, and there are two ways to name one:
  1. **`chapter_id`** — the chapter the new position joins, straight in the flat
top-level `positions[]` list. This is the form to use when your ERP holds
one chapter handle per record and cannot rebuild the tree just to append a
line. The handle is the `id` of the chapter as GET reports it; an unknown
one is a `400`.
  2. **Nesting** the entry under that chapter in `chapters[]` — equivalent, and
the natural shape when you are round-tripping a whole document anyway.
(An article note and a labelled sub-line name an existing *line* instead, via
`parent_line_id`, so they need no chapter at all.)
With an `article_number` the entry becomes an article line (resolved against
your catalogue — an unknown number is a `400`); without one, send `description`
for a free-text line. Either way you can set the line's price and discount on
the add (`list_price` + one of `discount_percentage` / `net_price`).
On an **article** line you need not send `list_price`: the new line starts from
that article's catalogue price for your organisation — the same price picking
the article in Mercura would give it — so `net_price` alone is enough. The
catalogue gross (Brutto) stays on the line and the discount percentage is
back-computed from it, which is what makes the line show its price in Mercura
and count towards the tender's gross.

```
PATCH /tenders/63365
{ "positions": [
    { "chapter_id": "5503", "article_number": "LEU-1200-30-840",
      "quantity": 12, "net_price": "59.42" }
] }
```
`chapter_id` addresses a chapter **only on an add**. On an entry that carries
an `id` it is ignored, so a GET body — where every line reports its
`chapter_id` — round-trips as edits without creating anything.
- **Summary totals** — `totals.net_total` / `totals.gross_total` persist as your
authoritative amounts and **win over** Mercura's recalculation (they may
legitimately differ from the line sums — freight, rounding, surcharges).
`totals.additional_discount_percentage` sets the header discount.
- **Request custom fields** — send a label-keyed `request_custom_fields` object to
shallow-merge values onto the tender's underlying request (symmetric with the
read side — send back exactly what you GET). A `null` **value** clears that field;
a whole-object `null` (or omitting it) is a no-op, so a full GET body round-trips.
An unknown label or an unknown `multi_select` option label is a `400`. Use this to
report partner-maintained values back with the prices — e.g. an "Offer Validity"
(Angebotsgültigkeit) custom field maintained in your ERP.
- **Close a tender** — see *Closing a tender* below.
- **Concurrency** — pass the GET's `ETag` as `If-Match`; if the tender changed
since you read it you get a `412` (re-fetch and retry). Omitting `If-Match`
skips the check (last-write-wins).

Apart from an explicit `status` write, editing does not change the tender's
lifecycle `status` and never re-triggers an export.

Version: 1.79.2

## Description:

  - `Editing a tender` ()
    `PATCH /tenders/{tender_id}` is a **partial, in-place** edit that returns the
re-projected tender. **Recommended: send only what changed (sparse).** Put the lines you touched in a
top-level `positions[]`, each addressed by its `id` — no chapter path needed
(line ids are unique within a tender). This is the primary contract for keeping
an ERP in sync and the one that scales: the body is proportional to your edit,
not to the tender. ```
PATCH /tenders/63365
{ "positions": [ { "id": "10", "net_price": "1.00" }, { "id": "11", "op": "delete" } ] }
``` **Also supported: full-document round-trip.** The body is field-aligned with the
GET response, so you *can* GET a tender, change fields in the nested `chapters[]`
tree, and send it all back — read-only fields are tolerated and ignored, unknown
keys are a `400`. Prefer sparse for large tenders: a full round-trip re-sends and
re-resolves every line. (A discounted line's GET carries `list_price` +
`discount_percentage` + `net_price` together; that's accepted as long as they're
consistent — but if you *change* a price, send only the field you changed, or a
contradictory pair is a `400`.) - **Edit a line** — top-level `positions[]` (by `id`), or under its chapter.
Price resolution: `list_price` (Brutto) is the anchor, then **at most one** of
`discount_percentage` / `net_price` resolves the other:
  - `discount_percentage` is a **manual** discount applied *on top of* any
catalogue/pricing-rule discount already on the line — i.e. on the
net-of-catalogue base, exactly as the Mercura UI applies it — not a raw
percentage off `list_price`. Editing it recomputes `net_price` off that base
(a catalogue discount is preserved).
  - `net_price` sets the exact per-unit net; the resulting `discount_percentage`
is back-computed from it. **To pin an exact net, send `net_price`.**
Also editable: `quantity`, `unit`, `additional_text`. `description` is editable
only on a free-text line — on an ARTICLE line it comes from the catalogue and
is read-only (sent values are ignored, so a GET body still round-trips).
**`manufacturer_article_number` — the line's own number.** Editable on a
free-text line, both on an edit and on an add. A line Mercura matched to no
catalogue article has no `article_number`; this is where the number it *does*
carry belongs — a sourced third-party product, an EAN, a competitor's type
code, a service code. It is stored verbatim and **never resolved** against
your article master, so any value is accepted, it reaches the ERP export, and
it comes back on the next GET. An empty string clears it; `null` changes
nothing. On an **ARTICLE** line it is ignored (so a GET body round-trips):
there the column is the matched article's own number, which an
`article_number` swap re-resolves from the catalogue, so a value sent here
would not survive. Sending a number that is not in your article master as
`article_number` is a `400` — that field resolves against the catalogue, and a
number it can never find must not arrive as one.
**Replacing the article — `article_number` on an ARTICLE line.** Send a
different catalogue number to put another article on the line, together with
a price (`list_price`, `net_price` or `discount_percentage`) — the stored one
belongs to the article you replace, so a swap without a price is a `400`.
Echoing the line's current number is not a swap and needs no price. A swap is
recorded as a **correction of Mercura's match**, exactly like a user replacing
the article in the Mercura app: the line keeps its `id` and carries the new
article, the replaced article is kept as a removed line (it comes back flagged
under `include_deleted=true`, see *Removed lines and positions*), and Mercura
learns from the correction for future matching — also when you send it after
the tender was exported.
Note on `additional_text` — it behaves differently per line type, because on
an ARTICLE line the stored column reaches **no Mercura-side output** (neither
the offer PDF nor the GAEB export, and the ERP payload omits it there):
  - on a **free-text** line it writes the line's own `additional_text`, which
several ERP integrations consume as their long-text element (an empty string
clears it);
  - on an **ARTICLE** line it writes that line's **article note** instead — the
same row `parent_line_id` creates (below) — so the text does reach the offer
PDF, the GAEB export and the parent line's ERP remark. One ERP record stays
one payload object: send the article's fields and its long text together,
without a second entry.
On an ARTICLE line an empty string (`""`) clears the note, while `null`
changes nothing — GET returns the note as its own line and `null` on the
parent, so a full round-tripped body must not delete the note it just read.
A note has three addresses — its own line `id`, its parent's `parent_line_id`,
and its parent's `additional_text` — and using more than one of them for the
same note in a single request is a `400` rather than a last-one-wins
overwrite (or, with a competing `op: "delete"`, a silent loss of the text you
just sent). Address each note once per request.
**Edge case on older data.** An ARTICLE line can still carry a value in the
`additional_text` column from before this behaviour existed. GET echoes that
value on the parent *and* the note as its own line, so replaying the whole
body addresses one note twice and hits the `400` above. It is deterministic,
and one PATCH clears it: send that line's `additional_text` **on its own**
(without also echoing the note line). That rewrites the note and drops the
dead column value, after which full-body round-trips are clean. Lines whose
column was already empty — every line written since this release — are
unaffected.
- **Annotate a line (article note)** — send `parent_line_id` (the `id` of the
line to annotate) plus `description`, and **no** `id`. The note becomes a text
line on the parent's own position — no new position, no own position number —
and its text follows the path an operator-typed note takes: into Mercura's
offer PDF and GAEB export, and folded into the parent line's remark
(`Bemerkung`) in the ERP payload.
This is the one add that needs no chapter path (the parent line already names
its position), so it is accepted in the top-level `positions[]` too. It is an
**upsert**: sending a note for a parent that already has one *replaces* that
note, so a retried PATCH cannot pile up duplicates. A note is text only —
`article_number`, prices, `quantity`, `unit` and `additional_text` on a note
are rejected with a `400` rather than silently dropped, and a note never
enters the totals. To remove one, address the note line itself with
`{ "id": "…", "op": "delete" }`, or send `additional_text: ""` on its parent.
The parent's `additional_text` (above) is the ergonomic form of the same
write: use `parent_line_id` when the note is its own record on your side, and
`additional_text` when the text lives in the same record as the article.
- **Offer an alternative or an accessory** — add a **new article line onto an
existing position**, marked with one label. It sits under the main line as a
sub-line, the way an operator's alternative does: same position, same position
number, its own price and quantity.
Two addressing forms, both accepted in the top-level `positions[]` (the parent
line already names its position, so no chapter path is needed):
  1. `parent_line_id` = the main line's `id`, plus `article_number` — the
unambiguous form, and the one to prefer.
  2. `id` = the main line's own `id`, repeated, plus `article_number` — for ERPs
whose record has a single id field. This is told apart from an *edit* of the
main line by the label: an entry carrying a writable label whose target does
not already have that role is an add.
Requirements, all `400` when missing: exactly **one** `labels[]` entry, whose
`type` is `ALTERNATIVE` or `ACCESSORY` (`CUSTOM` and `PARTS_LIST` are not
writable); a **`quantity`**; and a price (one of `list_price`,
`discount_percentage` or `net_price` — a bare `net_price` is the usual one, and
the line keeps your catalogue's gross behind it). The label's `text` is ignored — the
display text is your organisation's own, resolved from the `type`; if your
organisation has no label of that type configured, the request is a `400`
rather than an unlabelled line. The parent must be an `ARTICLE` line (not a
free-text line and not a note).
An `ACCESSORY` counts towards the tender total; an `ALTERNATIVE` never does,
and adding one leaves a partner-pinned `net_total` / `gross_total` untouched.
**This add is not an upsert.** From the next GET on, the new line has its own
`id` (`{parent}.{n}`) — address it by that id to edit or delete it. Re-sending
the same body a second time adds a second sub-line, so retry on the id, not on
the add. Echoing back a GET body is safe: a sub-line that already carries its
label reads as an ordinary edit.
- **Delete a line** — `{ "id": "…", "op": "delete" }` (soft-delete). Works in the
top-level `positions[]` or under a chapter.
- **Add a line** — include a position **without** an `id`. Every add needs a
target, and there are two ways to name one:
  1. **`chapter_id`** — the chapter the new position joins, straight in the flat
top-level `positions[]` list. This is the form to use when your ERP holds
one chapter handle per record and cannot rebuild the tree just to append a
line. The handle is the `id` of the chapter as GET reports it; an unknown
one is a `400`.
  2. **Nesting** the entry under that chapter in `chapters[]` — equivalent, and
the natural shape when you are round-tripping a whole document anyway.
(An article note and a labelled sub-line name an existing *line* instead, via
`parent_line_id`, so they need no chapter at all.)
With an `article_number` the entry becomes an article line (resolved against
your catalogue — an unknown number is a `400`); without one, send `description`
for a free-text line. Either way you can set the line's price and discount on
the add (`list_price` + one of `discount_percentage` / `net_price`).
On an **article** line you need not send `list_price`: the new line starts from
that article's catalogue price for your organisation — the same price picking
the article in Mercura would give it — so `net_price` alone is enough. The
catalogue gross (Brutto) stays on the line and the discount percentage is
back-computed from it, which is what makes the line show its price in Mercura
and count towards the tender's gross.

```
PATCH /tenders/63365
{ "positions": [
    { "chapter_id": "5503", "article_number": "LEU-1200-30-840",
      "quantity": 12, "net_price": "59.42" }
] }
```
`chapter_id` addresses a chapter **only on an add**. On an entry that carries
an `id` it is ignored, so a GET body — where every line reports its
`chapter_id` — round-trips as edits without creating anything.
- **Summary totals** — `totals.net_total` / `totals.gross_total` persist as your
authoritative amounts and **win over** Mercura's recalculation (they may
legitimately differ from the line sums — freight, rounding, surcharges).
`totals.additional_discount_percentage` sets the header discount.
- **Request custom fields** — send a label-keyed `request_custom_fields` object to
shallow-merge values onto the tender's underlying request (symmetric with the
read side — send back exactly what you GET). A `null` **value** clears that field;
a whole-object `null` (or omitting it) is a no-op, so a full GET body round-trips.
An unknown label or an unknown `multi_select` option label is a `400`. Use this to
report partner-maintained values back with the prices — e.g. an "Offer Validity"
(Angebotsgültigkeit) custom field maintained in your ERP.
- **Close a tender** — see *Closing a tender* below.
- **Concurrency** — pass the GET's `ETag` as `If-Match`; if the tender changed
since you read it you get a `412` (re-fetch and retry). Omitting `If-Match`
skips the check (last-write-wins). Apart from an explicit `status` write, editing does not change the tender's
lifecycle `status` and never re-triggers an export.

