openapi: 3.1.0
info:
  title: MerchandiserOS ERP Integration API
  version: "1.0.0"
  description: |
    The HTTP API an ERP (NetSuite, Odoo, SAP Business One, Dynamics, or any other)
    uses to exchange data with ONE MerchandiserOS workspace. The plain-language
    companion is `docs/api/erp-api-guide.md`; where the two ever disagree, the code
    under `src/app/api/v1/**` and `src/lib/erp/**` is the truth.

    **Sign-in.** The factory owner creates an integration login in Settings →
    Integrations → ERP → "ERP API". The ERP trades its username and password for a
    one-hour bearer token at `POST /auth/login` and sends it on every other call as
    `Authorization: Bearer <token>`. An integration login can only call `/api/v1`;
    it can never open the app. Turning a login off, or giving it a new password,
    cancels every token it holds at once.

    **The money line.** No amount is ever read. Every request body is copied through
    an allowlist of named fields; an `amount`, `total`, `balance`, `value` or any
    other field not listed in this document is ignored and never stored. The only
    number read as a price is a material's unit price (`POST /erp/material-prices`),
    which is a costing estimate — never a payable, never booked.

    **Nothing is applied on arrival.** Every inbound write lands on the workspace's
    ERP review list as a proposal (one per field), showing what the app holds and
    what the ERP says. A person approves or rejects it; a rejection carries a
    reason. The answer to a write says what happened to EACH row. `202` with
    `state: "pending_review"` means at least one row is waiting for a person; `200`
    with `state: "complete"` means no row is waiting. Read the final decisions from
    `GET /erp/proposals`.

    Auto-accept can be switched on by the owner PER KIND. It is off by default, an
    auto-accepted proposal is still recorded and visible, and commercial terms can
    never be auto-accepted.

    Two exceptions write at once, because they change none of the factory's records:
    acknowledging a document we sent you (`POST /erp/documents/{external_key}/ack`)
    and storing your own code lists (`POST /erp/reference-lists/{list}`).

    **What approval does, per kind** (`kind` on a proposal):

    | kind | approving it |
    |---|---|
    | `po_status` | links your PO (by `erp_po_id`) and its status to our purchase request. A PO you cancelled is shown; nothing here is cancelled. |
    | `payment` | writes the received date for deposit / balance / LC on the order. |
    | `invoice` | records or updates the invoice STATUS record (no amount), linked to our orders and shipments. It changes no order field. |
    | `buyer_claim` | records the claim (kind, reason, date — no amount) against the order. |
    | `commercial_terms` | a disagreement is SETTLED and recorded, but the order is NOT changed. Only a pre-fill of an EMPTY field on an unconfirmed (Draft) order is written. |
    | `document_status` | recorded only — the app does not track export documents yet. |
    | `material_price` | changes the material's unit price through the normal price-change path (reason and history kept). |
    | `stock_variance` | recorded only — our own stock count is never overwritten. |
    | `material_erp_code` | stamps your item code on our material. |
    | `material_create` | creates the material from your catalog item (no price). |
    | `production_output` | records the output through the same path the Garment.io feed uses (deduplicated). |
    | `production_defect` | recorded only (compare-only). |
    | `production_downtime` | recorded only (compare-only). |
    | `smv_actual` | recorded only (compare-only) — costing is never changed. |
    | `tracking_mode` | switches who records the order's output (locks or unlocks the floor's own entry). |

    **Webhooks.** Registration is stored. Deliveries are NOT sent yet — poll the API.
  contact:
    name: MerchandiserOS Integrations

servers:
  - url: https://{workspace}.merchandiseros.online/api/v1
    description: >
      Each workspace's own web address + /api/v1. A token only works on the
      address of the workspace that issued it.
    variables:
      workspace:
        default: your-workspace
        description: The workspace part of the factory's MerchandiserOS web address.

security:
  - bearerAuth: []

tags:
  - name: Sign-in
  - name: Documents
    description: Documents this app has queued for your ERP (pull + acknowledge).
  - name: Status back
    description: What your ERP sends back. Every row becomes a proposal on the review list.
  - name: Materials
  - name: Review list
    description: The answers to what you sent.
  - name: Reference lists
  - name: Production
  - name: Webhooks
    description: Registration only — deliveries are not sent yet.

