diff --git a/tests/api/examples/cargo_movements.json b/tests/api/examples/cargo_movements.json index 4277c14b..fcde0085 100644 --- a/tests/api/examples/cargo_movements.json +++ b/tests/api/examples/cargo_movements.json @@ -108,6 +108,17 @@ "type": "consignee", "id": "5b3fcbc1bd2efec8999bd37d128a7e2132ebd9acf5a1a6fd8d6d2ba234c13938", "label": "RELIANCE" + }, + { + "type": "load", + "buyer_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "buyer_label": "BUYER CORP", + "seller_id": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", + "seller_label": "SELLER CORP", + "contract_type": "spot", + "delivery_method": "FOB", + "buyer_reason": "ais_history", + "seller_reason": "ais_history" } ] } diff --git a/tests/api/test_cargo_movement.py b/tests/api/test_cargo_movement.py index c53aaed8..5dc919fa 100644 --- a/tests/api/test_cargo_movement.py +++ b/tests/api/test_cargo_movement.py @@ -105,6 +105,17 @@ class TestCargoMovement(TestCase): "id": "5b3fcbc1bd2efec8999bd37d128a7e2132ebd9acf5a1a6fd8d6d2ba234c13938", "label": "RELIANCE", }, + { + "type": "load", + "buyer_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "buyer_label": "BUYER CORP", + "seller_id": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", + "seller_label": "SELLER CORP", + "contract_type": "spot", + "delivery_method": "FOB", + "buyer_reason": "ais_history", + "seller_reason": "ais_history", + }, ], } @@ -191,9 +202,39 @@ def test_convert_to_flat_dict(self) -> None: "trades.0.type": "shipper", "trades.0.id": "c9f70607e743e82428ea24cd32f6e403c6ced09078eacfe3d6c175347a9ab508", "trades.0.label": "NAYARA ENERGY", + "trades.0.label_keyword": None, + "trades.0.buyer_id": None, + "trades.0.buyer_label": None, + "trades.0.seller_id": None, + "trades.0.seller_label": None, + "trades.0.contract_type": None, + "trades.0.delivery_method": None, + "trades.0.buyer_reason": None, + "trades.0.seller_reason": None, "trades.1.type": "consignee", "trades.1.id": "5b3fcbc1bd2efec8999bd37d128a7e2132ebd9acf5a1a6fd8d6d2ba234c13938", "trades.1.label": "RELIANCE", + "trades.1.label_keyword": None, + "trades.1.buyer_id": None, + "trades.1.buyer_label": None, + "trades.1.seller_id": None, + "trades.1.seller_label": None, + "trades.1.contract_type": None, + "trades.1.delivery_method": None, + "trades.1.buyer_reason": None, + "trades.1.seller_reason": None, + "trades.2.type": "load", + "trades.2.id": None, + "trades.2.label": None, + "trades.2.label_keyword": None, + "trades.2.buyer_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "trades.2.buyer_label": "BUYER CORP", + "trades.2.seller_id": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", + "trades.2.seller_label": "SELLER CORP", + "trades.2.contract_type": "spot", + "trades.2.delivery_method": "FOB", + "trades.2.buyer_reason": "ais_history", + "trades.2.seller_reason": "ais_history", } assert flat == expected diff --git a/tests/endpoints/test_cargo_movements_real.py b/tests/endpoints/test_cargo_movements_real.py index 157cce83..07876902 100644 --- a/tests/endpoints/test_cargo_movements_real.py +++ b/tests/endpoints/test_cargo_movements_real.py @@ -374,3 +374,90 @@ def test_filter_exclude_shipper_and_consignee(self): trade["id"] != "5b3fcbc1bd2efec8999bd37d128a7e2132ebd9acf5a1a6fd8d6d2ba234c13938" ) + + def test_filter_by_buyer_and_seller(self): + # BP as buyer, Atlantic LNG as seller + buyer_id = ( + "409dd25aeff466583e8d738f2972ab7d6032622b07c2722f6459edd1be32e1de" + ) + seller_id = ( + "273f7718a1739b407893709d5738992f5c5653bb98460db69295ced21bb38c72" + ) + + cms = CargoMovements().search( + filter_activity="loading_start", + filter_time_min=datetime(2025, 1, 1), + filter_time_max=datetime(2025, 1, 14), + filter_buyer=buyer_id, + filter_seller=seller_id, + ) + + results = list(cms) + assert len(results) > 0, "Expected at least one cargo movement" + + found_matching_trade = False + for cm in results: + if "trades" in cm and cm["trades"]: + # At least one trade should have the filtered buyer_id + buyer_ids = [ + t.get("buyer_id") + for t in cm["trades"] + if t.get("buyer_id") + ] + if buyer_id in buyer_ids: + found_matching_trade = True + + # At least one trade should have the filtered seller_id + seller_ids = [ + t.get("seller_id") + for t in cm["trades"] + if t.get("seller_id") + ] + if seller_id in seller_ids: + found_matching_trade = True + + assert ( + found_matching_trade + ), "Expected at least one trade with matching buyer_id or seller_id" + + def test_filter_exclude_buyer_and_seller(self): + # Exclude BP as buyer, Atlantic LNG as seller + buyer_id = ( + "409dd25aeff466583e8d738f2972ab7d6032622b07c2722f6459edd1be32e1de" + ) + seller_id = ( + "273f7718a1739b407893709d5738992f5c5653bb98460db69295ced21bb38c72" + ) + + cms = CargoMovements().search( + filter_activity="loading_start", + filter_time_min=datetime(2025, 1, 1), + filter_time_max=datetime(2025, 1, 14), + exclude_buyer=buyer_id, + exclude_seller=seller_id, + ) + + results = list(cms) + assert len(results) > 0, "Expected at least one cargo movement" + + for cm in results: + if "trades" in cm and cm["trades"]: + # No trade should have the excluded buyer_id + buyer_ids = [ + t.get("buyer_id") + for t in cm["trades"] + if t.get("buyer_id") + ] + assert ( + buyer_id not in buyer_ids + ), f"Did not expect {buyer_id} in trades" + + # No trade should have the excluded seller_id + seller_ids = [ + t.get("seller_id") + for t in cm["trades"] + if t.get("seller_id") + ] + assert ( + seller_id not in seller_ids + ), f"Did not expect {seller_id} in trades" diff --git a/vortexasdk/api/cargo_movement.py b/vortexasdk/api/cargo_movement.py index f65b91f1..51c5cbb2 100644 --- a/vortexasdk/api/cargo_movement.py +++ b/vortexasdk/api/cargo_movement.py @@ -135,10 +135,24 @@ class CargoMovementProductEntry(BaseModel): label: Optional[str] = None +CargoMovementContractType = Literal["spot", "term"] + +CargoMovementDeliveryMethodType = Literal["FOB", "DES", "CFR", "CIF"] + + class CargoMovementTradeEntry(BaseModel): type: Literal["load", "discharge", "shipper", "consignee"] id: Optional[ID] = None label: Optional[str] = None + label_keyword: Optional[str] = None + buyer_id: Optional[ID] = None + buyer_label: Optional[str] = None + seller_id: Optional[ID] = None + seller_label: Optional[str] = None + contract_type: Optional[CargoMovementContractType] = None + delivery_method: Optional[CargoMovementDeliveryMethodType] = None + buyer_reason: Optional[str] = None + seller_reason: Optional[str] = None class CargoMovement(BaseModel): diff --git a/vortexasdk/endpoints/cargo_movements.py b/vortexasdk/endpoints/cargo_movements.py index d8ef52d4..8f52b2ee 100644 --- a/vortexasdk/endpoints/cargo_movements.py +++ b/vortexasdk/endpoints/cargo_movements.py @@ -84,6 +84,10 @@ def search( disable_geographic_exclusion_rules: Optional[bool] = None, intra_movements: Optional[str] = None, quantity_at_time_of: str = "load", + filter_buyer: Optional[Union[ID, List[ID]]] = None, + exclude_buyer: Optional[Union[ID, List[ID]]] = None, + filter_seller: Optional[Union[ID, List[ID]]] = None, + exclude_seller: Optional[Union[ID, List[ID]]] = None, ) -> CargoMovementsResult: """ @@ -174,16 +178,21 @@ def search( exclude_vessel_tags: A time bound vessel tag, or list of time bound vessel tags to exclude. disable_geographic_exclusion_rules: This controls a popular industry term "intra-movements" and determines - the filter behaviour for cargo leaving then entering the same geographic area. + the filter behaviour for cargo leaving then entering the same geographic area. intra_movements: This enum controls a popular industry term intra-movements and determines the filter behaviour for cargo leaving then entering the same geographic area. - One of `all`, `exclude_intra_country` or `exclude_intra_geography` + One of `all`, `exclude_intra_country` or `exclude_intra_geography` quantity_at_time_of: This parameter is designed for LNG cargo and gives the user the freedom to choose whether to create the time series based on the load volume or discharged volumes, as we consider the discharge quantities to differ from load quantities due to boil-off gas. - One of: - `load` - represents the quantity of the selected unit at the time of the loading event. - `unload` - represents the quantity of the selected unit at the time of the unloading event. + One of: + `load` - represents the quantity of the selected unit at the time of the loading event. + `unload` - represents the quantity of the selected unit at the time of the unloading event. + + filter_buyer: A buyer ID, or list of buyer IDs to filter on. + exclude_buyer: A buyer ID, or list of buyer IDs to exclude. + filter_seller: A seller ID, or list of seller IDs to filter on. + exclude_seller: A seller ID, or list of seller IDs to exclude. # Returns `CargoMovementsResult`, containing all the cargo movements matching the given search terms. @@ -278,6 +287,8 @@ def search( "filter_vessel_propulsion": convert_to_list( exclude_vessel_propulsion ), + "filter_seller": convert_to_list(exclude_seller), + "filter_buyer": convert_to_list(exclude_buyer), } api_params: Dict[str, Any] = { @@ -323,6 +334,8 @@ def search( "intra_movements": intra_movements, "size": self._MAX_PAGE_RESULT_SIZE, "quantity_at_time_of": quantity_at_time_of, + "filter_buyer": convert_to_list(filter_buyer), + "filter_seller": convert_to_list(filter_seller), } response = super().search_with_client(**api_params) diff --git a/vortexasdk/version.py b/vortexasdk/version.py index 6e3c058c..c916e680 100644 --- a/vortexasdk/version.py +++ b/vortexasdk/version.py @@ -1 +1 @@ -__version__ = "1.0.20" +__version__ = "1.0.21"