Mapping bill of lading data to ISF 10+2 fields

Assembling a compliant Importer Security Filing means projecting two source documents — the ocean bill of lading and the purchase order — onto the ten importer-supplied ISF data elements, and the mapping is rarely one-to-one. A single house bill can carry parties that CBP treats as five distinct entities (manufacturer, seller, buyer, ship-to, and importer of record), while the container stuffing location and consolidator live only in the carrier’s booking data, not on the commercial paperwork at all. This page gives you a deterministic dataclass-based mapper that reads a normalized bill of lading plus its matching purchase order, resolves each of the ISF-10 importer elements, normalizes the bill-of-lading number to the SCAC-plus-serial form CBP expects, and validates the assembled filing before it is ever queued for transmission to the ISF 10+2 Data Assembly pipeline. It leads with runnable code and anchors every field decision in 19 CFR 149.

Prerequisites

The mapper is a pure function of its inputs; confirm each of these upstream conditions before you apply it:

  • Python 3.10+ with dataclass, Optional, and Decimal available. Never use float for monetary line values — invoice totals feed downstream duty math and must survive ISO 4217 minor-unit rounding exactly.
  • A normalized bill-of-lading record. The BOL must already be parsed into structured fields (parties, ports, container list, HTS references). If you are still extracting these from carrier PDFs or EDI, run the document through Commercial Invoice PDF Extraction and packing-list normalization first so the mapper receives clean text rather than OCR fragments.
  • A matched purchase order. The manufacturer, seller, and country-of-origin elements frequently resolve from the PO, not the BOL. The mapper assumes the PO is already joined to the shipment on a stable order key.
  • A party master keyed by name and address. ISF requires each party’s full name and address; the consignee number (IRS/EIN, SSN, or CBP-assigned number) and the importer-of-record number are looked up from your party master, never invented.
  • Structured logging configured (stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format) so every field resolution is greppable for a reasonable-care audit.

Implementation

The mapper models the source documents and the ten importer data elements as frozen dataclasses, then resolves each element with an explicit precedence rule. Missing mandatory elements raise before the filing can be assembled — a partial ISF is worse than a late one, because it invites a rejection and a fresh clock.

import logging
import re
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Optional

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("isf_bol_mapper")


@dataclass(frozen=True)
class Party:
    name: str
    address: str
    country: str                      # ISO 3166-1 alpha-2
    id_number: Optional[str] = None   # EIN/SSN/CBP-assigned; None until resolved


@dataclass(frozen=True)
class BillOfLading:
    scac: str                         # 2-4 char carrier code
    bol_serial: str                   # raw serial as printed
    shipper: Party                    # carrier "shipper" = often the seller
    consignee: Party
    notify_party: Optional[Party]
    stuffing_location: Optional[str]  # from carrier booking, not commercial docs
    consolidator: Optional[Party]     # NVOCC/freight forwarder
    port_of_lading: str               # UN/LOCODE
    containers: tuple[str, ...] = ()


@dataclass(frozen=True)
class PurchaseOrder:
    order_no: str
    manufacturer: Party
    seller: Party
    buyer: Party
    ship_to: Party
    country_of_origin: str            # ISO 3166-1 alpha-2
    hts_codes: tuple[str, ...] = ()


@dataclass
class Isf10:
    """The ten importer-supplied ISF elements (19 CFR 149.3)."""
    manufacturer: Party
    seller: Party
    buyer: Party
    ship_to: Party
    container_stuffing_location: str
    consolidator: Party
    importer_of_record_no: str
    consignee_no: str
    country_of_origin: str
    htsus_numbers: tuple[str, ...]
    bol_number: str
    assembled_on: date = field(default_factory=date.today)


BOL_NUMBER_RE = re.compile(r"[^A-Z0-9]")


