openapi: 3.1.0

info:
  title: FHC Reseller API
  version: 1.1.0
  summary: Programmatic resale order creation and product lookup for authorized FHC resellers.
  description: |
    The FHC Reseller API allows authorized reseller partners to submit resale
    orders directly into the Frameless Hardware Company D3 ERP system, and to
    look up per-customer pricing and per-warehouse product availability.

    ## Endpoints

    **Order submission**

    - `POST /v1/orders` — create a resale order in D3 and receive the assigned
      order number.

    **Product lookups**

    - `GET /v1/products/{product_code}` — description, total on-hand.
    - `GET /v1/products/{product_code}/availability` — per-warehouse
      availability for one SKU.
    - `POST /v1/products/availability` — per-warehouse availability for a list
      of SKUs in a single call.
    - `POST /v1/products/pricing` — customer-specific unit prices for a list
      of `{product_code, quantity}` pairs.

    All endpoints share the same authentication, idempotency, and customer-
    injection model. The customer the token is scoped to is automatically
    applied to any customer-dependent operation — pricing is resolved from
    that customer's price list and contract, and order submission creates the
    order under that customer's account.

    ## What the caller controls (orders)

    For `POST /v1/orders`, the fields documented on the `OrderRecord` schema
    are the **complete** set of caller-controllable inputs. Any other fields
    FHC's ERP needs (pricing, tax, warehouse routing, salesperson assignment,
    etc.) are resolved server-side from the customer account and the product
    master — callers do not supply them.

    If a ship-to field is omitted, behavior depends on the field:

    - **Ship-to name and address** (`SHIP_TO_FNAME`, `SHIP_TO_LNAME`,
      `SHIP_TO_COMPANY`, `SHIP_TO_ADDRESS`, `SHIP_TO_CITY`, `SHIP_TO_STATE`,
      `SHIP_TO_ZIP`, `SHIP_TO_PHONE`): if the caller supplies these they are
      used; if omitted, fields on the resulting order may be blank. Passing a
      complete ship-to block is recommended.
    - **`SHIP_TO_COUNTRY`**: defaults to `US` if omitted.
    - **`SHIP_VIA`**: if omitted, FHC's default carrier for the region is
      used. Carriers may also be substituted server-side based on customer
      preferences and destination state.
    - **`ORDR_EMAIL`, `REQUESTED_BY`, `LOC`, `JOB_NAME`, `PURCHASE_ORDER`,
      `ORDER_COMMENTS`**: all optional; omit if not applicable.

    ## Server-side processing notes (orders)

    The server performs a handful of transformations on caller input:

    - **Control characters stripped.** All text fields have characters below
      ASCII 32 or above 126 removed.
    - **Length truncation.** A few fields are truncated at known widths:
      `JOB_NAME` → 20, `SHIP_TO_COMPANY` → 30, `SHIP_TO_ADDRESS` → 30,
      `PURCHASE_ORDER` → 15. Truncation is silent; the original value is
      preserved in internal audit comments.
    - **Phone normalization.** `SHIP_TO_PHONE` is stripped of non-digits; if
      11 digits the leading `1` is dropped; the result is formatted as
      `NNN/NNN-NNNN`.
    - **Address line composition.** `SHIP_TO_CITY`, `SHIP_TO_STATE`,
      `SHIP_TO_ZIP`, and `SHIP_TO_COUNTRY` are composed server-side into a
      single display line.
    - **Comments word-wrap.** `ORDER_COMMENTS` is split at 30-character word
      boundaries (breaking on ` -_,;:.!?`) and stored as a multi-value list.
    - **Carrier substitution.** `SHIP_VIA` codes may be substituted based on
      the customer's preferences and the destination state.

    ## Authentication

    All requests require an `X-API-Key` header containing a token issued by
    FHC in the form `tok_<customer_id>.<secret>`. Tokens are scoped to a
    specific customer number; the API forcibly injects the token's customer
    into every request — callers cannot operate on behalf of other customers.

    ## Idempotency

    `POST /v1/orders` and `POST /v1/products/pricing` accept an
    `X-Idempotency-Key` header. If the same token replays the same key with
    the same body within 10 minutes, the original response is returned with
    `idempotent_replay: true`. Pricing lookups are idempotent by nature, but
    the key-based replay guarantees byte-identical responses during short
    pricing windows.

    ## Support

    Contact your FHC integration representative for token provisioning,
    rotation, or questions about product codes.

  contact:
    name: FHC Hardware — Integration Support
    url: https://data.fhchardware.com
    email: integrations@fhc-usa.com

