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,492 @@
1
+ import json
2
+ from datetime import datetime, UTC, timedelta
3
+ from typing import Dict, Any, Tuple, Iterable
4
+
5
+ import polars as pl
6
+ import pytz
7
+ from loguru import logger
8
+
9
+ from bb_integrations_lib.models.pipeline_structs import StopPipeline
10
+ from dateutil.parser import parse
11
+
12
+ from bb_integrations_lib.protocols.pipelines import Step
13
+ from bb_integrations_lib.shared.exceptions import StepInitializationError
14
+ from bb_integrations_lib.shared.model import RawData, FileFormat
15
+
16
+
17
+ class ReadingParser:
18
+ """
19
+ Parser for tank reading data that supports multiple output formats.
20
+
21
+ This class provides parsing capabilities for tank reading data into different
22
+ client-specific formats, including standard PDI-compatible output and various
23
+ Circle K formats for different integration systems.
24
+
25
+ The parser takes preprocessed DataFrame input (with standardized column names)
26
+ and transforms it according to the specified format requirements.
27
+
28
+ Supported formats:
29
+ - standard: PDI-compatible format with configurable disconnection tracking
30
+ - circlek: Circle K format with TelaPoint integration fields
31
+ - circlek2: Simplified Circle K format for Gravitate system integration
32
+ """
33
+
34
+ def __init__(self, file_format: FileFormat):
35
+ """
36
+ Initialize the parser with a specific output format.
37
+
38
+ Args:
39
+ file_format (FileFormat): The desired output format for parsed data
40
+ """
41
+ self.format = file_format
42
+
43
+ def parse(self, df: pl.DataFrame, disconnected_column: bool = False,
44
+ disconnected_only: bool = False, water_level_column: bool = False) -> pl.DataFrame:
45
+ """
46
+ Parse tank reading data according to the configured format.
47
+
48
+ Args:
49
+ df (DataFrame): Input DataFrame with standardized column names:
50
+ - Store Number: Store identifier
51
+ - Name: Store name
52
+ - Tank Id: Tank identifier
53
+ - Tank Product: Product type in tank
54
+ - Carrier: Carrier information
55
+ - Volume: Current volume measurement
56
+ - Ullage: Unfilled space in tank
57
+ - Read Time: Timestamp of reading
58
+ - Store Source Number: Store number assigned by client (extra_data.site_source_number)
59
+ - Disconnected: Boolean disconnection status (optional)
60
+ disconnected_column (bool): Whether to include Disconnected column
61
+ in output (standard format only)
62
+ disconnected_only (bool): Whether to filter to only disconnected
63
+ tanks (standard format only)
64
+ water_level_column (bool): Whether to include Water Level column
65
+
66
+ Returns:
67
+ DataFrame: Parsed data in the specified format
68
+
69
+ Raises:
70
+ ValueError: If the configured format is not supported
71
+ """
72
+ if self.format == FileFormat.standard:
73
+ return self._parse_standard(df, disconnected_column, disconnected_only, water_level_column)
74
+ elif self.format == FileFormat.circlek:
75
+ return self._parse_circlek(df)
76
+ elif self.format == FileFormat.circlek2:
77
+ return self._parse_circlek2(df)
78
+ elif self.format == FileFormat.reduced:
79
+ return self._parse_reduced(df)
80
+ else:
81
+ raise ValueError(f"Unsupported format: {self.format}")
82
+
83
+ def _parse_standard(self, df: pl.DataFrame, disconnected_column: bool,
84
+ disconnected_only: bool, water_level_column: bool) -> pl.DataFrame:
85
+ """
86
+ Parse data into standard PDI-compatible format.
87
+
88
+ Produces columns: Store Number, Name, Tank Id, Tank Product, Carrier,
89
+ Volume, Ullage, Read Time, and optionally Disconnected.
90
+ """
91
+ column_order = [
92
+ 'Store Number', 'Name', 'Tank Id', 'Tank Product',
93
+ 'Carrier', 'Volume', 'Ullage', 'Read Time'
94
+ ]
95
+
96
+ if water_level_column:
97
+ column_order.append('Water')
98
+
99
+ if disconnected_column:
100
+ column_order.append('Disconnected')
101
+
102
+ if disconnected_only:
103
+ df = df.filter(pl.col("Disconnected") == True)
104
+
105
+ return df.select(column_order)
106
+
107
+ def _parse_circlek(self, df: pl.DataFrame) -> pl.DataFrame:
108
+ """
109
+ Parse data into Circle K format with TelaPoint integration fields.
110
+
111
+ Transforms input data into Circle K's expected structure with TelaPoint
112
+ account/site numbers and formatted timestamps.
113
+ """
114
+
115
+ def parse_date(dt: str) -> str:
116
+ dt = parse(dt)
117
+ return dt.strftime("%m/%d/%Y %H:%M")
118
+
119
+ rows = []
120
+ records = df.to_dicts()
121
+
122
+ for record in records:
123
+ rows.append({
124
+ "ClientName": None,
125
+ "FacilityName": None,
126
+ "FacilityInternalID": None,
127
+ "FacilityState": None,
128
+ "VolumePercentage": None,
129
+ "TankStatus": None,
130
+ "TankNbr": None,
131
+ "TankInternalID": None,
132
+ "AtgTankNumber": record['Tank Id'],
133
+ "ATGTankLabel": None,
134
+ "Product": None,
135
+ "TankCapacity": None,
136
+ "Ullage": None,
137
+ "SafeUllage": None,
138
+ "Volume": record['Volume'],
139
+ "Height": None,
140
+ "Water": None,
141
+ "Temperature": None,
142
+ "InventoryDate": parse_date(record['Read Time']),
143
+ "SystemUnits": None,
144
+ "CollectionDateTimeUtc": None,
145
+ "TelaPointAccountNumber": 100814,
146
+ "TelaPointSiteNumber": record['Store Number'],
147
+ })
148
+
149
+ return pl.DataFrame(rows)
150
+
151
+ def _parse_circlek2(self, df: pl.DataFrame) -> pl.DataFrame:
152
+ """
153
+ Parse data into simplified Circle K format.
154
+ """
155
+
156
+ def parse_date(dt: str) -> str:
157
+ dt = parse(dt)
158
+ return dt.strftime("%m/%d/%Y %H:%M")
159
+
160
+ rows = []
161
+ records = df.to_dicts()
162
+
163
+ for record in records:
164
+ rows.append({
165
+ "storeNumber": record.get('Store Source Number'),
166
+ "timestamp": parse_date(record['Read Time']),
167
+ "tankLabel": record.get('Tank Product'), # Product name assigned to tank
168
+ "volume": record['Volume'],
169
+ "tankNumber": record['Tank Id'],
170
+ "ullage": record.get('Ullage', 0),
171
+ "productLevel": 0, # Can be set to 0 as specified
172
+ "waterLevel": 0, # Can be set to 0 as specified
173
+ "temperature": 0 # Can be set to 0 as specified
174
+ })
175
+
176
+ def _parse_reduced(self, df: pl.DataFrame) -> pl.DataFrame:
177
+ """
178
+ Parse data into reduced format with minimal columns.
179
+ """
180
+ df = df.with_columns([
181
+ (pl.col('Read Time')
182
+ .str.replace(" UTC", "")
183
+ .str.to_datetime(format="%Y-%m-%d %H:%M:%S %z")
184
+ .dt.convert_time_zone("UTC")
185
+ .dt.strftime("%Y-%m-%dT%H:%M:%S.%3f")
186
+ .str.replace(r"\.(\d{3})\d*$", ".$1")
187
+ + pl.lit("Z"))
188
+ .alias('read_time')
189
+ ])
190
+
191
+ return df.select([
192
+ pl.col('Store Number').alias('store_number'),
193
+ pl.col('Tank Id').alias('tank_id'),
194
+ pl.col('read_time'),
195
+ pl.col('Volume').alias('volume')
196
+ ])
197
+
198
+ @classmethod
199
+ def create_parser(cls, file_format: FileFormat) -> 'ReadingParser':
200
+ """
201
+ Factory method to create a parser for the specified format.
202
+
203
+ Args:
204
+ file_format (FileFormat): The desired output format
205
+
206
+ Returns:
207
+ ReadingParser: Configured parser instance
208
+ """
209
+ return cls(file_format)
210
+
211
+
212
+ class ParseTankReadingsStep(Step):
213
+ def __init__(self, file_format: FileFormat, timezone: str, include_water_level: bool = False,
214
+ disconnected_column: bool = False, disconnected_only: bool = False,
215
+ disconnected_hours_threshold: float | None = None,
216
+ *args, **kwargs):
217
+ """
218
+ Parse tank readings from BBDExportReadingsStep and create a PDI-compatible file, either in the standard output
219
+ format (with some configuration options) or in a client-specific format.
220
+
221
+ See the ``FileFormat`` enum for currently supported formats.
222
+
223
+ :param file_format: The ``FileFormat`` to use for the output file. "standard" provides a PDI compatible file,
224
+ but additional formats may be implemented at client request.
225
+ :param timezone: Timezone to localize read times to. Must be a Pytz-known timezone name.
226
+ :param include_water_level: Whether to include water level in the output file. Defaults to False.
227
+ :param disconnected_column: Whether to include a "Disconnected" column in the output file. Independent of
228
+ disconnected_only. Requires ``disconnected_hours_threshold`` to be set.
229
+ :param disconnected_only: Whether to post-filter result rows to only disconnected site/tanks. Independent of
230
+ disconnected_column. Requires ``disconnected_hours_threshold`` to be set.
231
+ :param disconnected_hours_threshold: How long it may be since the last reading before a tank is considered
232
+ disconnected. Setting this value without ``disconnected_column`` or ``disconnected_only`` will have no effect.
233
+ """
234
+ super().__init__(*args, **kwargs)
235
+ self.file_format: FileFormat = file_format
236
+ self.timezone = timezone
237
+ self.step_created_time = datetime.now(UTC)
238
+ self.include_water_level = include_water_level
239
+ self.disconnected_column = disconnected_column
240
+ self.disconnected_only = disconnected_only
241
+ self.disconnected_hours_threshold = disconnected_hours_threshold
242
+ self.disconnected_threshold = timedelta(
243
+ hours=self.disconnected_hours_threshold) if self.disconnected_hours_threshold else None
244
+
245
+ # Initialize the reading parser for the configured format
246
+ self.reading_parser = ReadingParser(self.file_format)
247
+
248
+ if (self.disconnected_column or self.disconnected_only) and not self.disconnected_hours_threshold:
249
+ raise StepInitializationError(
250
+ "If disconnected_column or disconnected_only is True, disconnected_hours_threshold must be set")
251
+
252
+ def describe(self) -> str:
253
+ return f"Format tank readings step"
254
+
255
+ async def execute(self, data: Tuple[Dict, Dict, Iterable]) -> pl.DataFrame:
256
+ store_lkp, tank_lkp, readings = data
257
+ df = pl.LazyFrame(readings, schema={
258
+ "tank_agent_name": str,
259
+ "store_number": str,
260
+ "run_time": datetime,
261
+ "tank_id": str,
262
+ "read_time": datetime,
263
+ "product": str,
264
+ "monitor_type": str,
265
+ "volume": float,
266
+ })
267
+ return (await self.parse_data(data=df, tank_lkp=tank_lkp, store_lkp=store_lkp)).collect()
268
+
269
+ @staticmethod
270
+ def safe_expand_extra_data(df: pl.DataFrame, extra_data_col='extra_data') -> pl.DataFrame:
271
+ if extra_data_col not in df.columns:
272
+ logger.warning(f"Warning: {extra_data_col} column not found")
273
+ return df
274
+ if df[extra_data_col].is_null().all():
275
+ logger.warning(f"Warning: All {extra_data_col} values are null")
276
+ return df.drop(extra_data_col)
277
+ try:
278
+ col_dtype = df[extra_data_col].dtype
279
+ if str(col_dtype).startswith("Struct"):
280
+ df = df.unnest(extra_data_col)
281
+ else:
282
+ df = df.with_columns([
283
+ pl.col(extra_data_col).map_elements(
284
+ lambda x: x if isinstance(x, dict) else (json.loads(x) if isinstance(x, str) else {}),
285
+ return_dtype=pl.Struct
286
+ ).alias(f"{extra_data_col}_parsed")
287
+ ])
288
+ df = df.unnest(f"{extra_data_col}_parsed")
289
+ df = df.drop(extra_data_col)
290
+ return df
291
+ except Exception as e:
292
+ logger.error(f"Error expanding {extra_data_col}: {e}")
293
+ return df
294
+
295
+ def maybe_add_water_level(self, df: pl.DataFrame, columns_to_keep: list) -> pl.DataFrame:
296
+ if self.include_water_level:
297
+ columns_to_keep.append('water')
298
+ df = df.with_columns([
299
+ pl.col('water').fill_nan(None).alias('water')
300
+ ])
301
+ return df
302
+
303
+ def map_stores_to_gravitate_name(self, df: pl.DataFrame, store_lkp: Dict) -> pl.DataFrame:
304
+ store_name_map = {k: v.get('name') if v else None for k, v in store_lkp.items()}
305
+ store_source_number_map = {k: v.get('extra_data', {}).get('site_source_number') if v else None
306
+ for k, v in store_lkp.items()}
307
+
308
+ df = df.with_columns([
309
+ pl.col('store_number').replace(store_name_map, default=None).alias('name'),
310
+ pl.col('store_number').replace(store_source_number_map, default=None).alias('store_source_number')
311
+ ])
312
+ return df
313
+
314
+ def select_keep_columns(self, df: pl.DataFrame, columns_to_keep: list) -> pl.DataFrame:
315
+ return df.select(columns_to_keep)
316
+
317
+ def localize_timestamps(self, df: pl.DataFrame) -> pl.DataFrame:
318
+ df = df.with_columns([
319
+ pl.col('read_time')
320
+ .dt.replace_time_zone("UTC")
321
+ .dt.convert_time_zone(self.timezone)
322
+ .alias('read_time')
323
+ ])
324
+ return df
325
+
326
+ def maybe_calculate_disconnected_tanks(self, df: pl.DataFrame) -> pl.DataFrame:
327
+ """
328
+ Optionally calculate disconnected status for tanks.
329
+
330
+ Groups by (store_number, tank_id) and determines if tank is disconnected
331
+ based on reading timestamps and configured threshold.
332
+
333
+ Args:
334
+ df: Input DataFrame with read_time column
335
+
336
+ Returns:
337
+ DataFrame with optional 'disconnected' column added
338
+ """
339
+ if self.disconnected_column or self.disconnected_only:
340
+ disconnections = df.group_by(['store_number', 'tank_id']).agg([
341
+ pl.col('read_time').map_elements(
342
+ lambda times: self.is_disconnected(times, self.disconnected_threshold),
343
+ return_dtype=pl.Boolean
344
+ ).first().alias('disconnected')
345
+ ])
346
+ df = df.join(disconnections, on=['store_number', 'tank_id'], how='left')
347
+ return df
348
+
349
+ def format_timestamps(self, df: pl.DataFrame) -> pl.DataFrame:
350
+ """
351
+ Format read_time datetime to string using vectorized strftime.
352
+
353
+ Converts to "YYYY-MM-DD HH:MM:SS TZ±HHMM" format.
354
+
355
+ Args:
356
+ df: Input DataFrame with datetime read_time column
357
+
358
+ Returns:
359
+ DataFrame with string-formatted read_time
360
+ """
361
+ df = df.with_columns([
362
+ pl.col('read_time').dt.strftime("%Y-%m-%d %H:%M:%S %Z%z").alias('read_time')
363
+ ])
364
+ return df
365
+
366
+ def create_tank_lookup_key(self, df: pl.DataFrame) -> pl.DataFrame:
367
+ df = df.with_columns([
368
+ (pl.col('store_number').cast(pl.Utf8) + ':' + pl.col('tank_id').cast(pl.Utf8)).alias('key')
369
+ ])
370
+ return df
371
+
372
+ def map_tank_metadata(self, df: pl.DataFrame, tank_lkp: Dict) -> pl.DataFrame:
373
+ """
374
+ Map tank metadata (product, carrier, storage_max) using composite key.
375
+ """
376
+ tank_product_map = {k: v.get('product') if v else None for k, v in tank_lkp.items()}
377
+ tank_carrier_map = {k: v.get('carrier') if v else None for k, v in tank_lkp.items()}
378
+ tank_storage_max_map = {k: v.get('storage_max') if v else None for k, v in tank_lkp.items()}
379
+ df = df.with_columns([
380
+ pl.col('key').replace(tank_product_map, default=None).alias('tank_product'),
381
+ pl.col('key').replace(tank_carrier_map, default=None).alias('carrier'),
382
+ pl.col('key').replace(tank_storage_max_map, default=None).alias('storage_max')
383
+ ])
384
+ return df
385
+
386
+ def calculate_ullage(self, df: pl.DataFrame) -> pl.DataFrame:
387
+ """
388
+ Formula: ullage = storage_max - volume
389
+ """
390
+ df = df.with_columns([
391
+ (pl.col('storage_max').fill_null(0) - pl.col('volume').fill_null(0)).alias('ullage')
392
+ ])
393
+ return df
394
+
395
+ def map_tanks_to_gravitate_id(self, df: pl.DataFrame, tank_lkp: Dict) -> pl.DataFrame:
396
+ df = self.create_tank_lookup_key(df)
397
+ df = self.map_tank_metadata(df, tank_lkp)
398
+ df = self.calculate_ullage(df)
399
+ return df
400
+
401
+ def format_column_names(self, df: pl.DataFrame) -> pl.DataFrame:
402
+ column_mapping = {col: ParseTankReadingsStep.format_column_name(col) for col in df.columns}
403
+ return df.rename(column_mapping)
404
+
405
+ def add_reference_data(self, df: pl.DataFrame, tank_lkp: Dict, store_lkp: Dict) -> pl.DataFrame:
406
+ columns_to_keep = ['store_number', 'tank_id', 'read_time', 'volume', 'name', 'store_source_number']
407
+ df = self.maybe_add_water_level(df, columns_to_keep)
408
+ df = self.map_stores_to_gravitate_name(df, store_lkp)
409
+ df = self.select_keep_columns(df, columns_to_keep)
410
+ df = self.localize_timestamps(df)
411
+ df = self.maybe_calculate_disconnected_tanks(df)
412
+ df = self.format_timestamps(df)
413
+ df = self.map_tanks_to_gravitate_id(df, tank_lkp)
414
+ df = self.format_column_names(df)
415
+
416
+ return df
417
+
418
+ def file_parser(self, df: pl.DataFrame, tank_lkp: Dict, store_lkp: Dict) -> pl.DataFrame:
419
+ """
420
+ Parse tank reading data into the configured output format.
421
+
422
+ This method first enriches the data with reference information, then uses
423
+ the ReadingParser to transform it into the appropriate format.
424
+
425
+ Args:
426
+ df (DataFrame): Raw tank readings DataFrame
427
+ tank_lkp (Dict): Tank lookup dictionary
428
+ store_lkp (Dict): Store lookup dictionary
429
+
430
+ Returns:
431
+ DataFrame: Parsed DataFrame in the configured output format
432
+ """
433
+ df = self.add_reference_data(df, tank_lkp, store_lkp)
434
+ return self.reading_parser.parse(
435
+ df,
436
+ disconnected_column=self.disconnected_column,
437
+ disconnected_only=self.disconnected_only,
438
+ water_level_column=self.include_water_level
439
+ )
440
+
441
+ def localize(self, dt: datetime, timezone: str = None) -> datetime:
442
+
443
+ if timezone is None:
444
+ timezone = self.timezone
445
+ utc = pytz.timezone('UTC')
446
+ dt = utc.localize(dt)
447
+ dt = dt.astimezone(pytz.timezone(timezone))
448
+ return dt
449
+
450
+ def format_dt_col(self, dt: datetime) -> str:
451
+ return dt.strftime("%Y-%m-%d %H:%M:%S %Z%z")
452
+
453
+ def is_disconnected(self, reading_times: Iterable[datetime], threshold: timedelta) -> bool:
454
+ """
455
+ Determine if a tank is disconnected based on reading timestamps.
456
+
457
+ A tank is considered disconnected if it has no readings within the threshold
458
+ period from the current time, or if it has no readings at all.
459
+
460
+ Args:
461
+ reading_times (Iterable[datetime]): Collection of reading timestamps
462
+ threshold (timedelta): Time threshold for disconnection detection
463
+
464
+ Returns:
465
+ bool: True if tank is disconnected, False otherwise
466
+ """
467
+ # Skip future times, with a 15 minute grace period (maybe the clocks are just slightly desynced)
468
+ filtered = [t for t in reading_times if t <= self.step_created_time + timedelta(minutes=15)]
469
+ # No readings, or a reading older than now - threshold? Disconnected
470
+ return len(filtered) == 0 or max(filtered) < datetime.now(UTC) - threshold
471
+
472
+ @staticmethod
473
+ def format_column_name(col_name: str) -> str:
474
+ return ' '.join(word.capitalize() for word in col_name.split('_'))
475
+
476
+ async def parse_data(self, data: pl.DataFrame, store_lkp: Dict, tank_lkp: Dict) -> pl.DataFrame:
477
+ """
478
+ Parse tank reading data and return as RawData for pipeline output.
479
+
480
+ Args:
481
+ data (DataFrame): Raw tank readings DataFrame
482
+ store_lkp (Dict): Store lookup dictionary
483
+ tank_lkp (Dict): Tank lookup dictionary
484
+
485
+ Returns:
486
+ DataFrame: Parsed data output in the requested format. An additional step is required to export this data
487
+ to a RawData object.
488
+ """
489
+ df = self.file_parser(df=data, tank_lkp=tank_lkp, store_lkp=store_lkp)
490
+ # self.pipeline_context.included_files["parse pricing engine prices to PDI file step"] = json.dumps(
491
+ # df.to_dicts())
492
+ return df
@@ -0,0 +1,54 @@
1
+ import json
2
+ from math import ceil
3
+ from time import sleep
4
+ from typing import Iterable, Union
5
+
6
+ from loguru import logger
7
+ from more_itertools import chunked
8
+
9
+ from bb_integrations_lib.gravitate.sd_api import GravitateSDAPI
10
+ from bb_integrations_lib.protocols.flat_file import PriceRow
11
+ from bb_integrations_lib.protocols.pipelines import Step
12
+ from bb_integrations_lib.shared.model import SupplyPriceUpdateManyRequest
13
+
14
+
15
+ class BBDUploadPricesStep(Step):
16
+ def __init__(self, sd_client: GravitateSDAPI, sleep_between: float = 0.5, chunk_size: int = 1000, *args, **kwargs):
17
+ super().__init__(*args, **kwargs)
18
+ self.sd_client = sd_client
19
+ self.sleep_between = sleep_between
20
+ self.chunk_size = chunk_size
21
+
22
+ def describe(self) -> str:
23
+ return "Upload prices to BBD"
24
+
25
+ async def execute(self, i: Union[Iterable[PriceRow], Iterable[SupplyPriceUpdateManyRequest]]) -> int:
26
+ total_prices = len(i)
27
+ count = ceil(total_prices / 1000)
28
+ attempted = 0
29
+ succeeded = 0
30
+ responses = []
31
+ price_dump = i.model_dump(mode="json")
32
+ list({json.dumps(record, sort_keys=True): record for record in price_dump}.values())
33
+ for idx, group in enumerate(chunked(i, self.price_dump)):
34
+
35
+
36
+ logger.info(f"Uploading prices to bestbuy {idx + 1} of {count}")
37
+ sleep(self.sleep_between)
38
+ attempted += len(group)
39
+ group = [g.model_dump(mode="json") for g in group]
40
+ try:
41
+ successes, response = await self.sd_client.upload_prices(group)
42
+ succeeded += successes
43
+ responses.append(response)
44
+ except Exception as e:
45
+ logger.error(f"Batch {idx} prices failed | {e}")
46
+ continue
47
+ logger.info(f"Successfully uploaded {succeeded} prices to BBD.")
48
+ logs = {
49
+ "response": responses,
50
+ "attempted": attempted,
51
+ "succeeded": succeeded
52
+ }
53
+ self.pipeline_context.included_files["upload prices to sd"] = json.dumps(logs)
54
+ return succeeded
@@ -0,0 +1,124 @@
1
+ import json
2
+ from math import ceil
3
+ from time import sleep
4
+ from typing import Dict, List
5
+
6
+ from loguru import logger
7
+ from more_itertools import chunked
8
+
9
+ from bb_integrations_lib.gravitate.sd_api import GravitateSDAPI
10
+ from bb_integrations_lib.models.pipeline_structs import BBDUploadResult
11
+ from bb_integrations_lib.models.rita.issue import IssueBase, IssueCategory
12
+ from bb_integrations_lib.protocols.pipelines import Step
13
+ from bb_integrations_lib.protocols.flat_file import TankSales
14
+ from bb_integrations_lib.util.utils import CustomJSONEncoder
15
+ from bb_integrations_lib.util.config.manager import GlobalConfigManager
16
+ from bb_integrations_lib.util.config.model import GlobalConfig
17
+
18
+
19
+ class BBDUploadTankSalesStep(Step):
20
+ """
21
+ Takes a list of TankSales and uploads them to Best Buy
22
+ """
23
+
24
+ def __init__(self, sd_client: GravitateSDAPI, sleep_between: float = 0.5, chunk_size: int = 1000, *args, **kwargs):
25
+ super().__init__(*args, **kwargs)
26
+ self.sd_client = sd_client
27
+ self.sleep_between = sleep_between
28
+ self.chunk_size = chunk_size
29
+
30
+ def describe(self) -> str:
31
+ return "Upload Tanksales to BBD"
32
+
33
+ async def execute(self, i: List[TankSales]) -> BBDUploadResult:
34
+ logs = {"requests": [], "responses": [], "errors": []}
35
+ try:
36
+ total_sales = len(i)
37
+ count = ceil(total_sales / self.chunk_size)
38
+ attempted = 0
39
+ succeeded = 0
40
+ failed_items = []
41
+ store_ids = []
42
+
43
+ for idx, group in enumerate(chunked(i, self.chunk_size)):
44
+ logger.info(f"Uploading sales to bestbuy {idx + 1} of {count} to: {self.sd_client.base_url} ")
45
+ attempted += len(group)
46
+ serialized_group = [g.model_dump(mode="json") for g in group]
47
+ batch_store_ids = [g.get("store_number", "unknown") for g in serialized_group]
48
+ logs["requests"].append({
49
+ "row_id": idx,
50
+ "request": serialized_group,
51
+ "store_ids": batch_store_ids
52
+ })
53
+
54
+ try:
55
+ response: Dict = await self.sd_client.upload_tank_sales(serialized_group)
56
+ response_data = response
57
+ logs["responses"].append({
58
+ "row_id": idx,
59
+ "response": response_data,
60
+ })
61
+ created = response_data.get("created", 0)
62
+ updated = response_data.get("updated", 0)
63
+ failed = response_data.get("failed", [])
64
+ current_succeeded = created + updated
65
+ succeeded += current_succeeded
66
+ if current_succeeded > 0:
67
+ if current_succeeded == len(serialized_group) and not failed:
68
+ store_ids.extend(batch_store_ids)
69
+ else:
70
+ failed_store_numbers = [f["record"]["store_number"] for f in failed if
71
+ "record" in f and "store_number" in f["record"]]
72
+ failed_set = set(failed_store_numbers)
73
+ successful_ids = [
74
+ store_id for store_id in batch_store_ids
75
+ if store_id not in failed_set
76
+ ]
77
+ store_ids.extend(successful_ids)
78
+ if failed:
79
+ failed_items.extend(failed)
80
+ logs["errors"].append({
81
+ "row_id": idx,
82
+ "failed_items": failed,
83
+ "response": response_data
84
+ })
85
+ logger.error(f"Errors occurred while uploading data: {failed}")
86
+ logger.info(f"Batch {idx + 1}: Created {created}, Updated {updated}, Failed {len(failed)}")
87
+ sleep(self.sleep_between)
88
+ except Exception as e:
89
+ error_msg = f"Batch {idx} sales failed | {e}"
90
+ logger.error(error_msg)
91
+ failed_items.extend(batch_store_ids)
92
+ logs["errors"].append({
93
+ "row_id": idx,
94
+ "exception": str(e),
95
+ "store_ids": batch_store_ids
96
+ })
97
+ continue
98
+
99
+ logger.info(f"Successfully uploaded {succeeded} of {attempted} sales.")
100
+ logger.info(f"Failed to upload {len(failed_items)} of {attempted} sales")
101
+ if failed_items and hasattr(self.pipeline_context,
102
+ 'issue_report_config') and self.pipeline_context.issue_report_config:
103
+ irc = self.pipeline_context.issue_report_config
104
+ fc = self.pipeline_context.file_config
105
+ key = f"{irc.key_base}_{fc.config_id}_failed_to_upload"
106
+ self.pipeline_context.issues.append(IssueBase(
107
+ key=key,
108
+ config_id=fc.config_id,
109
+ name="Failed to upload Tanksales",
110
+ category=IssueCategory.TANK_READING,
111
+ problem_short=f"{len(failed_items)} sales did not upload",
112
+ problem_long=json.dumps(failed_items)
113
+ ))
114
+
115
+ self.pipeline_context.included_files["sales data upload"] = json.dumps(logs, cls=CustomJSONEncoder)
116
+ return BBDUploadResult(
117
+ succeeded=succeeded,
118
+ failed=len(failed_items),
119
+ succeeded_items=store_ids
120
+ )
121
+
122
+ except Exception as e:
123
+ logger.exception(f"Unable to upload | {e}")
124
+ raise e