def normalize_bol_number(scac: str, serial: str) -> str:
    """CBP matches the ISF bill number to the carrier's AMS bill: SCAC + serial,
    uppercased, with all separators and spaces removed."""
    scac_clean = BOL_NUMBER_RE.sub("", scac.upper())
    serial_clean = BOL_NUMBER_RE.sub("", serial.upper())
    if not (2 <= len(scac_clean) <= 4):
        raise ValueError(f"SCAC must be 2-4 alphanumerics, got {scac!r}")
    if not serial_clean:
        raise ValueError("BOL serial is empty after normalization")
    return f"{scac_clean}{serial_clean}"


class IsfMapper:
    """Deterministic BOL + PO -> ISF-10 projector."""

    def __init__(self, importer_of_record_no: str, party_master: dict[str, str]):
        self._ior_no = importer_of_record_no
        self._party_master = party_master   # party name -> id_number

    def _resolve_id(self, party: Party) -> Party:
        if party.id_number:
            return party
        looked_up = self._party_master.get(party.name)
        if not looked_up:
            raise ValueError(f"No consignee/party number for {party.name!r}")
        return Party(party.name, party.address, party.country, looked_up)

    def map(self, bol: BillOfLading, po: PurchaseOrder) -> Isf10:
        # Container stuffing location and consolidator are the two elements that
        # exist ONLY on the carrier side; a missing value is a hard stop.
        if not bol.stuffing_location:
            raise ValueError("Container stuffing location absent from BOL booking")
        if bol.consolidator is None:
            raise ValueError("Consolidator (stuffer) absent from BOL booking")

        consignee = self._resolve_id(bol.consignee)
        bol_number = normalize_bol_number(bol.scac, bol.bol_serial)

        isf = Isf10(
            manufacturer=po.manufacturer,           # supplier, from the PO
            seller=po.seller,                        # party of sale
            buyer=po.buyer,                          # party of purchase
            ship_to=po.ship_to,                      # first US delivery address
            container_stuffing_location=bol.stuffing_location,
            consolidator=bol.consolidator,
            importer_of_record_no=self._ior_no,
            consignee_no=consignee.id_number or "",
            country_of_origin=po.country_of_origin,
            htsus_numbers=po.hts_codes,
            bol_number=bol_number,
        )
        logger.info(
            "ISF assembled: bol=%s ior=%s coo=%s hts=%d elements",
            isf.bol_number, isf.importer_of_record_no,
            isf.country_of_origin, len(isf.htsus_numbers),
        )
        return isf


def validate_isf(isf: Isf10) -> list[str]:
    """Return a list of blocking defects. Empty list == filable."""
    defects: list[str] = []
    for label, party in (
        ("manufacturer", isf.manufacturer), ("seller", isf.seller),
        ("buyer", isf.buyer), ("ship_to", isf.ship_to),
        ("consolidator", isf.consolidator),
    ):
        if not party.name or not party.address:
            defects.append(f"{label}: name and address required")
        if len(party.country) != 2:
            defects.append(f"{label}: country must be ISO 3166-1 alpha-2")
    if len(isf.country_of_origin) != 2:
        defects.append("country_of_origin must be ISO 3166-1 alpha-2")
    if not isf.htsus_numbers:
        defects.append("at least one HTSUS number required")
    for code in isf.htsus_numbers:
        digits = code.replace(".", "")
        if not (digits.isdigit() and len(digits) >= 6):
            defects.append(f"HTSUS {code!r} must be >= 6 digits")
    if not isf.consignee_no:
        defects.append("consignee number unresolved")
    return defects

The two hard stops in map — stuffing location and consolidator — encode the practical reality that the “+2” carrier elements aside, these two importer elements are the ones most often missing from commercial paperwork, because they originate in the booking, not the invoice. Refusing to assemble a filing without them is cheaper than a rejected transmission.