servers:
  - url: https://api4.fhchardware.com
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Orders
    description: Resale order operations.
  - name: Products
    description: |
      **In rollout** — endpoints documented here are being deployed this week.
      Check the portal home page for current live status or retry a 404/501
      after confirmation from FHC.

      Product catalog lookups. All product operations are scoped to the
      authenticated customer — pricing in particular reflects the caller's
      contract and price list, not a generic catalog price.

paths:

  /v1/orders:
    post:
      tags: [Orders]
      summary: Create a resale order
      operationId: createOrder
      description: |
        Submit a resale order. On success, creates an order record in D3
        and returns the assigned order number. The caller's customer number
        is injected automatically from the authenticated token — do not
        include `CUSTOMER_NUMBER` in the request body.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
            examples:
              minimal:
                summary: Minimum — one line item, no ship-to (uses customer master defaults)
                value:
                  REC:
                    ORDER_LINES:
                      - PRODUCT_CODE: HP2X6SSS
                        ORDER_QUANTITY: 4
              typical:
                summary: Typical order with a full ship-to block
                value:
                  REC:
                    SHIP_TO_FNAME: Jane
                    SHIP_TO_LNAME: Doe
                    SHIP_TO_ADDRESS: 123 Main St
                    SHIP_TO_CITY: Charlotte
                    SHIP_TO_STATE: NC
                    SHIP_TO_ZIP: "28202"
                    ORDER_LINES:
                      - PRODUCT_CODE: HP2X6SSS
                        ORDER_QUANTITY: 4
              fullOrder:
                summary: Full order with every optional field populated
                value:
                  REC:
                    JOB_NAME: Charlotte Tower East Lobby
                    SHIP_TO_FNAME: Jane
                    SHIP_TO_LNAME: Doe
                    SHIP_TO_COMPANY: Doe Glass Installers
                    SHIP_TO_ADDRESS: 123 Main St Suite 400
                    SHIP_TO_CITY: Charlotte
                    SHIP_TO_STATE: NC
                    SHIP_TO_ZIP: "28202"
                    SHIP_TO_COUNTRY: US
                    SHIP_TO_PHONE: "7045551212"
                    SHIP_VIA: UG
                    PURCHASE_ORDER: PO-12345
                    ORDER_COMMENTS: Please deliver to loading dock B.
                    ORDR_EMAIL: jane@doeglass.example
                    LOC: "10"
                    REQUESTED_BY: Jane Doe
                    ORDER_LINES:
                      - PRODUCT_CODE: HP2X6SSS
                        ORDER_QUANTITY: 4
                      - PRODUCT_CODE: HC1875SSS
                        ORDER_QUANTITY: 2
      responses:
        '200':
          description: Order created successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateOrderSuccess'
              examples:
                created:
                  summary: New order
                  value:
                    order_number: "918845"
                    customer_number: 46813
                    status: created
                idempotentReplay:
                  summary: Idempotent replay of a prior successful call
                  value:
                    order_number: "918845"
                    customer_number: 46813
                    status: created
                    idempotent_replay: true
        '400':
          description: |
            Request rejected. Either the submitted JSON was malformed, or
            D3/MVIS rejected the order on a business rule (customer not
            found, not a resale customer, unknown product code, inactive
            customer, etc.).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainError'
              examples:
                notResaleCustomer:
                  summary: Customer is not configured as a resale customer
                  value:
                    error: NOT A RESALE CUSTOMER
                    customer_number: 99
                productNotFound:
                  summary: Product code does not exist in D3
                  value:
                    error: PRODUCT XYZ NOT FOUND
                    customer_number: 46813
                inactiveCustomer:
                  value:
                    error: INACTIVE CUSTOMER
                    customer_number: 46813
                invalidJson:
                  value:
                    error: invalid JSON body
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamUnreachable' }
        '504': { $ref: '#/components/responses/UpstreamTimeout' }

  /v1/products/{product_code}:
    get:
      tags: [Products]
      summary: Get product details
      operationId: getProduct
      description: |
        Look up a single product by its FHC product code. Returns description
        and total on-hand quantity across all warehouses. For per-warehouse
        breakdown, see `GET /v1/products/{product_code}/availability`.

        Product codes are case-sensitive and must be uppercase.
      parameters:
        - $ref: '#/components/parameters/ProductCodePath'
      responses:
        '200':
          description: Product found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductDetails'
              examples:
                found:
                  value:
                    product_code: HP2X6SSS
                    description: "2x6 Stainless Steel Standoff, Satin"
                    quantity_on_hand: 142
        '400':
          description: Product not found, or invalid product code format.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainError'
              examples:
                notFound:
                  value:
                    error: PRODUCT HP2X6SSS NOT FOUND
                    customer_number: 46813
                lowercase:
                  summary: Product codes are case-sensitive
                  value:
                    error: PRODUCT hp2x6sss NOT FOUND
                    customer_number: 46813
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamUnreachable' }
        '504': { $ref: '#/components/responses/UpstreamTimeout' }

  /v1/products/{product_code}/availability:
    get:
      tags: [Products]
      summary: Get per-warehouse availability for one product
      operationId: getProductAvailability
      description: |
        Returns per-warehouse availability for a single product. Each entry
        in the `availability` array corresponds to one of FHC's five
        warehouses (see the `LOC` values in the `OrderRecord` schema and on
        the Codes reference page).

        Warehouses with zero on-hand for this product may be omitted from
        the response — absence implies zero, but callers should not rely on
        this and should treat missing locations as "no stock currently
        held".
      parameters:
        - $ref: '#/components/parameters/ProductCodePath'
      responses:
        '200':
          description: Product found; per-warehouse availability returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductAvailability'
              examples:
                inStock:
                  value:
                    product_code: HP2X6SSS
                    availability:
                      - location: "1"
                        available: 42
                      - location: "2"
                        available: 87
                      - location: "22"
                        available: 13
                oneWarehouse:
                  value:
                    product_code: HP2X6SSS
                    availability:
                      - location: "10"
                        available: 5
        '400':
          description: Product not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainError'
              examples:
                notFound:
                  value:
                    error: PRODUCT HP2X6SSS NOT FOUND
                    customer_number: 46813
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamUnreachable' }
        '504': { $ref: '#/components/responses/UpstreamTimeout' }

  /v1/products/availability:
    post:
      tags: [Products]
      summary: Bulk per-warehouse availability for a list of products
      operationId: getProductAvailabilityBulk
      description: |
        Check per-warehouse availability for a list of product codes in a
        single call. Response is keyed by product code; each entry carries
        the same `availability` array shape as the single-product endpoint.

        Recommended whenever you're planning a multi-line order — one call
        replaces N calls to the single-product endpoint.

        If any product code in the input is unknown, the entire request is
        rejected; callers should validate codes via `GET /v1/products/{code}`
        first when working with unvetted input.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkAvailabilityRequest'
            examples:
              basic:
                value:
                  product_codes:
                    - HP2X6SSS
                    - HC1875SSS
                    - HTRIM500
      responses:
        '200':
          description: All product codes found; availability returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkAvailabilityResponse'
              examples:
                mixedStock:
                  value:
                    products:
                      - product_code: HP2X6SSS
                        availability:
                          - { location: "1",  available: 42 }
                          - { location: "2",  available: 87 }
                      - product_code: HC1875SSS
                        availability:
                          - { location: "1",  available: 5 }
                      - product_code: HTRIM500
                        availability: []
        '400':
          description: Request body invalid or one or more product codes not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainError'
              examples:
                notFound:
                  value:
                    error: PRODUCT HTRIM500 NOT FOUND
                    customer_number: 46813
                emptyList:
                  value:
                    error: product_codes must contain at least one entry
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamUnreachable' }
        '504': { $ref: '#/components/responses/UpstreamTimeout' }

  /v1/products/pricing:
    post:
      tags: [Products]
      summary: Get customer-specific pricing for a list of products
      operationId: getPricing
      description: |
        Returns the unit price the authenticated customer will pay for each
        supplied `{product_code, quantity}` pair. Prices reflect FHC's
        pricing cascade for this customer:

        1. Customer-specific price (`CUSPRICE`, scoped to your account)
        2. Customer-group price (if your account belongs to a pricing group)
        3. Sale price (`SALPRICE`, if one is active)
        4. Product list price (`PRODUCT`)

        **Quantity matters.** Some prices are tiered — you may see a
        different unit price at QTY 1 vs QTY 100. Always pass the quantity
        you intend to order to get the correct price.

        Use this before submitting `POST /v1/orders` to confirm total cost,
        or at point of sale / cart to display live customer pricing.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PricingRequest'
            examples:
              cart:
                summary: Cart pricing before checkout
                value:
                  products:
                    - product_code: HP2X6SSS
                      quantity: 10
                    - product_code: HC1875SSS
                      quantity: 4
      responses:
        '200':
          description: Pricing resolved for all products.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PricingResponse'
              examples:
                priced:
                  value:
                    customer_number: 46813
                    products:
                      - product_code: HP2X6SSS
                        quantity: 10
                        unit_price: 12.50
                      - product_code: HC1875SSS
                        quantity: 4
                        unit_price: 18.75
        '400':
          description: Request body invalid or one or more product codes not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainError'
              examples:
                notFound:
                  value:
                    error: PRODUCT XYZ NOT FOUND
                    customer_number: 46813
                emptyList:
                  value:
                    error: products must contain at least one entry
                badQty:
                  value:
                    error: quantity must be a positive integer
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamUnreachable' }
        '504': { $ref: '#/components/responses/UpstreamTimeout' }

