bb-integrations-library 3.0.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. bb_integrations_lib/__init__.py +0 -0
  2. bb_integrations_lib/converters/__init__.py +0 -0
  3. bb_integrations_lib/gravitate/__init__.py +0 -0
  4. bb_integrations_lib/gravitate/base_api.py +20 -0
  5. bb_integrations_lib/gravitate/model.py +29 -0
  6. bb_integrations_lib/gravitate/pe_api.py +122 -0
  7. bb_integrations_lib/gravitate/rita_api.py +552 -0
  8. bb_integrations_lib/gravitate/sd_api.py +572 -0
  9. bb_integrations_lib/gravitate/testing/TTE/sd/models.py +1398 -0
  10. bb_integrations_lib/gravitate/testing/TTE/sd/tests/test_models.py +2987 -0
  11. bb_integrations_lib/gravitate/testing/__init__.py +0 -0
  12. bb_integrations_lib/gravitate/testing/builder.py +55 -0
  13. bb_integrations_lib/gravitate/testing/openapi.py +70 -0
  14. bb_integrations_lib/gravitate/testing/util.py +274 -0
  15. bb_integrations_lib/mappers/__init__.py +0 -0
  16. bb_integrations_lib/mappers/prices/__init__.py +0 -0
  17. bb_integrations_lib/mappers/prices/model.py +106 -0
  18. bb_integrations_lib/mappers/prices/price_mapper.py +127 -0
  19. bb_integrations_lib/mappers/prices/protocol.py +20 -0
  20. bb_integrations_lib/mappers/prices/util.py +61 -0
  21. bb_integrations_lib/mappers/rita_mapper.py +523 -0
  22. bb_integrations_lib/models/__init__.py +0 -0
  23. bb_integrations_lib/models/dtn_supplier_invoice.py +487 -0
  24. bb_integrations_lib/models/enums.py +28 -0
  25. bb_integrations_lib/models/pipeline_structs.py +76 -0
  26. bb_integrations_lib/models/probe/probe_event.py +20 -0
  27. bb_integrations_lib/models/probe/request_data.py +431 -0
  28. bb_integrations_lib/models/probe/resume_token.py +7 -0
  29. bb_integrations_lib/models/rita/audit.py +113 -0
  30. bb_integrations_lib/models/rita/auth.py +30 -0
  31. bb_integrations_lib/models/rita/bucket.py +17 -0
  32. bb_integrations_lib/models/rita/config.py +188 -0
  33. bb_integrations_lib/models/rita/constants.py +19 -0
  34. bb_integrations_lib/models/rita/crossroads_entities.py +293 -0
  35. bb_integrations_lib/models/rita/crossroads_mapping.py +428 -0
  36. bb_integrations_lib/models/rita/crossroads_monitoring.py +78 -0
  37. bb_integrations_lib/models/rita/crossroads_network.py +41 -0
  38. bb_integrations_lib/models/rita/crossroads_rules.py +80 -0
  39. bb_integrations_lib/models/rita/email.py +39 -0
  40. bb_integrations_lib/models/rita/issue.py +63 -0
  41. bb_integrations_lib/models/rita/mapping.py +227 -0
  42. bb_integrations_lib/models/rita/probe.py +58 -0
  43. bb_integrations_lib/models/rita/reference_data.py +110 -0
  44. bb_integrations_lib/models/rita/source_system.py +9 -0
  45. bb_integrations_lib/models/rita/workers.py +76 -0
  46. bb_integrations_lib/models/sd/bols_and_drops.py +241 -0
  47. bb_integrations_lib/models/sd/get_order.py +301 -0
  48. bb_integrations_lib/models/sd/orders.py +18 -0
  49. bb_integrations_lib/models/sd_api.py +115 -0
  50. bb_integrations_lib/pipelines/__init__.py +0 -0
  51. bb_integrations_lib/pipelines/parsers/__init__.py +0 -0
  52. bb_integrations_lib/pipelines/parsers/distribution_report/__init__.py +0 -0
  53. bb_integrations_lib/pipelines/parsers/distribution_report/order_by_site_product_parser.py +50 -0
  54. bb_integrations_lib/pipelines/parsers/distribution_report/tank_configs_parser.py +47 -0
  55. bb_integrations_lib/pipelines/parsers/dtn/__init__.py +0 -0
  56. bb_integrations_lib/pipelines/parsers/dtn/dtn_price_parser.py +102 -0
  57. bb_integrations_lib/pipelines/parsers/dtn/model.py +79 -0
  58. bb_integrations_lib/pipelines/parsers/price_engine/__init__.py +0 -0
  59. bb_integrations_lib/pipelines/parsers/price_engine/parse_accessorials_prices_parser.py +67 -0
  60. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/__init__.py +0 -0
  61. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_merge_parser.py +111 -0
  62. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_sync_parser.py +107 -0
  63. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/shared.py +81 -0
  64. bb_integrations_lib/pipelines/parsers/tank_reading_parser.py +155 -0
  65. bb_integrations_lib/pipelines/parsers/tank_sales_parser.py +144 -0
  66. bb_integrations_lib/pipelines/shared/__init__.py +0 -0
  67. bb_integrations_lib/pipelines/shared/allocation_matching.py +227 -0
  68. bb_integrations_lib/pipelines/shared/bol_allocation.py +2793 -0
  69. bb_integrations_lib/pipelines/steps/__init__.py +0 -0
  70. bb_integrations_lib/pipelines/steps/create_accessorials_step.py +80 -0
  71. bb_integrations_lib/pipelines/steps/distribution_report/__init__.py +0 -0
  72. bb_integrations_lib/pipelines/steps/distribution_report/distribution_report_datafram_to_raw_data.py +33 -0
  73. bb_integrations_lib/pipelines/steps/distribution_report/get_model_history_step.py +50 -0
  74. bb_integrations_lib/pipelines/steps/distribution_report/get_order_by_site_product_step.py +62 -0
  75. bb_integrations_lib/pipelines/steps/distribution_report/get_tank_configs_step.py +40 -0
  76. bb_integrations_lib/pipelines/steps/distribution_report/join_distribution_order_dos_step.py +85 -0
  77. bb_integrations_lib/pipelines/steps/distribution_report/upload_distribution_report_datafram_to_big_query.py +47 -0
  78. bb_integrations_lib/pipelines/steps/echo_step.py +14 -0
  79. bb_integrations_lib/pipelines/steps/export_dataframe_to_rawdata_step.py +28 -0
  80. bb_integrations_lib/pipelines/steps/exporting/__init__.py +0 -0
  81. bb_integrations_lib/pipelines/steps/exporting/bbd_export_payroll_file_step.py +107 -0
  82. bb_integrations_lib/pipelines/steps/exporting/bbd_export_readings_step.py +236 -0
  83. bb_integrations_lib/pipelines/steps/exporting/cargas_wholesale_bundle_upload_step.py +33 -0
  84. bb_integrations_lib/pipelines/steps/exporting/dataframe_flat_file_export.py +29 -0
  85. bb_integrations_lib/pipelines/steps/exporting/gcs_bucket_export_file_step.py +34 -0
  86. bb_integrations_lib/pipelines/steps/exporting/keyvu_export_step.py +356 -0
  87. bb_integrations_lib/pipelines/steps/exporting/pe_price_export_step.py +238 -0
  88. bb_integrations_lib/pipelines/steps/exporting/platform_science_order_sync_step.py +500 -0
  89. bb_integrations_lib/pipelines/steps/exporting/save_rawdata_to_disk.py +15 -0
  90. bb_integrations_lib/pipelines/steps/exporting/sftp_export_file_step.py +60 -0
  91. bb_integrations_lib/pipelines/steps/exporting/sftp_export_many_files_step.py +23 -0
  92. bb_integrations_lib/pipelines/steps/exporting/update_exported_orders_table_step.py +64 -0
  93. bb_integrations_lib/pipelines/steps/filter_step.py +22 -0
  94. bb_integrations_lib/pipelines/steps/get_latest_sync_date.py +34 -0
  95. bb_integrations_lib/pipelines/steps/importing/bbd_import_payroll_step.py +30 -0
  96. bb_integrations_lib/pipelines/steps/importing/get_order_numbers_to_export_step.py +138 -0
  97. bb_integrations_lib/pipelines/steps/importing/load_file_to_dataframe_step.py +46 -0
  98. bb_integrations_lib/pipelines/steps/importing/load_imap_attachment_step.py +172 -0
  99. bb_integrations_lib/pipelines/steps/importing/pe_bulk_sync_price_structure_step.py +68 -0
  100. bb_integrations_lib/pipelines/steps/importing/pe_price_merge_step.py +86 -0
  101. bb_integrations_lib/pipelines/steps/importing/sftp_file_config_step.py +124 -0
  102. bb_integrations_lib/pipelines/steps/importing/test_exact_file_match.py +57 -0
  103. bb_integrations_lib/pipelines/steps/null_step.py +15 -0
  104. bb_integrations_lib/pipelines/steps/pe_integration_job_step.py +32 -0
  105. bb_integrations_lib/pipelines/steps/processing/__init__.py +0 -0
  106. bb_integrations_lib/pipelines/steps/processing/archive_gcs_step.py +76 -0
  107. bb_integrations_lib/pipelines/steps/processing/archive_sftp_step.py +48 -0
  108. bb_integrations_lib/pipelines/steps/processing/bbd_format_tank_readings_step.py +492 -0
  109. bb_integrations_lib/pipelines/steps/processing/bbd_upload_prices_step.py +54 -0
  110. bb_integrations_lib/pipelines/steps/processing/bbd_upload_tank_sales_step.py +124 -0
  111. bb_integrations_lib/pipelines/steps/processing/bbd_upload_tankreading_step.py +80 -0
  112. bb_integrations_lib/pipelines/steps/processing/convert_bbd_order_to_cargas_step.py +226 -0
  113. bb_integrations_lib/pipelines/steps/processing/delete_sftp_step.py +33 -0
  114. bb_integrations_lib/pipelines/steps/processing/dtn/__init__.py +2 -0
  115. bb_integrations_lib/pipelines/steps/processing/dtn/convert_dtn_invoice_to_sd_model.py +145 -0
  116. bb_integrations_lib/pipelines/steps/processing/dtn/parse_dtn_invoice_step.py +38 -0
  117. bb_integrations_lib/pipelines/steps/processing/file_config_parser_step.py +720 -0
  118. bb_integrations_lib/pipelines/steps/processing/file_config_parser_step_v2.py +418 -0
  119. bb_integrations_lib/pipelines/steps/processing/get_sd_price_price_request.py +105 -0
  120. bb_integrations_lib/pipelines/steps/processing/keyvu_upload_deliveryplan_step.py +39 -0
  121. bb_integrations_lib/pipelines/steps/processing/mark_orders_exported_in_bbd_step.py +185 -0
  122. bb_integrations_lib/pipelines/steps/processing/pe_price_rows_processing_step.py +174 -0
  123. bb_integrations_lib/pipelines/steps/processing/send_process_report_step.py +47 -0
  124. bb_integrations_lib/pipelines/steps/processing/sftp_renamer_step.py +61 -0
  125. bb_integrations_lib/pipelines/steps/processing/tank_reading_touchup_steps.py +75 -0
  126. bb_integrations_lib/pipelines/steps/processing/upload_supplier_invoice_step.py +16 -0
  127. bb_integrations_lib/pipelines/steps/send_attached_in_rita_email_step.py +44 -0
  128. bb_integrations_lib/pipelines/steps/send_rita_email_step.py +34 -0
  129. bb_integrations_lib/pipelines/steps/sleep_step.py +24 -0
  130. bb_integrations_lib/pipelines/wrappers/__init__.py +0 -0
  131. bb_integrations_lib/pipelines/wrappers/accessorials_transformation.py +104 -0
  132. bb_integrations_lib/pipelines/wrappers/distribution_report.py +191 -0
  133. bb_integrations_lib/pipelines/wrappers/export_tank_readings.py +237 -0
  134. bb_integrations_lib/pipelines/wrappers/import_tank_readings.py +192 -0
  135. bb_integrations_lib/pipelines/wrappers/wrapper.py +81 -0
  136. bb_integrations_lib/protocols/__init__.py +0 -0
  137. bb_integrations_lib/protocols/flat_file.py +210 -0
  138. bb_integrations_lib/protocols/gravitate_client.py +104 -0
  139. bb_integrations_lib/protocols/pipelines.py +697 -0
  140. bb_integrations_lib/provider/__init__.py +0 -0
  141. bb_integrations_lib/provider/api/__init__.py +0 -0
  142. bb_integrations_lib/provider/api/cargas/__init__.py +0 -0
  143. bb_integrations_lib/provider/api/cargas/client.py +43 -0
  144. bb_integrations_lib/provider/api/cargas/model.py +49 -0
  145. bb_integrations_lib/provider/api/cargas/protocol.py +23 -0
  146. bb_integrations_lib/provider/api/dtn/__init__.py +0 -0
  147. bb_integrations_lib/provider/api/dtn/client.py +128 -0
  148. bb_integrations_lib/provider/api/dtn/protocol.py +9 -0
  149. bb_integrations_lib/provider/api/keyvu/__init__.py +0 -0
  150. bb_integrations_lib/provider/api/keyvu/client.py +30 -0
  151. bb_integrations_lib/provider/api/keyvu/model.py +149 -0
  152. bb_integrations_lib/provider/api/macropoint/__init__.py +0 -0
  153. bb_integrations_lib/provider/api/macropoint/client.py +28 -0
  154. bb_integrations_lib/provider/api/macropoint/model.py +40 -0
  155. bb_integrations_lib/provider/api/pc_miler/__init__.py +0 -0
  156. bb_integrations_lib/provider/api/pc_miler/client.py +130 -0
  157. bb_integrations_lib/provider/api/pc_miler/model.py +6 -0
  158. bb_integrations_lib/provider/api/pc_miler/web_services_apis.py +131 -0
  159. bb_integrations_lib/provider/api/platform_science/__init__.py +0 -0
  160. bb_integrations_lib/provider/api/platform_science/client.py +147 -0
  161. bb_integrations_lib/provider/api/platform_science/model.py +82 -0
  162. bb_integrations_lib/provider/api/quicktrip/__init__.py +0 -0
  163. bb_integrations_lib/provider/api/quicktrip/client.py +52 -0
  164. bb_integrations_lib/provider/api/telapoint/__init__.py +0 -0
  165. bb_integrations_lib/provider/api/telapoint/client.py +68 -0
  166. bb_integrations_lib/provider/api/telapoint/model.py +178 -0
  167. bb_integrations_lib/provider/api/warren_rogers/__init__.py +0 -0
  168. bb_integrations_lib/provider/api/warren_rogers/client.py +207 -0
  169. bb_integrations_lib/provider/aws/__init__.py +0 -0
  170. bb_integrations_lib/provider/aws/s3/__init__.py +0 -0
  171. bb_integrations_lib/provider/aws/s3/client.py +126 -0
  172. bb_integrations_lib/provider/ftp/__init__.py +0 -0
  173. bb_integrations_lib/provider/ftp/client.py +140 -0
  174. bb_integrations_lib/provider/ftp/interface.py +273 -0
  175. bb_integrations_lib/provider/ftp/model.py +76 -0
  176. bb_integrations_lib/provider/imap/__init__.py +0 -0
  177. bb_integrations_lib/provider/imap/client.py +228 -0
  178. bb_integrations_lib/provider/imap/model.py +3 -0
  179. bb_integrations_lib/provider/sqlserver/__init__.py +0 -0
  180. bb_integrations_lib/provider/sqlserver/client.py +106 -0
  181. bb_integrations_lib/secrets/__init__.py +4 -0
  182. bb_integrations_lib/secrets/adapters.py +98 -0
  183. bb_integrations_lib/secrets/credential_models.py +222 -0
  184. bb_integrations_lib/secrets/factory.py +85 -0
  185. bb_integrations_lib/secrets/providers.py +160 -0
  186. bb_integrations_lib/shared/__init__.py +0 -0
  187. bb_integrations_lib/shared/exceptions.py +25 -0
  188. bb_integrations_lib/shared/model.py +1039 -0
  189. bb_integrations_lib/shared/shared_enums.py +510 -0
  190. bb_integrations_lib/storage/README.md +236 -0
  191. bb_integrations_lib/storage/__init__.py +0 -0
  192. bb_integrations_lib/storage/aws/__init__.py +0 -0
  193. bb_integrations_lib/storage/aws/s3.py +8 -0
  194. bb_integrations_lib/storage/defaults.py +72 -0
  195. bb_integrations_lib/storage/gcs/__init__.py +0 -0
  196. bb_integrations_lib/storage/gcs/client.py +8 -0
  197. bb_integrations_lib/storage/gcsmanager/__init__.py +0 -0
  198. bb_integrations_lib/storage/gcsmanager/client.py +8 -0
  199. bb_integrations_lib/storage/setup.py +29 -0
  200. bb_integrations_lib/util/__init__.py +0 -0
  201. bb_integrations_lib/util/cache/__init__.py +0 -0
  202. bb_integrations_lib/util/cache/custom_ttl_cache.py +75 -0
  203. bb_integrations_lib/util/cache/protocol.py +9 -0
  204. bb_integrations_lib/util/config/__init__.py +0 -0
  205. bb_integrations_lib/util/config/manager.py +391 -0
  206. bb_integrations_lib/util/config/model.py +41 -0
  207. bb_integrations_lib/util/exception_logger/__init__.py +0 -0
  208. bb_integrations_lib/util/exception_logger/exception_logger.py +146 -0
  209. bb_integrations_lib/util/exception_logger/test.py +114 -0
  210. bb_integrations_lib/util/utils.py +364 -0
  211. bb_integrations_lib/workers/__init__.py +0 -0
  212. bb_integrations_lib/workers/groups.py +13 -0
  213. bb_integrations_lib/workers/rpc_worker.py +50 -0
  214. bb_integrations_lib/workers/topics.py +20 -0
  215. bb_integrations_library-3.0.11.dist-info/METADATA +59 -0
  216. bb_integrations_library-3.0.11.dist-info/RECORD +217 -0
  217. bb_integrations_library-3.0.11.dist-info/WHEEL +4 -0