Field mapping from bill of lading and purchase order to the ten ISF importer elements Two source boxes on the left feed a mapper in the centre that emits the ten ISF-10 importer elements on the right. The purchase order supplies manufacturer, seller, buyer, ship-to, country of origin, and the HTSUS numbers. The ocean bill of lading supplies the container stuffing location, the consolidator, and the raw bill-of-lading number. The importer of record number and the consignee number are resolved from the party master, not from either document. The bill-of-lading number passes through a normalize step that combines the SCAC carrier code with the serial, uppercased and stripped of separators, before it reaches the filing. Purchase Order manufacturer seller · buyer ship-to country of origin HTSUS numbers Bill of Lading stuffing location consolidator SCAC + serial (from booking, not the invoice) IsfMapper resolve ids normalize BOL# validate Party master IOR# · consignee# ISF-10 elements 1 · manufacturer 2 · seller 3 · buyer 4 · ship-to 5 · stuffing location 6 · consolidator 7 · importer of record # 8 · consignee # 9 · country of origin 10 · HTSUS numbers + normalized BOL# SCAC + serial matches AMS bill defects == [] before transmission
The purchase order supplies the trade parties and origin; the bill of lading supplies the two booking-only elements and the raw bill number; the party master supplies the two ID numbers. The BOL number is normalized to SCAC+serial so it matches the carrier's AMS bill.

Verification steps

Run these checks against a representative shipment before you trust the mapper in production:

  1. Element completeness. Assert validate_isf(isf) returns an empty list for a known-good shipment, and a specific defect for each field you deliberately blank. A silent pass on a blank party name means your Party came in with whitespace that survived the truthiness test — strip on ingestion.
  2. BOL number parity with AMS. Take the carrier’s advance manifest bill number and confirm normalize_bol_number(scac, serial) reproduces it character for character. CBP matches your ISF to the AMS bill on this string; a hyphen or leading zero difference produces a “no bill on file” mismatch, not a clean rejection.
  3. Party number resolution. Feed a consignee whose id_number is None and confirm the mapper resolves it from the party master. Then feed one absent from the master and assert it raises — never let an unresolved consignee number serialize as an empty string into the filing.
  4. Origin precedence. Confirm country of origin comes from the purchase order, not the bill-of-lading shipper’s country. A trans-shipped consignment shows the load port’s country on the BOL but must declare the actual country of manufacture.
  5. HTSUS granularity. Assert every HTSUS number carries at least six digits. ISF accepts the six-digit international subheading, but if your downstream entry pipeline needs ten, confirm the mapper preserves the full code rather than truncating.
  6. Idempotence. Map the same BOL and PO twice and confirm byte-identical output. A non-deterministic assembled_on from date.today() is acceptable, but every other field must be stable so re-runs never produce a spurious amendment.

Edge cases & gotchas

  • Shipper is not always the seller. The carrier’s “shipper” party on the BOL is whoever tendered the cargo — often a freight forwarder or the manufacturer, not the party of sale. Resolve seller from the purchase order, and only fall back to the BOL shipper when the PO genuinely lacks a distinct seller (a factory direct sale).
  • Consolidated house bills. An NVOCC consolidation puts many house bills under one master bill. The consolidator element is the party that stuffed the container, which for LCL cargo is the CFS operator, not your supplier. Map the consolidator from the booking’s stuffer, and file one ISF per house bill even though they share a master.
  • Bill-of-lading number collisions. Two carriers can issue the same serial; only the SCAC prefix disambiguates them. Never store or match on the bare serial. The normalize_bol_number contract is SCAC-first for exactly this reason.
  • Ship-to versus consignee. The ship-to is the first physical US delivery address; the consignee is the party on whose account the goods are entered. They differ for drop-ship and 3PL flows. Mapping ship-to from the consignee’s address is a common defect that survives validation because both are structurally valid parties.
  • Country codes as names. Feeds that carry “United States” or “USA” instead of US fail the alpha-2 length check in validate_isf — which is the intended behavior. Normalize country names to ISO 3166-1 alpha-2 at ingestion rather than loosening the validator.
  • Amendment triggers. Any change to a mapped element after transmission requires an ISF amendment, and a late amendment carries the same liquidated-damages exposure as a late original. Treat a re-mapped field as a filing event, and follow the timing and monitoring workflow in Handling late ISF filings and liquidated damages.

Up: ISF 10+2 Data Assembly

Authoritative references: 19 CFR 149 (Importer Security Filing); CBP ACE ABI/ISF technical guidance; WCO HS 2022 nomenclature; ISO 3166-1 country codes; ISO 4217 currency minor units.