components:

  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Token in the form `tok_<customer_id>.<secret>`. The customer ID bound
        to the token determines which D3 customer operations run under; the
        caller cannot override it.

  parameters:
    IdempotencyKey:
      name: X-Idempotency-Key
      in: header
      required: false
      schema:
        type: string
      description: |
        Opaque string chosen by the caller. If the same token replays the
        same key with the same request body within 10 minutes, the original
        response is returned and no duplicate operation is performed.
        Recommended for any client that retries on network errors.

    ProductCodePath:
      name: product_code
      in: path
      required: true
      schema:
        type: string
        pattern: '^[A-Z0-9-]+$'
      description: |
        FHC product code. Must be uppercase — product codes are case-
        sensitive and lowercase submissions will be rejected with
        `PRODUCT <code> NOT FOUND`.
      example: HP2X6SSS

  responses:
    Unauthorized:
      description: Missing or malformed `X-API-Key` header.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/InfraError'
          examples:
            missingKey:
              value: { error: Unauthorized }

    Forbidden:
      description: Token is valid but not authorized for this operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/InfraError'
          examples:
            forbidden:
              value: { error: Forbidden }

    TooManyRequests:
      description: Rate limit exceeded for this token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/InfraError'
          examples:
            throttled:
              value: { error: Too Many Requests }

    InternalError:
      description: |
        Unexpected internal error. Safe to retry with the same
        `X-Idempotency-Key`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/InfraError'

    UpstreamUnreachable:
      description: |
        Upstream ERP is unreachable. Safe to retry with the same
        `X-Idempotency-Key`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/InfraError'
          examples:
            unreachable:
              value: { error: "MVIS unreachable: fetch failed" }

    UpstreamTimeout:
      description: |
        Upstream ERP timed out processing the request. For `POST /v1/orders`,
        confirm whether the order was created before blind retry. Using
        `X-Idempotency-Key` on the original call protects against duplicates.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/InfraError'
          examples:
            timeout:
              value: { error: "MVIS timeout" }

  schemas:

    # =======================================================================
    # Order submission
    # =======================================================================

    CreateOrderRequest:
      type: object
      required: [REC]
      properties:
        REC:
          $ref: '#/components/schemas/OrderRecord'
      description: |
        Request envelope. The order itself lives inside the `REC` field to
        match the upstream D3/MVIS record shape.

    OrderRecord:
      type: object
      required: [ORDER_LINES]
      properties:
        JOB_NAME:
          type: string
          description: |
            Job or project name this order is associated with. Appears on
            the packing slip and order documents. Truncated to 20 characters
            server-side if longer.
          example: Charlotte Tower East Lobby
        SHIP_TO_FNAME:
          type: string
          description: |
            Ship-to first name. Combined with `SHIP_TO_LNAME` to form the
            contact name on the order.
          example: Jane
        SHIP_TO_LNAME:
          type: string
          description: Ship-to last name.
          example: Doe
        SHIP_TO_COMPANY:
          type: string
          description: |
            Ship-to company / business name. Truncated to 30 characters
            server-side if longer.
          example: Doe Glass Installers
        SHIP_TO_ADDRESS:
          type: string
          description: |
            Ship-to street address (single line — include suite / unit in
            this field). Truncated to 30 characters server-side if longer.
          example: 123 Main St Suite 400
        SHIP_TO_CITY:
          type: string
          example: Charlotte
        SHIP_TO_STATE:
          type: string
          description: Two-letter US state or province code.
          example: NC
        SHIP_TO_ZIP:
          type: string
          description: 5-digit ZIP or 9-digit ZIP+4 (`NNNNN` or `NNNNN-NNNN`).
          example: "28202"
        SHIP_TO_COUNTRY:
          type: string
          description: ISO 3166-1 alpha-2 country code. Defaults to `US` if omitted.
          default: US
          example: US
        SHIP_TO_PHONE:
          type: string
          description: |
            Ship-to contact phone number. Any reasonable format is accepted —
            the server strips non-digits, drops a leading `1` if present, and
            reformats as `NNN/NNN-NNNN`. 7 or 10 digits recommended.
          example: "7045551212"
        SHIP_VIA:
          type: string
          enum:
            - "03"
            - "04"
            - "05"
            - "07"
            - "FA"
            - "FF"
            - "FG"
            - "TC"
            - "U1"
            - "U2"
            - "U3"
            - "U4"
            - "U5"
            - "UE"
            - "UG"
            - "UP"
            - "US"
            - "WC"
          description: |
            Carrier / service code. If omitted, FHC's default carrier for
            the ship-to region is used. Carrier substitution rules may
            override the submitted code server-side based on customer and
            destination. Valid values:

            | Code | Carrier / service |
            |------|-------------------|
            | `03` | Common Carrier |
            | `04` | UPS |
            | `05` | USPS |
            | `07` | No ship |
            | `FA` | FedEx Air |
            | `FF` | FedEx Freight |
            | `FG` | FedEx Ground |
            | `TC` | Trump Card |
            | `U1` | UPS Next Day Air |
            | `U2` | UPS 2-Day |
            | `U3` | UPS 3-Day Select |
            | `U4` | UPS Next Day Saver |
            | `U5` | UPS 2nd Day AM |
            | `UE` | UPS Next Day Early AM |
            | `UG` | UPS Ground |
            | `UP` | USPS Priority |
            | `US` | UPS Saturday Delivery |
            | `WC` | Will Call |
          example: UG
        PURCHASE_ORDER:
          type: string
          description: |
            Your internal purchase order reference for this order. Truncated
            to 15 characters server-side if longer.
          example: PO-12345
        ORDER_COMMENTS:
          type: string
          description: |
            Free-text notes attached to the order. Split server-side at
            30-character word boundaries (breaking on ` -_,;:.!?`) and
            stored as a multi-value list.
          example: Please deliver to loading dock B.
        ORDR_EMAIL:
          type: string
          description: |
            Contact email for this order. Note the attribute spelling —
            `ORDR_EMAIL` is the MVIS-canonical field name. Stored up to
            approximately 40 characters.
          format: email
          example: jane@doeglass.example
        LOC:
          type: string
          enum: ["1", "2", "6", "10", "22"]
          description: |
            Warehouse / location code. Only these five integer-valued codes
            are accepted:

            | Code | Warehouse |
            |------|-----------|
            | `1`  | California |
            | `2`  | Texas |
            | `6`  | New Jersey |
            | `10` | Washington |
            | `22` | South Carolina |
          example: "10"
        REQUESTED_BY:
          type: string
          description: |
            Name of the person requesting the order. Stored up to
            approximately 15 characters.
          example: Jane Doe
        ORDER_LINES:
          type: array
          description: One or more line items. At least one line is required.
          minItems: 1
          items:
            $ref: '#/components/schemas/OrderLine'
      description: |
        The resale order record. These are the **only** fields the caller
        controls — pricing, tax, warehouse routing, and other order metadata
        are resolved server-side. `CUSTOMER_NUMBER` is force-injected from
        the authenticated token and must NOT be included in the request.

    OrderLine:
      type: object
      required: [PRODUCT_CODE, ORDER_QUANTITY]
      properties:
        PRODUCT_CODE:
          type: string
          pattern: '^[A-Z0-9-]+$'
          description: |
            FHC product code / SKU. Must be uppercase. Product codes are
            case-sensitive in D3 — lowercase submissions will be rejected
            with `PRODUCT <code> NOT FOUND`.
          example: HP2X6SSS
        ORDER_QUANTITY:
          type: integer
          minimum: 1
          description: Quantity ordered. Positive integer.
          example: 4

    CreateOrderSuccess:
      type: object
      required: [order_number, customer_number, status]
      properties:
        order_number:
          type: string
          description: D3 order number assigned to the new order.
          example: "918845"
        customer_number:
          type: integer
          description: The customer number the order was created under.
          example: 46813
        status:
          type: string
          enum: [created]
        idempotent_replay:
          type: boolean
          description: |
            Present and `true` only when this response is a cached replay of
            a prior call with the same `X-Idempotency-Key`.

    # =======================================================================
    # Product lookups
    # =======================================================================

    ProductDetails:
      type: object
      required: [product_code, description, quantity_on_hand]
      properties:
        product_code:
          type: string
          description: FHC product code, as looked up.
          example: HP2X6SSS
        description:
          type: string
          description: Human-readable product description from the D3 product master.
          example: "2x6 Stainless Steel Standoff, Satin"
        quantity_on_hand:
          type: integer
          minimum: 0
          description: |
            Total quantity on hand across all FHC warehouses. For per-warehouse
            breakdown, use `GET /v1/products/{product_code}/availability`.
          example: 142

    ProductAvailability:
      type: object
      required: [product_code, availability]
      properties:
        product_code:
          type: string
          example: HP2X6SSS
        availability:
          type: array
          description: |
            Per-warehouse availability. Warehouses with zero stock may be
            omitted. Treat missing locations as zero.
          items:
            $ref: '#/components/schemas/WarehouseAvailability'

    WarehouseAvailability:
      type: object
      required: [location, available]
      properties:
        location:
          type: string
          enum: ["1", "2", "6", "10", "22"]
          description: |
            Warehouse code — same enumeration as the `LOC` field on
            `OrderRecord`. See the Codes page for the code-to-warehouse
            mapping.
          example: "1"
        available:
          type: integer
          minimum: 0
          description: Units available at this warehouse.
          example: 42

    BulkAvailabilityRequest:
      type: object
      required: [product_codes]
      properties:
        product_codes:
          type: array
          minItems: 1
          maxItems: 200
          description: |
            List of FHC product codes to query. Codes must be uppercase.
            Maximum 200 codes per request.
          items:
            type: string
            pattern: '^[A-Z0-9-]+$'
            example: HP2X6SSS

    BulkAvailabilityResponse:
      type: object
      required: [products]
      properties:
        products:
          type: array
          description: |
            One entry per requested product code, in the same order as the
            request.
          items:
            $ref: '#/components/schemas/ProductAvailability'

    PricingRequest:
      type: object
      required: [products]
      properties:
        products:
          type: array
          minItems: 1
          maxItems: 200
          description: |
            List of `{product_code, quantity}` pairs to price. Quantity
            matters — tiered pricing may return different unit prices at
            different quantities, so pass the quantity you intend to order.
          items:
            $ref: '#/components/schemas/PricingRequestItem'

    PricingRequestItem:
      type: object
      required: [product_code, quantity]
      properties:
        product_code:
          type: string
          pattern: '^[A-Z0-9-]+$'
          example: HP2X6SSS
        quantity:
          type: integer
          minimum: 1
          example: 10

    PricingResponse:
      type: object
      required: [customer_number, products]
      properties:
        customer_number:
          type: integer
          description: The customer number the pricing was resolved under.
          example: 46813
        products:
          type: array
          items:
            $ref: '#/components/schemas/PricingResponseItem'
        idempotent_replay:
          type: boolean
          description: |
            Present and `true` only when this response is a cached replay of
            a prior call with the same `X-Idempotency-Key`.

    PricingResponseItem:
      type: object
      required: [product_code, quantity, unit_price]
      properties:
        product_code:
          type: string
          example: HP2X6SSS
        quantity:
          type: integer
          description: Echo of the requested quantity.
          example: 10
        unit_price:
          type: number
          description: |
            Unit price for this customer at this quantity, in USD. Reflects
            the full pricing cascade (customer-specific price → group price
            → sale price → list price).
          example: 12.50

    # =======================================================================
    # Shared error schemas
    # =======================================================================

    DomainError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: |
            Human-readable error string from the D3 business layer, or a
            client-side validation error (e.g. invalid JSON, unknown product
            code).
          example: NOT A RESALE CUSTOMER
        customer_number:
          type: integer
          description: |
            The customer number the operation was attempted under. Present
            for D3 business errors; absent for pre-processing errors (e.g.
            invalid JSON).
          example: 46813

    InfraError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Short description of the infrastructure-layer failure.
          example: "MVIS unreachable: fetch failed"
