{
  "openapi": "3.1.0",
  "info": {
    "title": "FabParts B2B-API",
    "version": "1.0.0",
    "description": "REST-API der FabStack GmbH für 3D-Druck auf Bestellung: Upload, Sofort-Schätzung, exakter Preis aus echtem Produktions-Slice, Bestellung mit Zahlungslink, Lieferstatus, Datei-Löschung mit Protokoll. Feldnamen sind deutsch und identisch zur Weboberfläche; Beträge sind ganze Cent inklusive 19 % MwSt. Fehler kommen immer als {\"fehler\": \"Klartext\"}.",
    "contact": { "email": "bestellung@fabparts.de", "url": "https://fabparts.de/api-doku" }
  },
  "servers": [{ "url": "https://fabparts.de/api/v1" }],
  "security": [{ "ApiKey": [] }],
  "components": {
    "securitySchemes": {
      "ApiKey": { "type": "apiKey", "in": "header", "name": "X-Api-Key", "description": "Key aus der Registrierungs-Mail (Präfix fpk_). Alles außer POST /registrierung verlangt ihn." }
    },
    "schemas": {
      "Fehler": {
        "type": "object",
        "properties": { "fehler": { "type": "string", "description": "Kurze deutsche Klartext-Meldung." } },
        "required": ["fehler"]
      },
      "Check": {
        "type": "object",
        "description": "Prüf-Ampel der Datei. Rot ist nicht bestellbar.",
        "properties": {
          "ampel": { "type": "string", "enum": ["gruen", "gelb", "rot"] },
          "hinweise": { "type": "array", "items": { "type": "string" } }
        }
      },
      "Schaetzung": {
        "type": ["object", "null"],
        "description": "Sofort-Schätzspanne. null, wenn die Schätzung erst nach dem exakten Slice möglich ist (komplexe Projektdateien).",
        "properties": {
          "vonCent": { "type": "integer" },
          "bisCent": { "type": "integer" }
        }
      },
      "Angebot": {
        "type": "object",
        "description": "Kernfelder eines Angebots. Je nach Status kommen weitere Felder dazu (preisCent/versandCent erst ab Status bestellbar).",
        "properties": {
          "id": { "type": "string", "description": "Angebots-ID, Präfix FP-." },
          "status": { "type": "string", "enum": ["neu", "geschaetzt", "check_fehler", "bestellbar", "bestellt"], "description": "check_fehler: Datei abgelehnt oder Slice fehlgeschlagen, Details in check.hinweise." },
          "schaetzung": { "$ref": "#/components/schemas/Schaetzung" },
          "check": { "$ref": "#/components/schemas/Check" },
          "preisCent": { "type": "integer", "description": "Verbindlicher Teilepreis nach echtem Slice (Status bestellbar), 14 Tage gültig." },
          "versandCent": { "type": "integer", "description": "Beim Slice eingefrorener Versandanteil." },
          "farbSlots": { "type": "integer", "description": "Anzahl Farb-Slots (1 = einfarbig, bis 4 bei Mehrfarb-3MF)." },
          "dateiName": { "type": ["string", "null"] },
          "teile": { "type": "array", "description": "Nur bei Projektdateien mit mehreren Druckplatten: ein eigenes Angebot je Platte.", "items": { "type": "object", "additionalProperties": true } }
        },
        "additionalProperties": true
      },
      "Loeschprotokoll": {
        "type": "object",
        "properties": {
          "angebotId": { "type": "string" },
          "zeitpunkt": { "type": "string", "format": "date-time" },
          "geloescht": {
            "type": "object",
            "properties": {
              "datei": { "type": "boolean" },
              "artefakt": { "type": "boolean", "description": "Geometrie-Artefakt der Druckvorbereitung." },
              "datensatz": { "type": "boolean" }
            }
          },
          "verbleibt": { "type": "string" },
          "hinweis": { "type": "string" }
        }
      }
    },
    "responses": {
      "Unauthorisiert": { "description": "API-Key fehlt oder ungültig.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Fehler" } } } },
      "Kontingent": { "description": "Tageskontingent erschöpft (Reset Mitternacht UTC).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Fehler" } } } }
    }
  },
  "paths": {
    "/registrierung": {
      "post": {
        "summary": "Konto anlegen, API-Key kommt per E-Mail",
        "security": [],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email"], "properties": { "email": { "type": "string", "format": "email" }, "firma": { "type": "string", "maxLength": 200 } } } } } },
        "responses": {
          "202": { "description": "Angenommen, Key per Mail. Der Key erscheint nie in einer HTTP-Antwort." },
          "422": { "description": "E-Mail ungültig.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Fehler" } } } },
          "429": { "description": "Zu viele Registrierungen von dieser IP (3 pro Tag)." },
          "502": { "description": "Registrierungs-Mail nicht zustellbar, Konto verworfen." }
        }
      }
    },
    "/konto": {
      "get": {
        "summary": "Eigenes Konto: Limits, heutiger Verbrauch, Bestell-Freigabe",
        "responses": {
          "200": { "description": "Kontodaten.", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "email": { "type": "string" }, "firma": { "type": ["string", "null"] }, "bestellenFreigegeben": { "type": "boolean" }, "limits": { "type": "object", "properties": { "uploadTag": { "type": "integer" }, "sliceTag": { "type": "integer" } } }, "verbrauchtHeute": { "type": "object", "properties": { "upload": { "type": "integer" }, "slice": { "type": "integer" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" }
        }
      }
    },
    "/farben": {
      "get": {
        "summary": "Bestellbare Farben je Material und Ausführung, live aus dem Lager",
        "responses": {
          "200": { "description": "Farbliste. Je Eintrag u. a. bambuColorId (für Bestell-Positionen), farbe, hex, material, subtyp.", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" }
        }
      }
    },
    "/angebot": {
      "post": {
        "summary": "Datei hochladen, Sofort-Schätzung",
        "description": "Multipart-Upload (Feld: datei). Formate STL, 3MF, OBJ, STEP (STEP wird konvertiert, bis zu zwei Minuten). Maximale Dateigröße 50 MB. Unbestellte Angebote werden nach 30 Tagen automatisch gelöscht.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["datei", "material", "subtyp", "preset", "layer"],
                "properties": {
                  "datei": { "type": "string", "format": "binary" },
                  "material": { "type": "string", "examples": ["PLA", "PETG"] },
                  "subtyp": { "type": "string", "examples": ["Matte", "Basic"] },
                  "preset": { "type": "string", "enum": ["deko", "standard", "stabil", "massiv"] },
                  "layer": { "type": "string", "enum": ["0.28", "0.20", "0.12"] },
                  "stueckzahl": { "type": "integer", "minimum": 1 },
                  "unterseite": { "type": "string", "enum": ["standard", "glatt"] },
                  "farbmodus": { "type": "string", "enum": ["einfarbig", "mehrfarbig"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Angebot mit Schätzspanne oder check_fehler.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Angebot" } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" },
          "422": { "description": "Datei oder Konfiguration ungültig.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Fehler" } } } },
          "429": { "$ref": "#/components/responses/Kontingent" }
        }
      }
    },
    "/angebot/{id}/preis": {
      "post": {
        "summary": "Exakter Preis aus echtem Produktions-Slice",
        "description": "Dauert bis zu etwa zwei Minuten (synchroner Aufruf, großzügiges Client-Timeout setzen). Der Preis ist verbindlich, solange das Angebot bestellbar ist (14 Tage).",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "Angebot mit preisCent und versandCent (Status bestellbar) oder check_fehler.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Angebot" } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" },
          "404": { "description": "Angebot unbekannt." },
          "429": { "$ref": "#/components/responses/Kontingent" },
          "502": { "description": "Fertigungs-Gateway derzeit nicht erreichbar, später erneut versuchen." }
        }
      }
    },
    "/angebot/{id}/stueckzahl": {
      "post": {
        "summary": "Stückzahl eines bestellbaren Angebots ändern (ohne neuen Slice)",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["stueckzahl"], "properties": { "stueckzahl": { "type": "integer", "minimum": 1 } } } } } },
        "responses": {
          "200": { "description": "Angebot mit neu berechnetem preisCent (Mengenrabatt-Staffel).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Angebot" } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" },
          "404": { "description": "Angebot unbekannt." },
          "422": { "description": "Angebot nicht bestellbar oder Stückzahl ungültig." }
        }
      }
    },
    "/angebot/{id}": {
      "delete": {
        "summary": "Eigenes Angebot sofort löschen (Löschprotokoll)",
        "description": "Nur Angebote, die über den eigenen API-Key hochgeladen wurden. Unbestellt: Volllöschung (Datei, Druckvorbereitung, Datensatz). Nach Versand oder Storno: Kundendatei sofort; mit {\"inklusiveNachweis\": true} zusätzlich das Geometrie-Artefakt (es verbleiben Slice-Parameter und Qualitätsfoto als Beleg). Ohne Datei und Geometrie ist bei einer Reklamation kein identischer Neudruck aus dem Bestand möglich; die Gewährleistung bleibt unberührt.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "inklusiveNachweis": { "type": "boolean", "default": false } } } } } },
        "responses": {
          "200": { "description": "Löschprotokoll.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Loeschprotokoll" } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" },
          "404": { "description": "Kein Angebot mit dieser ID für dieses API-Konto.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Fehler" } } } },
          "409": { "description": "Gerade nicht löschbar: laufender Bestellvorgang, Auftrag in Bearbeitung oder bezahlter Prüfauftrag (Antwort nennt den Grund)." },
          "502": { "description": "Geometrie-Artefakt derzeit nicht löschbar; Aufruf gefahrlos wiederholbar, Antwort enthält Teilprotokoll." }
        }
      }
    },
    "/bestellung": {
      "post": {
        "summary": "Bestellung anlegen (erst nach manueller Konto-Freischaltung)",
        "description": "Positionen referenzieren bestellbare Angebote plus bambuColorId aus GET /farben (bei Mehrfarb-Angeboten bambuColorIds als Array je Slot). Optional: zahlart rechnung (nur mit kunde.firma, 14 Tage Ziel), direktversand true (Streckengeschäft mit neutralem Lieferschein, optional lieferschein_text bis 200 Zeichen), dateiAufbewahren true (12 statt 6 Monate), kunde.rechnungs_email (abweichender Empfänger nur für die Rechnung, z. B. buchhaltung@), kunde.referenz (PO-Nummer), Express für genau eine Position mit Stückzahl 1. Die vier zustimmung-Flags sind Pflicht.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true, "required": ["positionen", "kunde", "zustimmung"], "properties": { "positionen": { "type": "array", "items": { "type": "object", "additionalProperties": true, "properties": { "angebotId": { "type": "string" }, "bambuColorId": { "type": "string" } } } }, "kunde": { "type": "object", "additionalProperties": true }, "zustimmung": { "type": "object", "properties": { "agb": { "type": "boolean" }, "widerruf_kenntnis": { "type": "boolean" }, "angaben_geprueft": { "type": "boolean" }, "beschaffenheit": { "type": "boolean" } } }, "versandArt": { "type": "string", "enum": ["versand", "abholung"] }, "zahlart": { "type": "string", "enum": ["stripe", "rechnung"] }, "direktversand": { "type": "boolean" }, "dateiAufbewahren": { "type": "boolean" } } } } } },
        "responses": {
          "200": { "description": "Bestellung angelegt.", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string", "description": "Bestell-ID, Präfix FP-B-." }, "url": { "type": "string", "description": "Stripe-Zahlungslink (entfällt bei zahlart rechnung)." } }, "additionalProperties": true } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" },
          "403": { "description": "Bestellen für diesen Key noch nicht freigeschaltet." },
          "422": { "description": "Position, Zustimmung oder Adresse ungültig (Klartext in fehler)." }
        }
      }
    },
    "/bestellung/{id}/status": {
      "get": {
        "summary": "Bestellstatus abfragen (Polling)",
        "description": "Empfohlenes Polling-Intervall: alle paar Minuten. Webhooks gibt es derzeit nicht.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "Aktueller Status.", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": ["neu", "rechnung_angefragt", "bezahlt", "in_produktion", "versendet", "reklamation", "storniert", "verfallen"] } } } } } },
          "404": { "description": "Bestellung unbekannt." }
        }
      }
    },
    "/nachbestellen": {
      "post": {
        "summary": "Frische Angebote aus einer früheren Bestellung (gleiche Konfiguration)",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["bestellungId"], "properties": { "bestellungId": { "type": "string" } } } } } },
        "responses": {
          "200": { "description": "Neue Angebote je Position der alten Bestellung.", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } },
          "401": { "$ref": "#/components/responses/Unauthorisiert" },
          "404": { "description": "Bestellung unbekannt oder Datei nicht mehr gespeichert." },
          "429": { "$ref": "#/components/responses/Kontingent" }
        }
      }
    }
  }
}