@@ -0,0 +1,431 @@
1
+ import re
2
+ from datetime import datetime, UTC, timezone, timedelta
3
+ from typing import List, override, Literal, Any, Self
4
+
5
+ from bson import ObjectId
6
+ from loguru import logger
7
+ from pydantic import BaseModel, Field
8
+
9
+ from bb_integrations_lib.gravitate.testing.TTE.sd.models import PydanticObjectId
10
+ from bb_integrations_lib.models.sd_api import DeliveryWindow
11
+
12
+
13
+ class RequestDataSerializationHalted(Exception):
14
+ """Thrown by a probe when it is not serializing a certain event, but for a non-error reason. E.g. this may be thrown
15
+ by a probe that only serializes some kinds of events but ignores others."""
16
+ pass
17
+
18
+
19
+ class RequestData(BaseModel):
20
+ """This represents a data structure for a worker to use for its operation. This is a discriminated union."""
21
+ request_type: str
22
+
23
+ @classmethod
24
+ def from_db_obj(cls, obj: dict, updated_fields: dict | None = None):
25
+ """
26
+ Constructs BUT DOES NOT VALIDATE a RequestData object from the database info provided. Workers will know what
27
+ data value they expect and call model_validate appropriately.
28
+ """
29
+ ...
30
+
31
+ @classmethod
32
+ def get_id(cls, obj: dict):
33
+ if type(obj.get("_id")) is PydanticObjectId:
34
+ order_id = str(obj["_id"])
35
+ elif type(obj.get("_id")) is ObjectId:
36
+ order_id = str(obj["_id"])
37
+ elif type(obj.get("_id")) is dict and "$oid" in obj["_id"]:
38
+ order_id = obj["_id"].get("$oid")
39
+ else:
40
+ raise Exception("Couldn't find an id on object.")
41
+ return order_id
42
+
43
+ @classmethod
44
+ def get_dt(cls, val: Any) -> datetime | None:
45
+ if type(val) is datetime:
46
+ return val.replace(tzinfo=timezone.utc)
47
+ elif type(val) is str:
48
+ return datetime.fromisoformat(val).replace(tzinfo=timezone.utc)
49
+ elif type(val) is dict:
50
+ return val.get("$date").replace(tzinfo=timezone.utc)
51
+ return None
52
+
53
+
54
+ class ErrorRequestData(RequestData):
55
+ """Probes create worker requests with this type when they encounter an error. This is just to expose a breakage to the logging system."""
56
+ request_type: Literal["probe_error"]
57
+ error: str | None = None
58
+
59
+ #### Other Request Data
60
+
61
+ class MacropointLocationUpdate(RequestData):
62
+ """Data for the MacropointIntegrationRunnable. Uses the Macropoint API to update the location as we get them from S&D"""
63
+ request_type: Literal["MacropointLocationUpdate"] = "MacropointLocationUpdate"
64
+ order_number: int = Field(..., description="The order number of the order in S&D.")
65
+ lat: float = Field(..., description="Latitude")
66
+ lon: float = Field(..., description="Longitude")
67
+ date: datetime = Field(..., description="Date of the location update")
68
+ po: str = Field(..., description="The raw PO number on the order. This has not been parsed to find the relevant LD number for Macropoint")
69
+
70
+ @override
71
+ @classmethod
72
+ def from_db_obj(cls, obj: dict, updated_fields: dict | None = None):
73
+ order_number = obj.get("in_cab_context", {}).get("order_number")
74
+ lat = obj.get("gps", {}).get("lat")
75
+ lon = obj.get("gps", {}).get("lon")
76
+ date = cls.get_dt(obj.get("date"))
77
+ po = obj.get("po", "")
78
+ return MacropointLocationUpdate.model_construct(order_number=order_number, lat=lat, lon=lon, date=date, po=po)
79
+
80
+
81
+
82
+ #### Crossroads Request Datas
83
+
84
+ class CreateOrderFromSDOrder_Drop(BaseModel):
85
+ location_name: str = Field(..., description="Name of drop location")
86
+ location_id: str = Field(..., description="ID of the drop location")
87
+ tank_id: str = Field(..., description="ID of the drop tank")
88
+ product_name: str = Field(..., description="Name of the product to drop")
89
+ product_id: str = Field(..., description="ID of the product to drop")
90
+ volume: float = Field(..., description="Drop volume")
91
+ from_compartments: list[int] = Field([], description="The list of compartments that the drop is sourced from.")
92
+ delivery_window_start: datetime | None = Field(None, description="The earliest date this drop can be made.")
93
+ delivery_window_end: datetime | None = Field(None, description="The latest date this drop can be made.")
94
+
95
+
96
+ class CreateOrderFromSDOrder_Load(BaseModel):
97
+ location_name: str = Field("", description="Name of lift location")
98
+ location_id: str = Field(..., description="ID of the lift location")
99
+ product_name: str = Field("", description="Name of the lift product")
100
+ product_id: str = Field(..., description="ID of the lift product")
101
+ supplier_name: str = Field("", description="Name of the supplier")
102
+ supplier_id: str = Field(..., description="ID of the supplier")
103
+ volume: float = Field(..., description="Load volume")
104
+ compartments: list[int] = Field([], description="The list of compartments the load is put into.")
105
+
106
+
107
+ class CreateOrderFromSDOrder_Compartment(BaseModel):
108
+ compartment_index: int = Field(..., description="Compartment index")
109
+ product_id: str = Field(..., description="ID of the product in the compartment.")
110
+ product_name: str = Field(..., description="Name of the product in the compartment.")
111
+ volume: int = Field(..., description="Volume loaded into the compartment")
112
+
113
+ class CreateOrderFromSDOrder(RequestData):
114
+ request_type: Literal["CreateOrderFromSDOrder"] = "CreateOrderFromSDOrder"
115
+ order_id: str = Field(..., description="Order database ID")
116
+ order_number: int = Field(..., description="Order number")
117
+ note: str | None = Field(None, description="Order note, if available")
118
+ extra_data: dict = Field({}, description="The order's original extra_data. Crossroads integrations can update the extra data and need to know the original value to prevent clobbering.")
119
+ supply_owner_id: str = Field(..., description="Counterparty ID of the supply owner in the order's S&D instance.")
120
+ freight_customer_id: str = Field(..., description="Counterparty ID for the freight customer on this order.")
121
+ market: str = Field(..., description="Market name for this order")
122
+ drop_details: List[CreateOrderFromSDOrder_Drop] = Field([], description="Order drops")
123
+ load_details: List[CreateOrderFromSDOrder_Load] = Field([], description="Order loads")
124
+ compartments: List[CreateOrderFromSDOrder_Compartment] = Field([], description="Order compartments, by index")
125
+ state: str | None = Field(default=None, description="Order state")
126
+
127
+ def get_delivery_window(self, default_span_days = 7):
128
+ """Calculates the delivery window from the drops on this object. If multiple drops have delivery windows, the
129
+ start time is the latest possible start time and the end time is the earliest possible end time. If the order
130
+ does not have delivery windows set on its drops, which may happen when it is very new, the delivery window
131
+ defaults to the current time plus 7 days. If the delivery window is impossible (the end of the window is before
132
+ the start of the window) then also fallback to that basic window."""
133
+ window_starts = [x.delivery_window_start for x in self.drop_details if x.delivery_window_start is not None]
134
+ window_ends = [x.delivery_window_end for x in self.drop_details if x.delivery_window_end is not None]
135
+ default_start = datetime.now()
136
+ default_end = default_start + timedelta(days=default_span_days)
137
+
138
+ if len(window_starts) > 0:
139
+ window_start = max(window_starts)
140
+ else:
141
+ window_start = default_start
142
+ if len(window_ends) > 0:
143
+ window_end = min(window_ends)
144
+ else:
145
+ window_end = default_end
146
+
147
+ if window_end < window_start:
148
+ # Impossible delivery window; S&D API won't let us create an order with this. Adjust by setting end to be start
149
+ # + the default span days (this may be different than the "no delivery window" case because we may know the
150
+ # start date
151
+ window_end = window_start + timedelta(days=default_span_days)
152
+
153
+ return DeliveryWindow(start=window_start, end=window_end)
154
+
155
+ @override
156
+ @classmethod
157
+ def from_db_obj(cls, obj: dict, updated_fields: dict | None = None):
158
+ compartments = []
159
+ drop_details = []
160
+ load_details = []
161
+ order_id = cls.get_id(obj)
162
+ for comp in obj.get("compartments"):
163
+ compartments.append(CreateOrderFromSDOrder_Compartment.model_construct(
164
+ compartment_index=comp.get("compartment_index"),
165
+ product_id=comp.get("product_id"),
166
+ product_name=comp.get("product_name"),
167
+ volume=comp.get("volume")
168
+ ))
169
+ for drop in obj.get("drops"):
170
+ for detail in drop.get("details"):
171
+ drop_details.append(CreateOrderFromSDOrder_Drop.model_construct(
172
+ location_name=drop.get("location_name"),
173
+ location_id=drop.get("location_id"),
174
+ product_name=detail.get("product_name"),
175
+ product_id=detail.get("product_id"),
176
+ tank_id=str(detail.get("tank_id")),
177
+ volume=int(sum([x.get("volume", 0) for x in detail.get("sources")])),
178
+ from_compartments=[x.get("compartment_index") for x in detail.get("sources")],
179
+ delivery_window_start=detail.get("window_start"),
180
+ delivery_window_end=detail.get("window_end"),
181
+ ))
182
+ for load in obj.get("loads"):
183
+ for detail in load.get("details"):
184
+ load_details.append(CreateOrderFromSDOrder_Load.model_construct(
185
+ location_name=load.get("location_name"),
186
+ location_id=load.get("location_id"),
187
+ product_name=detail.get("product_name"),
188
+ product_id=detail.get("product_id"),
189
+ supplier_name=detail.get("counterparty"),
190
+ supplier_id=detail.get("counterparty_id"),
191
+ volume=int(sum([x.get("volume", 0) for x in detail.get("targets")])),
192
+ compartments=[x.get("compartment_index") for x in detail.get("targets")]
193
+ ))
194
+ return CreateOrderFromSDOrder.model_construct(
195
+ order_id=order_id,
196
+ order_number=obj.get("number"),
197
+ note=obj.get("note", {}).get("content"),
198
+ extra_data=obj.get("extra_data", {}),
199
+ supply_owner_id=obj.get("supply_option", {}).get("supply_owner_id"),
200
+ freight_customer_id=obj.get("supply_option", {}).get("freight_customer_id"),
201
+ market=obj.get("market"),
202
+ drop_details=drop_details,
203
+ load_details=load_details,
204
+ compartments=compartments,
205
+ state=obj.get("state"),
206
+ )
207
+
208
+
209
+ class UpdateStatusFromSD(RequestData):
210
+ request_type: Literal["UpdateStatusFromSD"] = "UpdateStatusFromSD"
211
+ origin_order_id: str = Field(..., description="Order database ID")
212
+ origin_order_number: int = Field(..., description="Order number")
213
+ crossroads_target_order_number: int = Field(..., description="Order number of the target order to be updated. This is captured from the extra_data on the order object in the origin database.")
214
+ crossroads_target_order_id: str = Field(..., description="Order database ID of the target order to be updated. This is captured from the extra_data on the order object in the origin database.")
215
+ date: str = Field(..., description="Date from the change. ISO formatted datetime string.")
216
+ status: str = Field(..., description="New status from the update.")
217
+ location_id: str | None = Field(..., description="location ID of the update, if applicable.")
218
+
219
+ @override
220
+ @classmethod
221
+ def from_db_obj(cls, obj: dict, updated_fields: dict | None = None):
222
+ origin_order_id = cls.get_id(obj)
223
+ origin_order_number = obj.get("number")
224
+ crossroads_target_order_number = int(obj.get("extra_data").get("crossroads_target_number"))
225
+ crossroads_target_order_id = obj.get("extra_data").get("crossroads_target_order_id")
226
+
227
+ logger.debug(f"parsing object: {obj}, updated_fields: {updated_fields}")
228
+ if obj.get("state") == "assigned":
229
+ raise RequestDataSerializationHalted("The order is in the 'assigned' state.")
230
+ elif obj.get("state") == "accepted":
231
+ raise RequestDataSerializationHalted("The order is in the 'accepted' state.")
232
+ elif obj.get("state") == "canceled":
233
+ raise RequestDataSerializationHalted("The order is in the 'canceled' state.")
234
+ elif obj.get("state") == "complete":
235
+ # The order has completed. We don't need to do the rest, we can just send a request to complete the order to the target and be done.
236
+ return UpdateStatusFromSD.model_construct(
237
+ origin_order_id=origin_order_id, origin_order_number=origin_order_number,
238
+ crossroads_target_order_number=crossroads_target_order_number, crossroads_target_order_id=crossroads_target_order_id,
239
+ date=cls.get_dt(obj.get("movement_updated")).isoformat(timespec="seconds"), status="complete", location_id=None
240
+ )
241
+ else:
242
+ try:
243
+ update = cls.extract_status_update_without_route_events(crossroads_target_order_number, origin_order_id,
244
+ crossroads_target_order_id, origin_order_number, obj,
245
+ updated_fields)
246
+ return update
247
+ except Exception as e:
248
+ try:
249
+ update = cls.extract_status_update_with_route_events(origin_order_id, origin_order_number,
250
+ crossroads_target_order_number,
251
+ crossroads_target_order_id, obj)
252
+ return update
253
+ except Exception as e:
254
+ # Both attempts to construct a status update failed. Bail out.
255
+ raise e
256
+
257
+ @classmethod
258
+ def extract_status_update_with_route_events(cls, origin_order_id: str, origin_order_number: str,
259
+ crossroads_target_order_number: str, crossroads_target_order_id: str,
260
+ obj: dict):
261
+ # Loop over the route events for loads and drops, selecting the latest one.
262
+ latest_re = None
263
+ selected_obj = None
264
+ selected_is = "drop"
265
+ for load in obj.get("loads", []):
266
+ for route_event in load.get("route_events", []):
267
+ if latest_re is None or route_event.get("timestamp") > latest_re.get("timestamp"):
268
+ latest_re = route_event
269
+ selected_obj = load
270
+ selected_is = "load"
271
+ for drop in obj.get("drops", []):
272
+ for route_event in drop.get("route_events", []):
273
+ if latest_re is None or route_event.get("timestamp") > latest_re.get("timestamp"):
274
+ latest_re = route_event
275
+ selected_obj = drop
276
+ selected_is = "drop"
277
+
278
+ # We now have the latest event.
279
+ new_status = selected_obj.get("route_status")
280
+
281
+ # Check for some statuses that we don't want to copy:
282
+ if new_status == "preview":
283
+ raise RequestDataSerializationHalted("The latest route status is a 'preview' status.")
284
+
285
+ # BUT there's a problem: when I hit the source with a "completed load" event, what I see is just "complete" for
286
+ # the load. So I need to do some extra checks. If the load was complete, I change the status I received to
287
+ # "completed load". If the drop was complete, I change the status to "completed drop". I ONLY use the completed
288
+ # status when the order state itself is "complete". Otherwise we would be closing it out early. Very annoying.
289
+ if new_status == "complete":
290
+ if selected_is == "load":
291
+ new_status = "completed load"
292
+ elif selected_is == "drop":
293
+ new_status = "completed drop"
294
+ location_id = selected_obj.get("location_id")
295
+ new_eta = cls.get_dt(selected_obj.get("updated"))
296
+ return UpdateStatusFromSD.model_construct(
297
+ origin_order_id=origin_order_id, origin_order_number=origin_order_number,
298
+ crossroads_target_order_number=crossroads_target_order_number, crossroads_target_order_id=crossroads_target_order_id,
299
+ date=new_eta.isoformat(timespec="seconds"), status=new_status, location_id=location_id,
300
+ )
301
+
302
+ @classmethod
303
+ def extract_status_update_without_route_events(cls, origin_order_id: str, origin_order_number: str,
304
+ crossroads_target_order_id: str, crossroads_target_order_number: str,
305
+ obj: dict, updated_fields: dict) -> Self:
306
+ # We need to inspect the updated fields description to figure out which order was updated. This is because we're
307
+ # not receiving an update notify from S&D but inspecting the database instead, and the S&D endpoint values for
308
+ # status don't exactly match the database values.
309
+ loads_eta_regex = r"loads\.(\d+)\.eta"
310
+ loads_status_regex = r"loads\.(\d+)\.route_status"
311
+ drops_eta_regex = r"drops\.(\d+)\.eta"
312
+ drops_status_regex = r"drops\.(\d+)\.route_status"
313
+ loads_matched_set = set()
314
+ drops_matched_set = set()
315
+ for field in updated_fields:
316
+ load_eta_match = re.match(loads_eta_regex, field)
317
+ load_status_match = re.match(loads_status_regex, field)
318
+ drop_eta_match = re.match(drops_eta_regex, field)
319
+ drop_status_match = re.match(drops_status_regex, field)
320
+ if load_eta_match:
321
+ loads_matched_set.add(int(load_eta_match.group(1)))
322
+ elif load_status_match:
323
+ loads_matched_set.add(int(load_status_match.group(1)))
324
+ elif drop_eta_match:
325
+ drops_matched_set.add(int(drop_eta_match.group(1)))
326
+ elif drop_status_match:
327
+ drops_matched_set.add(int(drop_status_match.group(1)))
328
+ max_updated_date = datetime(1, 1, 1, tzinfo=UTC)
329
+ max_updated_obj = {}
330
+ max_is = ""
331
+ for load_idx in loads_matched_set:
332
+ load = obj.get("loads")[load_idx]
333
+ dt = cls.get_dt(load.get("updated", None))
334
+ if dt and dt > max_updated_date:
335
+ max_updated_obj = load
336
+ max_is = "load"
337
+ for drop_idx in drops_matched_set:
338
+ drop = obj.get("drops")[drop_idx]
339
+ dt = cls.get_dt(drop.get("updated", None))
340
+ if dt and dt > max_updated_date:
341
+ max_updated_obj = drop
342
+ max_is = "drop"
343
+ new_status: str = max_updated_obj.get("route_status", "")
344
+ location_id = max_updated_obj.get("location_id")
345
+ # From testing it seems like all events just touch the ETA field? So I only have to parse that out, and
346
+ # not worry about Actual.
347
+ new_eta = cls.get_dt(max_updated_obj.get("eta"))
348
+ if not new_eta:
349
+ new_eta = cls.get_dt(max_updated_obj.get("actual"))
350
+ # BUT there's a problem: when I hit the source with a "completed load" event, what I see is just "complete" for
351
+ # the load. So I need to do some extra checks. If the load was complete, I change the status I received to
352
+ # "completed load". If the drop was complete, I change the status to "completed drop". I ONLY use the completed
353
+ # status when the order state itself is "complete". Otherwise we would be closing it out early. Very annoying.
354
+ if new_status == "complete":
355
+ if max_is == "load":
356
+ new_status = "completed load"
357
+ elif max_is == "drop":
358
+ new_status = "completed drop"
359
+ return UpdateStatusFromSD.model_construct(
360
+ origin_order_id=origin_order_id,
361
+ origin_order_number=origin_order_number,
362
+ crossroads_target_order_number=crossroads_target_order_number,
363
+ crossroads_target_order_id=crossroads_target_order_id,
364
+ location_id=location_id,
365
+ date=new_eta.isoformat(timespec="seconds"),
366
+ status=new_status
367
+ )
368
+
369
+
370
+ class CancelOrderFromSD(RequestData):
371
+ request_type: Literal["CancelOrderFromSD"] = "CancelOrderFromSD"
372
+ origin_order_number: int = Field(..., description="The origin order number of the order to cancel.")
373
+ crossroads_target_order_id: str = Field(..., description="Database ID of the order to cancel in the target. This is captured from the extra_data on the order object in the origin database.")
374
+ crossroads_target_order_number: int = Field(..., description="Order number of the order to cancel in the target. This is captured from the extra_data on the order object in the origin database.")
375
+
376
+ @override
377
+ @classmethod
378
+ def from_db_obj(cls, obj: dict, updated_fields: dict | None = None):
379
+ return CancelOrderFromSD.model_construct(
380
+ origin_order_number = obj.get("number"),
381
+ crossroads_target_order_id=obj.get("extra_data", {}).get("crossroads_target_order_id"),
382
+ crossroads_target_order_number=int(obj.get("extra_data", {}).get("crossroads_target_number")),
383
+ )
384
+
385
+
386
+ class SaveBOLsAndDropsFromSD_ExecutedDropDetails(BaseModel):
387
+ product_id: str = Field(..., description="Dropped product")
388
+ product_name: str = Field(..., description="Dropped product name")
389
+ before_gallons: int | None = Field(default=None, description="Gallons in tank before drop")
390
+ before_inches: int | None = Field(default=None, description="Inches in tank before drop")
391
+ after_gallons: int | None = Field(default=None, description="Gallons in tank after drop")
392
+ after_inches: int | None = Field(None, description="Inches in tank after drop")
393
+ volume: int = Field(..., description="Dropped volume")
394
+ time: datetime = Field(..., description="Time drop ended, if available. If unavailable, time drop started.")
395
+ destination_site: str = Field(..., description="The site that this drop was at")
396
+ destination_tank_id: int = Field(..., description="The tank ID that the product was dropped into")
397
+
398
+
399
+ class SaveBOLsAndDropsFromSD(RequestData):
400
+ request_type: Literal["SaveBOLsAndDropsFromSD"] = "SaveBOLsAndDropsFromSD"
401
+ origin_order_number: int = Field(..., description="The origin order number of the order to save BOLs and Drops to.")
402
+ crossroads_target_order_id: str = Field(..., description="Database ID of the order to save BOLs and Drops to in the target. This is captured from extra_data on the order object in the origin database.")
403
+ crossroads_target_order_number: int = Field(..., description="The crossroads_target_order_number from the order.")
404
+ bol_numbers: list[int] = Field(..., description="The BOL numbers tied to the order. The worker will retrieve these BOL numbers and mirror them to the target.")
405
+ executed_drop_details: list[SaveBOLsAndDropsFromSD_ExecutedDropDetails] = Field(..., description="The drops executed on the order.")
406
+
407
+ @override
408
+ @classmethod
409
+ def from_db_obj(cls, obj: dict, updated_fields: dict | None = None):
410
+ details = []
411
+ for drop in obj.get("drops", []):
412
+ for ex_detail in drop.get("executed_details", []):
413
+ details.append(SaveBOLsAndDropsFromSD_ExecutedDropDetails.model_construct(
414
+ product_id=ex_detail.get("product_id"),
415
+ product_name=ex_detail.get("product_name"),
416
+ before_gallons=ex_detail.get("before_gallons"),
417
+ before_inches=ex_detail.get("before_inches"),
418
+ after_gallons=ex_detail.get("after_gallons"),
419
+ after_inches=ex_detail.get("after_inches"),
420
+ volume=ex_detail.get("volume"),
421
+ time=ex_detail.get("after_stick_time", ex_detail.get("before_stick_time")),
422
+ destination_site=drop.get("location_name"),
423
+ destination_tank_id=ex_detail.get("tank_id"),
424
+ ))
425
+ return SaveBOLsAndDropsFromSD.model_construct(
426
+ origin_order_number=obj.get("number"),
427
+ crossroads_target_order_id=obj.get("extra_data", {}).get("crossroads_target_order_id"),
428
+ crossroads_target_order_number=obj.get("extra_data", {}).get("crossroads_target_number"),
429
+ bol_numbers=obj.get("supply_option", {}).get("actual_freight", {}).get("bol_numbers", []),
430
+ executed_drop_details=details
431
+ )
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ResumeToken(BaseModel):
5
+ probe_id: str
6
+ resume_token: dict
7
+ resume_token_timestamp: str # isoformat, UTC
@@ -0,0 +1,113 @@
1
+ from pydantic import BaseModel, Field
2
+ from datetime import datetime, UTC, timedelta
3
+ from typing import List, Dict, Optional, Any
4
+ from enum import Enum
5
+
6
+ class ProcessReportBase(BaseModel):
7
+ created_on: datetime = datetime.now(UTC)
8
+ indexed_field: str
9
+ included: Optional[List[Dict[str, str]]] = []
10
+ config_id: Optional[str] = None
11
+ snapshot_id: Optional[str] = None
12
+ logs: Optional[Any] = None
13
+
14
+ class Config:
15
+ arbitrary_types_allowed = True
16
+
17
+ class ProcessReportV2Status(str, Enum):
18
+ start = "start" # while start -> in progress = True
19
+ stop = "stop" # means halt
20
+ error = "error" # full failure
21
+ partial = "partial" # partial success
22
+
23
+ @property
24
+ def in_progress(self) -> bool:
25
+ return self == ProcessReportV2Status.start
26
+
27
+ class ProcessReportFileReference(BaseModel):
28
+ file_base_name: str = None
29
+ created_on: Optional[datetime] = datetime.now(UTC)
30
+ status: Optional[ProcessReportV2Status] = None
31
+
32
+
33
+ class UploadProcessReportFile(BaseModel):
34
+ file_base_name: str
35
+ content: str
36
+
37
+
38
+ class ProcessReportBaseV2(BaseModel):
39
+ # This doesn't seem to conflict with Beanie's internal _id field, but it allows us to deserialize an _id coming
40
+ # from rita-backend.
41
+ id: Optional[str] = Field(None, frozen=True, alias="_id")
42
+ trigger: str
43
+ status: Optional[ProcessReportV2Status] = ProcessReportV2Status.start
44
+ config_id: Optional[str] = None
45
+ updated_on: Optional[datetime] = datetime.now(UTC)
46
+ created_on: Optional[datetime] = datetime.now(UTC)
47
+ logs: Optional[List[ProcessReportFileReference]] = []
48
+ included_files: Optional[List[ProcessReportFileReference]] = []
49
+
50
+ @property
51
+ def time_delta(self) -> timedelta:
52
+ return self.updated_on - self.created_on
53
+
54
+ class Config:
55
+ arbitrary_types_allowed = True
56
+
57
+
58
+ class CreateReportV2(BaseModel):
59
+ """
60
+ Used for creating a process report with a Rita endpoint.
61
+ :param bool alert_override: Whether to override (force) send an alert.
62
+ """
63
+ alert_override: bool = False
64
+ trigger: str
65
+ status: Optional[ProcessReportV2Status] = ProcessReportV2Status.start
66
+ config_id: Optional[str] = None
67
+ log: Optional[UploadProcessReportFile] = None
68
+ included_files: Optional[list[UploadProcessReportFile]] = None
69
+
70
+
71
+ class UpdateReportV2(BaseModel):
72
+ """Used for updating an already-created report with a Rita endpoint."""
73
+ report_id: str
74
+ alert_override: bool = False
75
+ log: Optional[UploadProcessReportFile] = None
76
+ included_files: Optional[list[UploadProcessReportFile]] = None
77
+ status: ProcessReportV2Status = None
78
+
79
+
80
+ @property
81
+ def in_progress(self) -> bool:
82
+ return self == ProcessReportV2Status.start
83
+
84
+
85
+ class ProcessReportResponseV2(ProcessReportBaseV2):
86
+ logs: Optional[List[str]] = None
87
+
88
+
89
+ class NotificationChannel(str, Enum):
90
+ email = "email"
91
+ slack = "slack"
92
+
93
+
94
+ class NotificationStatus(str, Enum):
95
+ sent = "sent" # email has been sent
96
+ pending = "pending" # alert is yet to be sent
97
+ passed = "pass" # alert is not needed
98
+ alert = "alert" # alert the UI
99
+
100
+
101
+ class Notification(BaseModel):
102
+ notification_status: NotificationStatus = NotificationStatus.pending
103
+ updated_on: datetime = datetime.now(UTC)
104
+ channel: NotificationChannel = NotificationChannel.email
105
+ recipients: Optional[list] = None
106
+
107
+
108
+ class AlertBase(BaseModel):
109
+ config_id: Optional[str] = None
110
+ process_id: Optional[str] = None
111
+ trigger: Optional[str] = None
112
+ created_on: Optional[datetime] = datetime.now(UTC).replace(tzinfo=UTC)
113
+ notification: Notification = Notification()
@@ -0,0 +1,30 @@
1
+ from typing import List
2
+ from fastapi import Query
3
+
4
+ from pydantic import BaseModel, EmailStr
5
+
6
+
7
+ class DomainInfo(BaseModel):
8
+ domain: str
9
+ role: str
10
+
11
+ class User(BaseModel):
12
+ email: EmailStr
13
+ password: str | None = None
14
+ source: str = ""
15
+ disabled: bool = False
16
+ access: List[DomainInfo]
17
+ password_reset_code: str | None = None
18
+
19
+
20
+ class Role(BaseModel):
21
+ name: str
22
+ scopes: List[str] = []
23
+
24
+
25
+ class RequireScope:
26
+ def __init__(self, scope: str):
27
+ self.scope = scope
28
+
29
+ def __call__(self, scopes: str = Query(...)):
30
+ return self.scope in scopes.split(",")
@@ -0,0 +1,17 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class Bucket(BaseModel):
8
+ """
9
+ Bucket is a grouping mechanism. It may reference another bucket as its container
10
+ Items may reference a bucket as their container, but an item can only be contained in one bucket.
11
+ """
12
+ owned_by: Optional[str] = None # TODO: Write a validator that checks for cycles
13
+ name: str
14
+ description: Optional[str] = None
15
+ updated_by: Optional[str] = None
16
+ updated_on: Optional[datetime] = None
17
+ is_active: bool = True