paths:
  # ─────────────────────────────── Sign-in ───────────────────────────────
  /auth/login:
    post:
      tags: [Sign-in]
      summary: Trade the integration username + password for a one-hour token
      description: |
        Only an integration login (username like `erp-7f3k2q9a`) can sign in here — a
        person's email and password get the same "wrong username or password" as a typo.
        Every failure says the same thing.

        Limits: 20 attempts per IP and 10 per username in any 10 minutes (then 429 with
        Retry-After). Five wrong passwords in a row lock the login for 15 minutes; a
        correct password does not unlock it early.

        No Idempotency-Key is needed here. The body may be at most 4,000 characters.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string, example: erp-7f3k2q9a }
                password: { type: string, maxLength: 200 }
      responses:
        '200':
          description: Signed in.
          content:
            application/json:
              schema:
                type: object
                required: [token, token_type, expires_in, expires_at]
                properties:
                  token: { type: string, description: 'Starts with "mosi.". Send as Authorization: Bearer <token>.' }
                  token_type: { type: string, const: Bearer }
                  expires_in: { type: integer, const: 3600, description: Seconds. }
                  expires_at: { type: string, format: date-time }
        '400':
          description: 'The body is not JSON of the form {"username": "...", "password": "..."}.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          description: Wrong username or password (also a locked or turned-off login).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Documents ───────────────────────────────
  /erp/documents:
    get:
      tags: [Documents]
      summary: List the documents queued for your ERP
      description: |
        Default: the documents not yet acknowledged (`pending` + `exported`). Oldest first,
        cursor-paged: pass `next_cursor` back as `cursor` until it is null. Use
        `modified_since` to ask "what changed since my last pull".
      parameters:
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/DocStatus' }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/OutboundDocType' }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/ModifiedSince'
      responses:
        '200':
          description: One page.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ErpDocument' }
                  next_cursor: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/documents/{external_key}:
    get:
      tags: [Documents]
      summary: Fetch one queued document
      parameters:
        - $ref: '#/components/parameters/ExternalKey'
      responses:
        '200':
          description: The document.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErpDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /erp/documents/{external_key}/ack:
    post:
      tags: [Documents]
      summary: Say you have taken a document (and what you call it)
      description: |
        Applied at once (it changes no order, request or material — it is bookkeeping on
        our outbound queue). Moves only forward: exported → acknowledged → linked. A repeat
        returns the same answer with `changed: false`. Once an `erp_id` is stored it is never
        re-bound: a different `erp_id` for the same document is refused with 409.
      parameters:
        - $ref: '#/components/parameters/ExternalKey'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [acknowledged, linked] }
                erp_id: { type: string, maxLength: 80, description: YOUR permanent internal id for the record you created. Stored and bound on. }
                erp_ref: { type: string, maxLength: 120, description: The number people read (display only; may change). }
      responses:
        '200':
          description: The document as it now stands.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ErpDocument'
                  - type: object
                    properties:
                      changed: { type: boolean }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: >
            `conflict` — already linked to a different erp_id, already further along
            (only forward), or in error on our side; or `idempotency_key_reused`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Status back ───────────────────────────────
  /erp/po-status:
    post:
      tags: [Status back]
      summary: Purchase orders you made from our purchase requests
      description: |
        Matched on OUR request number (`request_ref`, e.g. PR-1042) or the `external_key` of the
        po_request document we sent. Bound on YOUR permanent PO id (`erp_po_id`); the PO number
        is display text. A PO you cancelled is shown to a person — nothing here is cancelled.
        Status words from NetSuite, Odoo, SAP B1 and Dynamics are read (open, approved, pending
        receipt, partially received, received, pending bill, closed, billed, cancelled, void…);
        a word we cannot read is answered `invalid` / `unknown_status` and not recorded.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/PoStatusRow' } } } }
            example:
              rows:
                - request_ref: PR-1042
                  erp_po_id: "88213"
                  erp_po_number: PO-2026-0457
                  status: Pending Receipt
                  date: "2026-09-25"
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/payments:
    post:
      tags: [Status back]
      summary: A payment milestone received (deposit, balance, LC)
      description: |
        Status and date only. `status` must be `paid` — the app keeps its own due dates and
        overdue flags. Any amount in the row is never read. Approving writes the received date
        on the order.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/PaymentRow' } } } }
            example:
              rows:
                - order_ref: ORD-1042
                  milestone: deposit
                  status: paid
                  date: "2026-09-20"
                  erp_payment_id: "PMT-5512"
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/invoice-status:
    post:
      tags: [Status back]
      summary: Your invoices, as a STATUS record — never an amount
      description: |
        Invoice, credit note, down-payment invoice or proforma. Bound on YOUR permanent invoice
        id (`erp_invoice_id`); `doc_number` is display only. Linked to OUR orders (`order_refs`)
        and, where known, our shipments (`shipment_ids`, which must belong to those orders).

        - A new `erp_invoice_id` → one proposal to record the invoice.
        - A known one → one proposal per CHANGED field (plus one if its links changed).
        - An `erp_modified_at` older than the version we hold → `stale`, nothing to review.
        - If the factory has switched invoice tracking off → every row `not_tracked`.

        Approving records the status record in MerchandiserOS. It changes no order field.
        (Showing it on the order screen is not built yet.)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/InvoiceRow' } } } }
            example:
              rows:
                - erp_invoice_id: "INV-77120"
                  doc_number: CI-2231
                  kind: invoice
                  lifecycle: issued
                  presentation: discrepancy
                  discrepancy_reason: late B/L
                  payment_state: unpaid
                  issued_date: "2026-09-02"
                  due_date: "2026-09-30"
                  erp_modified_at: "2026-09-24T10:15:00Z"
                  order_refs: [ORD-1042]
                  shipment_ids: [311]
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/claims:
    post:
      tags: [Status back]
      summary: A buyer's claim, chargeback or debit note — a reason, never an amount
      description: |
        Bound on YOUR permanent id (`erp_claim_id`). The same id sent again with the same
        details answers `matches`; with different details, `conflict` (a person checks it;
        nothing is recorded). Approving records the claim against the order.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/ClaimRow' } } } }
            example:
              rows:
                - erp_claim_id: "CLM-301"
                  kind: chargeback
                  reason_category: late delivery
                  order_ref: ORD-1042
                  shipment_id: 311
                  date: "2026-09-22"
                  doc_number: DN-0099
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/commercial-terms:
    post:
      tags: [Status back]
      summary: One order's terms as your ERP holds them — compared, never written over
      description: |
        The buyer's PO / LC is the source of truth; your ERP is a witness. Each term you send
        is compared with the order, one result per term:

        - the same → `matches`;
        - the order is still Draft and our field is EMPTY → a proposal to pre-fill it
          ("from ERP"), still reviewed. Pre-fill covers Incoterm, payment terms, partial
          shipment, LC transhipment, LC expiry and the deposit/balance due dates;
        - anything else → a DISAGREEMENT, shown with both values. Approving settles and
          records it; the order is NOT changed.

        While a disagreement is open, one on the LC latest ship date, LC expiry, LC partial
        shipment, partial shipment or tolerance BLOCKS ship clearance (no override); one on
        Incoterm or payment terms WARNS. Commercial terms are never auto-accepted.

        The body is ONE order (not a `rows` list). If nothing readable is in it, the answer is
        200 with one `invalid` / `missing_field` result. `received` counts terms, not rows.
        No amount is read.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CommercialTermsBody' }
            example:
              order_ref: ORD-1042
              incoterm: FOB
              payment_terms: 30% deposit, 70% against B/L copy
              partial_shipment_allowed: false
              lc:
                latest_ship: "2026-11-15"
                expiry: "2026-12-05"
                partial_allowed: false
              tolerance: { plus_pct: 3, minus_pct: 3 }
              payment_schedule:
                - { milestone: deposit, due_date: "2026-10-01" }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/document-status:
    post:
      tags: [Status back]
      summary: An export / compliance document's status — COMPARE ONLY
      description: |
        The app does not track these documents yet. A person sees your word on the review list;
        approving records that it was seen and changes nothing.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/DocumentStatusRow' } } } }
            example:
              rows:
                - order_ref: ORD-1042
                  document: packing_list
                  status: issued
                  date: "2026-09-24"
                  ref: PL-2231
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/material-prices:
    post:
      tags: [Materials]
      summary: A material's unit price — a costing estimate, never a payable
      description: |
        The ONE number this API reads as a price. Matched on OUR material code only; your item
        code alone is not used to find a material. An archived material is refused
        (`record_archived`). Approving goes through the same price-change path as a person's
        edit (reason and history kept).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [items], properties: { items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/MaterialPriceRow' } } } }
            example:
              items:
                - { material_code: MAT-201, price: 3.85, currency: USD, erp_code: FAB-00931 }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/stock-snapshot:
    post:
      tags: [Materials]
      summary: Your on-hand QUANTITY per material — reconcile and flag, never overwrite
      description: |
        A count that differs from ours by more than half a percent goes to the review list with
        BOTH numbers. Our own stock is never overwritten — approving records that it was seen.
        If `uom` differs from the unit we count in, the row is not compared (`unit_mismatch`).
        The answer also carries a `variances` list.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [items], properties: { items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/StockSnapshotRow' } } } }
            example:
              items:
                - { material_code: MAT-201, on_hand: 1240.5, uom: m, as_of: "2026-09-25" }
      responses:
        '200':
          description: No row is waiting for a person.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockReply' }
        '202':
          description: At least one row is waiting for a person.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StockReply' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/materials:
    get:
      tags: [Materials]
      summary: Our confirmed materials, for matching codes
      description: |
        Provisional ("new — sourcing to confirm") and archived materials are never listed, so
        you never create an item from a guess. No price is sent. Cursor-paged by our material id.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/ModifiedSince'
      responses:
        '200':
          description: One page.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Material' }
                  next_cursor: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /erp/materials/erp-codes:
    post:
      tags: [Materials]
      summary: Stamp your item code onto our material
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [mappings], properties: { mappings: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/ErpCodeRow' } } } }
            example:
              mappings:
                - { material_code: MAT-201, erp_code: FAB-00931 }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /erp/materials/import:
    post:
      tags: [Materials]
      summary: Match your item catalog to our materials (dry run by default)
      description: |
        Matching is by NAME, and only DISCOVERS a pair — nothing binds without a person.

        - Without `apply=true`: a dry run. Nothing is recorded; the answer is a report.
        - With `apply=true`: each confident match becomes a proposal to stamp your item code,
          and each item we do not have becomes a proposal to create it (no price). Items with no
          `erp_code` are reported as `no_erp_code` and never proposed.

        No price is read.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: apply
          in: query
          schema: { type: string, enum: ['true'] }
          description: Send `apply=true` to create proposals. Any other value is a dry run.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [items], properties: { items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/CatalogItemRow' } } } }
            example:
              items:
                - { name: 100% Cotton Jersey 180gsm, erp_code: FAB-00931, unit: kg, composition: 100% cotton, gsm: 180, width_cm: 180 }
      responses:
        '200':
          description: A dry run, or an apply where no row is waiting.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogReply' }
        '202':
          description: apply=true and at least one proposal is waiting for a person.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogReply' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /erp/settings:
    get:
      tags: [Documents]
      summary: Who issues the PO in this workspace, and how it exchanges data
      responses:
        '200':
          description: The workspace's ERP settings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  po_mode:
                    type: string
                    enum: [operational, erp_issued]
                    description: >
                      operational = this app issues an operational PO and the ERP owns the
                      financial PO; erp_issued = the ERP issues the PO.
                  adapter_mode: { type: string, enum: [mock, file, api] }
                  connected: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Review list ───────────────────────────────
  /erp/proposals:
    get:
      tags: [Review list]
      summary: The answers to what you sent
      description: |
        Every proposal in the workspace's ERP review list, oldest first by id, cursor-paged.
        With `modified_since`, only those received or decided since then — poll this to learn
        decisions.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/ModifiedSince'
      responses:
        '200':
          description: One page.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Proposal' }
                  next_cursor: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /erp/proposals/{id}:
    get:
      tags: [Review list]
      summary: The answer to one proposal
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        '200':
          description: The proposal.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Proposal' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Reference lists ───────────────────────────────
  /erp/reference-lists/{list}:
    parameters:
      - name: list
        in: path
        required: true
        schema: { type: string, enum: [payment_terms, incoterms] }
    get:
      tags: [Reference lists]
      summary: Your codes we hold for this list
      responses:
        '200':
          description: The list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  list: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        erp_id: { type: string }
                        code: { type: string }
                        name: { type: [string, 'null'] }
                        received_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Reference lists]
      summary: Send your own code list (stored at once, replaced per erp_id)
      description: |
        Your vocabulary, not a change to any of our records, so it is stored as a mirror
        (replaced per `erp_id`) rather than reviewed. It lets the sales orders we queue for you
        carry your code (`payment_terms_erp`, `incoterm_erp`) where our value matches one of
        your codes or names EXACTLY (case and spacing aside). Anything else stays null.
        A row missing `erp_id` or `code` is listed in `results` as invalid.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  maxItems: 500
                  items:
                    type: object
                    required: [erp_id, code]
                    properties:
                      erp_id: { type: string, maxLength: 80 }
                      code: { type: string, maxLength: 60 }
                      name: { type: string, maxLength: 200 }
            example:
              items:
                - { erp_id: "7", code: NET30, name: Net 30 days }
      responses:
        '200':
          description: Stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  list: { type: string }
                  stored: { type: integer }
                  results:
                    type: array
                    items: { $ref: '#/components/schemas/RowResult' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Production ───────────────────────────────
  /production/output:
    post:
      tags: [Production]
      summary: Shop-floor output for an order tracked by an outside system
      description: |
        Only for orders an owner has set to be recorded by an outside system (see
        `GET /production/tracked-orders`). An order recorded by hand on the floor answers
        `not_tracked` — machine and hand counts never double up. `external_id` is your unique
        id for the record; a repeat answers `duplicate` and is never counted twice. Approving
        records it through the same path the Garment.io feed uses; a closed order, or output
        past the order's production limit, is refused at that point (proposal `failed`).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/ProductionOutputRow' } } } }
            example:
              rows:
                - { order_ref: ORD-1042, external_id: out-2026-09-25-L3-001, pieces: 420, date: "2026-09-25", department: sewing, line: L3 }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /production/defects:
    post:
      tags: [Production]
      summary: Defects found on the line — COMPARE ONLY
      description: Shown to a person on the review list; not written into the app's quality records.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/DefectRow' } } } }
            example:
              rows:
                - { order_ref: ORD-1042, external_id: dfx-9912, defect_code: open seam, qty: 12, disposition: repair, date: "2026-09-25", operation: side seam }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /production/downtime:
    post:
      tags: [Production]
      summary: Minutes a line stood still — COMPARE ONLY
      description: Shown to a person on the review list; not written into planning.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: 'A single bare row object (without rows) is also accepted as one row.', properties: { rows: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/DowntimeRow' } } } }
            example:
              rows:
                - { order_ref: ORD-1042, external_id: dt-771, line: L3, reason_code: machine breakdown, minutes: 45, date: "2026-09-25" }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /production/tracked-orders:
    get:
      tags: [Production]
      summary: Orders whose floor output comes from an outside system
      description: >
        Cursor-paged by order id. `modified_since` filters by when tracking was turned on.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: One page.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        order_ref: { type: string }
                        external_ref: { type: [string, 'null'] }
                        connected_at: { type: [string, 'null'] }
                  next_cursor: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /production/orders/{order_ref}/tracking:
    put:
      tags: [Production]
      summary: Ask for an order's output to be recorded by an outside system, or by hand
      description: |
        A PROPOSAL — switching it locks or unlocks the floor's own entry, so a person approves.
        `garmentio` = recorded by an outside system; `manual` or `job_cards` = recorded by hand.
        If the order is already in the asked-for state, the answer is 200 with `matches`.
      parameters:
        - $ref: '#/components/parameters/OrderRef'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [manual, job_cards, garmentio] }
                external_ref: { type: string, maxLength: 80 }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /production/orders/{order_ref}/bulletin:
    get:
      tags: [Production]
      summary: The order's operation list and minutes, from its style's costing
      description: >
        An operation with no minutes says so (`smv: null`), never zero; `total_sam` is null when
        no minute is known. Per-line targets are not held in this app, so `line_targets` is
        always empty.
      parameters:
        - $ref: '#/components/parameters/OrderRef'
      responses:
        '200':
          description: The bulletin.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_ref: { type: string }
                  total_sam: { type: [number, 'null'] }
                  operations:
                    type: array
                    items:
                      type: object
                      properties:
                        seq: { type: integer }
                        operation: { type: string }
                        machine: { type: [string, 'null'] }
                        smv: { type: [number, 'null'] }
                  line_targets: { type: array, maxItems: 0, items: {} }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /production/orders/{order_ref}/smv-actuals:
    post:
      tags: [Production]
      summary: Actual minutes per operation — COMPARE ONLY
      description: >
        Shown beside the style's own minutes (matched on the operation's name). Costing is never
        changed by it; a person can change the style's minutes on the style itself.
      parameters:
        - $ref: '#/components/parameters/OrderRef'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                operations:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: '#/components/schemas/SmvRow' }
            example:
              operations:
                - { operation: Attach sleeve, actual_smv: 0.92 }
      responses:
        '200': { $ref: '#/components/responses/BatchComplete' }
        '202': { $ref: '#/components/responses/BatchPending' }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── Webhooks ───────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: The endpoints registered for this workspace
      responses:
        '200':
          description: The list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Webhooks]
      summary: Register an https endpoint — NOT AVAILABLE YET (answers 501)
      x-status: planned
      description: |
        **Not available yet.** Deliveries are not built, so registering is refused with
        **501** `not_available` rather than accepting an endpoint that would never be
        called. Poll instead, every 5–15 minutes:
        `GET /erp/documents?modified_since=…` and `GET /erp/proposals?modified_since=…`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: '`not_available` — webhooks are not built yet; poll the API.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Remove a registered endpoint
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204': { description: Removed. }
        '400': { $ref: '#/components/responses/BadWrite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/KeyReused' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A one-hour token from POST /auth/login. Not an API key, and not a web session:
        a web session cookie is never accepted here, and this token is accepted nowhere else.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >
        REQUIRED on every write (POST, PUT, DELETE). 8–128 characters from letters, digits and
        `. _ : -`. Send the same key when you retry: the stored answer is returned with the
        header `idempotent-replayed: true` and nothing is written again. Keys are kept 24 hours,
        per integration login. Only an accepted answer (below 400) is stored; a refusal is not, so
        a corrected request may reuse the key. A webhook signing secret is never repeated on a
        replay. The same key on a different method or
        path is refused (409 `idempotency_key_reused`).
      schema: { type: string, pattern: '^[A-Za-z0-9._:-]{8,128}$' }
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` from the previous page. Omit for the first page.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    ModifiedSince:
      name: modified_since
      in: query
      description: ISO date-time, e.g. 2026-09-25T00:00:00Z.
      schema: { type: string, format: date-time }
    ExternalKey:
      name: external_key
      in: path
      required: true
      description: >
        The document's key, as we listed it (e.g. `purchase_request:12:po_request:1`).
        URL-encode it.
      schema: { type: string }
    OrderRef:
      name: order_ref
      in: path
      required: true
      description: OUR order number, e.g. ORD-1042.
      schema: { type: string }

  responses:
    Unauthorized:
      description: >
        No token, or a malformed, forged, expired or revoked one, a turned-off login, or a
        token presented on another workspace's address. Header `WWW-Authenticate: Bearer`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: >
        `rate_limited`. Wait the number of seconds in the `Retry-After` header. Limits: 120 calls
        per minute per integration login; 600 per minute per IP address.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: '`not_found`.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: '`validation_error` — a bad query parameter (limit, cursor, modified_since, status, type, id).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadWrite:
      description: '`idempotency_key_required` (missing or malformed Idempotency-Key) or `invalid_json`.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    KeyReused:
      description: '`idempotency_key_reused` — that key was already used for a different request.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooLarge:
      description: '`payload_too_large` — the body is over 1,000,000 bytes. Send it in pages.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unprocessable:
      description: >
        `validation_error` — the body as a whole is unusable (no list under the expected key, an
        empty list, more than 500 rows, a bad `mode`/`status`, a bad webhook URL or event).
        A problem with ONE row is never a 422: it is that row's `invalid` result.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServerError:
      description: '`server_error`. Nothing was half-written; retry with the same Idempotency-Key.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BatchComplete:
      description: '`state: "complete"` — no row is waiting for a person.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/BatchReply' }
    BatchPending:
      description: '`state: "pending_review"` — at least one row is waiting for a person.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/BatchReply' }


  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - rate_limited
                - validation_error
                - idempotency_key_required
                - idempotency_key_reused
                - invalid_json
                - payload_too_large
                - not_found
                - conflict
                - limit_reached
                - secure_storage_unavailable
                - server_error
            message: { type: string, description: For people. Code against `code`. }

    RowOutcome:
      type: string
      description: |
        What one row turned into.
        queued_for_review — a proposal is waiting for a person (see proposal_id).
        already_waiting — the very same proposal was already waiting (same proposal_id).
        auto_accepted — the owner turned auto-accept on for this kind; it was applied.
        apply_failed — auto-accept tried and could not apply it (reason says why).
        matches — the app already holds this value; nothing to do.
        no_such_record — our reference (order, request, material, shipment) was not found.
        never_sent — the purchase request exists but was never sent to the ERP.
        not_tracked — invoice tracking is off, or the order is recorded by hand.
        duplicate — repeated in this call, or already recorded.
        stale — older than the version we hold (invoices, by erp_modified_at).
        conflict — we hold your id / number against different details; a person checks it.
        invalid — the row could not be used (see code).
      enum:
        - queued_for_review
        - already_waiting
        - auto_accepted
        - apply_failed
        - matches
        - no_such_record
        - never_sent
        - not_tracked
        - duplicate
        - stale
        - conflict
        - invalid

    RowErrorCode:
      type: string
      description: Stable — these never change meaning.
      enum: [missing_field, invalid_value, unknown_status, record_archived, unit_mismatch, conflicting_rows]

    RowResult:
      type: object
      required: [ref, outcome]
      properties:
        ref: { type: string, description: 'Which row this is (your reference, or ours).' }
        outcome: { $ref: '#/components/schemas/RowOutcome' }
        code: { $ref: '#/components/schemas/RowErrorCode' }
        reason: { type: string }
        proposal_id: { type: integer }
        state: { type: string, const: pending_review, description: Present on queued_for_review / already_waiting rows. }

    BatchReply:
      type: object
      required: [received, pending_review, state, results]
      properties:
        received: { type: integer }
        pending_review: { type: integer, description: Rows waiting for a person. }
        state: { type: string, enum: [pending_review, complete] }
        results:
          type: array
          items: { $ref: '#/components/schemas/RowResult' }

    StockReply:
      allOf:
        - $ref: '#/components/schemas/BatchReply'
        - type: object
          properties:
            variances:
              type: array
              items:
                type: object
                properties:
                  material_code: { type: string }
                  app_on_hand: { type: number }
                  erp_on_hand: { type: number }
                  queued_for_review: { type: boolean }

    CatalogReply:
      type: object
      properties:
        dry_run: { type: boolean }
        state: { type: string, enum: [pending_review, complete], description: Only when dry_run is false. }
        pending_review: { type: integer, description: Only when dry_run is false. }
        applied: { type: boolean }
        counts:
          type: object
          properties:
            matched: { type: integer }
            already_mapped: { type: integer }
            ambiguous: { type: integer }
            conflict: { type: integer }
            erp_only: { type: integer }
            no_erp_code: { type: integer }
        rows:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              state: { type: string, enum: [matched, already_mapped, ambiguous, conflict, erp_only, no_erp_code] }
              material_code: { type: [string, 'null'] }
              erp_code: { type: [string, 'null'] }
        invalid:
          type: array
          items: { $ref: '#/components/schemas/RowResult' }
        results:
          type: array
          description: Only when dry_run is false.
          items: { $ref: '#/components/schemas/RowResult' }

    RowsOf:
      type: object
      description: >
        `{ "rows": [ ... ] }` — 1 to 500 rows. A single bare row object (without `rows`) is also
        accepted as one row.
      properties:
        rows: { type: array, minItems: 1, maxItems: 500, items: { type: object } }
    ItemsOf:
      type: object
      required: [items]
      properties:
        items: { type: array, minItems: 1, maxItems: 500, items: { type: object } }
    MappingsOf:
      type: object
      required: [mappings]
      properties:
        mappings: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/ErpCodeRow' } }

    PoStatusRow:
      type: object
      required: [erp_po_id, erp_po_number]
      description: Name our request with request_ref OR external_key (one is required).
      properties:
        request_ref: { type: string, example: PR-1042 }
        external_key: { type: string, example: 'purchase_request:12:po_request:1' }
        erp_po_id: { type: string, maxLength: 80, description: YOUR permanent internal id for the PO — what the link is stored on. }
        erp_po_number: { type: string, maxLength: 120, description: The PO number people read (display). }
        status: { type: string, description: Your own status word. }
        date: { type: string, format: date }
    PaymentRow:
      type: object
      required: [order_ref, milestone, status, date]
      properties:
        order_ref: { type: string, example: ORD-1042 }
        milestone: { type: string, description: 'deposit, balance or lc_received (also read: advance, down payment, final, final payment, letter of credit…).' }
        status: { type: string, const: paid }
        date: { type: string, format: date, description: The day it was received. }
        shipment_ref: { type: string }
        erp_payment_id: { type: string, maxLength: 80, description: Your id for the payment — kept for tracing. }
    InvoiceRow:
      type: object
      required: [erp_invoice_id, kind, lifecycle, order_refs]
      properties:
        erp_invoice_id: { type: string, maxLength: 80, description: YOUR permanent internal id — what the record is bound on. }
        doc_number: { type: string, maxLength: 60, description: Display only. }
        kind: { type: string, enum: [invoice, credit_note, down_payment, proforma] }
        lifecycle: { type: string, enum: [issued, cancelled, reversed] }
        presentation: { type: string, enum: [not_presented, presented, accepted, discrepancy], default: not_presented }
        discrepancy_reason: { type: string, maxLength: 200, description: Required when presentation is discrepancy. }
        payment_state: { type: string, enum: [unpaid, partial, paid], default: unpaid, description: 'Your word, stored as given.' }
        issued_date: { type: string, format: date }
        due_date: { type: string, format: date }
        presented_date: { type: string, format: date }
        paid_date: { type: string, format: date }
        reverses_erp_invoice_id: { type: string, maxLength: 80, description: Required for a credit note — the permanent id of the invoice it corrects. }
        erp_modified_at: { type: string, format: date-time, description: When your ERP last changed it. An older message never replaces a newer one. }
        order_refs: { type: array, minItems: 1, maxItems: 50, items: { type: string }, description: OUR order numbers. }
        shipment_ids: { type: array, maxItems: 50, items: { type: integer }, description: OUR shipment ids; each must belong to one of those orders. }
    ClaimRow:
      type: object
      required: [erp_claim_id, kind, reason_category, order_ref, date]
      properties:
        erp_claim_id: { type: string, maxLength: 80 }
        kind: { type: string, enum: [claim, chargeback, debit_note] }
        reason_category: { type: string, maxLength: 60, example: late delivery }
        order_ref: { type: string }
        shipment_id: { type: integer, description: OUR shipment id; must belong to the order. }
        date: { type: string, format: date }
        doc_number: { type: string, maxLength: 60 }
    CommercialTermsBody:
      type: object
      required: [order_ref]
      properties:
        order_ref: { type: string }
        incoterm: { type: string, maxLength: 20 }
        payment_terms: { type: string, maxLength: 200 }
        partial_shipment_allowed: { type: boolean }
        lc:
          type: object
          properties:
            latest_ship: { type: string, format: date, description: Compared with the order's ship date. }
            expiry: { type: string, format: date }
            partial_allowed: { type: boolean }
            transshipment_allowed: { type: boolean }
            presentation_period_days: { type: integer, minimum: 0, maximum: 365 }
            tenor: { type: string, maxLength: 60 }
            required_documents: { type: array, maxItems: 30, items: { type: string, maxLength: 40 } }
        payment_schedule:
          type: array
          maxItems: 20
          description: Only milestone + due_date are read. deposit and balance are compared with the order; other milestones have nothing to compare against.
          items:
            type: object
            properties:
              milestone: { type: string, maxLength: 40 }
              due_date: { type: string, format: date }
        tolerance:
          type: object
          properties:
            plus_pct: { type: number, minimum: 0, maximum: 100 }
            minus_pct: { type: number, minimum: 0, maximum: 100 }
    DocumentStatusRow:
      type: object
      required: [order_ref, document, status]
      properties:
        order_ref: { type: string }
        document: { type: string, maxLength: 40, example: commercial_invoice }
        status: { type: string, enum: [pending, issued, received, failed] }
        date: { type: string, format: date }
        ref: { type: string, description: Your document number (display). }
    MaterialPriceRow:
      type: object
      required: [material_code, price, currency]
      properties:
        material_code: { type: string, example: MAT-201, description: OUR material code. }
        price: { type: number, minimum: 0, maximum: 1000000, description: Unit price — a costing estimate. }
        currency: { type: string, pattern: '^[A-Za-z]{3}$' }
        erp_code: { type: string, description: Your item code (display only; never used to find a material). }
    StockSnapshotRow:
      type: object
      required: [material_code, on_hand]
      properties:
        material_code: { type: string }
        on_hand: { type: number, description: 'A quantity, never money.' }
        uom: { type: string, maxLength: 10 }
        as_of: { type: string, format: date }
    ErpCodeRow:
      type: object
      required: [material_code, erp_code]
      properties:
        material_code: { type: string }
        erp_code: { type: string, maxLength: 60 }
    CatalogItemRow:
      type: object
      required: [name, unit]
      properties:
        name: { type: string, maxLength: 200 }
        erp_code: { type: string, maxLength: 60, description: 'Without it, the item is only reported (no_erp_code).' }
        unit: { type: string, maxLength: 10, example: kg }
        composition: { type: string, maxLength: 200 }
        gsm: { type: number, minimum: 0, maximum: 2000 }
        width_cm: { type: number, minimum: 0, maximum: 1000 }
        supplier_item_code: { type: string, maxLength: 60 }
    ProductionOutputRow:
      type: object
      required: [order_ref, external_id, pieces, date]
      properties:
        order_ref: { type: string }
        external_id: { type: string, maxLength: 120, description: Your unique id for this record. }
        pieces: { type: integer, minimum: 1, maximum: 1000000 }
        date: { type: string, format: date }
        department: { type: string, maxLength: 60 }
        line: { type: string, maxLength: 60 }
        operator: { type: string, maxLength: 60 }
    DefectRow:
      type: object
      required: [order_ref, external_id, defect_code, qty, date]
      properties:
        order_ref: { type: string }
        external_id: { type: string }
        defect_code: { type: string, maxLength: 60 }
        qty: { type: integer, minimum: 1, maximum: 1000000 }
        disposition: { type: string, enum: [repair, reject, b_grade] }
        date: { type: string, format: date }
        operation: { type: string, maxLength: 80 }
    DowntimeRow:
      type: object
      required: [order_ref, external_id, line, reason_code, minutes, date]
      properties:
        order_ref: { type: string }
        external_id: { type: string }
        line: { type: string, maxLength: 60 }
        reason_code: { type: string, maxLength: 60 }
        minutes: { type: integer, minimum: 1, maximum: 1440 }
        date: { type: string, format: date }
    SmvRow:
      type: object
      required: [operation, actual_smv]
      properties:
        operation: { type: string, maxLength: 120, description: Matched on the style's operation name. }
        actual_smv: { type: number, minimum: 0, maximum: 600, description: Minutes. }

    DocStatus:
      type: string
      enum: [pending, exported, acknowledged, linked, error]
    OutboundDocType:
      type: string
      enum: [po_request, sales_order, goods_receipt, material_issue, material_return, dispatch, commission, service_request]

    ErpDocument:
      type: object
      properties:
        external_key: { type: string, example: 'purchase_request:12:po_request:1' }
        doc_type: { $ref: '#/components/schemas/OutboundDocType' }
        record_type: { type: string }
        record_id: { type: integer }
        revision: { type: integer }
        status: { $ref: '#/components/schemas/DocStatus' }
        erp_ref: { type: [string, 'null'] }
        erp_id: { type: [string, 'null'] }
        payload:
          description: >
            Operational facts only — quantities, units, dates, codes. Never a price or an amount.
            Null when the record has moved on (payload_note says so).
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/PurchaseRequestPayload'
            - $ref: '#/components/schemas/SalesOrderPayload'
        payload_note: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PurchaseRequestPayload:
      type: object
      properties:
        request_ref: { type: string }
        for_order: { type: [string, 'null'] }
        buyer: { type: [string, 'null'] }
        lines:
          type: array
          items:
            type: object
            properties:
              item: { type: string }
              item_erp_code: { type: [string, 'null'] }
              vendor: { type: [string, 'null'] }
              vendor_erp_code: { type: [string, 'null'] }
              qty: { type: number }
              uom: { type: string }
    SalesOrderPayload:
      type: object
      properties:
        order_ref: { type: string }
        buyer: { type: [string, 'null'] }
        buyer_erp_code: { type: [string, 'null'] }
        style: { type: [string, 'null'] }
        style_erp_code: { type: [string, 'null'] }
        qty: { type: [number, 'null'] }
        counting_unit: { type: [string, 'null'] }
        ship_date: { type: [string, 'null'] }
        status: { type: [string, 'null'] }
        terms:
          type: [object, 'null']
          properties:
            payment_terms: { type: [string, 'null'] }
            payment_terms_erp:
              description: Your code, only on an exact match with a code you sent us.
              type: [object, 'null']
              properties:
                erp_id: { type: string }
                code: { type: string }
            incoterm: { type: [string, 'null'] }
            incoterm_erp:
              type: [object, 'null']
              properties:
                erp_id: { type: string }
                code: { type: string }
            tolerance:
              type: object
              properties:
                over_pct: { type: [number, 'null'] }
                under_pct: { type: [number, 'null'] }
            partial_shipment: { type: [string, 'null'] }
            lc:
              type: object
              properties:
                ref: { type: [string, 'null'] }
                expiry: { type: [string, 'null'] }
                partial_allowed: { type: [boolean, 'null'] }
            latest_ship_date: { type: [string, 'null'], description: The order's ship date. }

    Material:
      type: object
      properties:
        material_code: { type: string }
        erp_code: { type: [string, 'null'] }
        name: { type: string }
        material_type: { type: [string, 'null'] }
        unit: { type: string }
        composition: { type: [string, 'null'] }
        gsm: { type: [number, 'null'] }
        width_cm: { type: [number, 'null'] }
        updated_at: { type: string }

    ProposalKind:
      type: string
      enum:
        - po_status
        - payment
        - invoice
        - buyer_claim
        - commercial_terms
        - document_status
        - material_price
        - stock_variance
        - material_erp_code
        - material_create
        - production_output
        - production_defect
        - production_downtime
        - smv_actual
        - tracking_mode

    Proposal:
      type: object
      properties:
        proposal_id: { type: integer }
        kind: { $ref: '#/components/schemas/ProposalKind' }
        state:
          type: string
          enum: [pending_review, accepted, rejected, failed, superseded]
          description: |
            pending_review — waiting for a person.
            accepted — approved by a person, or auto-accepted (decided_automatically).
            rejected — reason carries why.
            failed — approved, but it could not be applied; result says why.
            superseded — replaced by a newer message from you for the same record and field.
        subject_ref: { type: string, description: 'What a person reads: ORD-1042, PR-1042, MAT-201…' }
        field: { type: string }
        app_value: { type: [string, 'null'], description: What the app held when it arrived. }
        erp_value: { type: [string, 'null'], description: What you said. }
        received_at: { type: string, format: date-time }
        decided_at: { type: [string, 'null'], format: date-time }
        decided_automatically: { type: boolean }
        reason: { type: [string, 'null'], description: The reason a person gave. }
        result: { type: [string, 'null'], description: 'What happened, in a sentence.' }

    WebhookEvent:
      type: string
      enum:
        - erp.document.queued
        - erp.document.linked
        - stock.variance.raised
        - order.confirmed
        - po.issued
        - grn.received
        - dispatch.created
        - ship.gate.blocked
        - production.output.recorded
        - production.milestone.reached
    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEvent' }
        created_at: { type: string, format: date-time }
