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.
- bb_integrations_lib/__init__.py +0 -0
- bb_integrations_lib/converters/__init__.py +0 -0
- bb_integrations_lib/gravitate/__init__.py +0 -0
- bb_integrations_lib/gravitate/base_api.py +20 -0
- bb_integrations_lib/gravitate/model.py +29 -0
- bb_integrations_lib/gravitate/pe_api.py +122 -0
- bb_integrations_lib/gravitate/rita_api.py +552 -0
- bb_integrations_lib/gravitate/sd_api.py +572 -0
- bb_integrations_lib/gravitate/testing/TTE/sd/models.py +1398 -0
- bb_integrations_lib/gravitate/testing/TTE/sd/tests/test_models.py +2987 -0
- bb_integrations_lib/gravitate/testing/__init__.py +0 -0
- bb_integrations_lib/gravitate/testing/builder.py +55 -0
- bb_integrations_lib/gravitate/testing/openapi.py +70 -0
- bb_integrations_lib/gravitate/testing/util.py +274 -0
- bb_integrations_lib/mappers/__init__.py +0 -0
- bb_integrations_lib/mappers/prices/__init__.py +0 -0
- bb_integrations_lib/mappers/prices/model.py +106 -0
- bb_integrations_lib/mappers/prices/price_mapper.py +127 -0
- bb_integrations_lib/mappers/prices/protocol.py +20 -0
- bb_integrations_lib/mappers/prices/util.py +61 -0
- bb_integrations_lib/mappers/rita_mapper.py +523 -0
- bb_integrations_lib/models/__init__.py +0 -0
- bb_integrations_lib/models/dtn_supplier_invoice.py +487 -0
- bb_integrations_lib/models/enums.py +28 -0
- bb_integrations_lib/models/pipeline_structs.py +76 -0
- bb_integrations_lib/models/probe/probe_event.py +20 -0
- bb_integrations_lib/models/probe/request_data.py +431 -0
- bb_integrations_lib/models/probe/resume_token.py +7 -0
- bb_integrations_lib/models/rita/audit.py +113 -0
- bb_integrations_lib/models/rita/auth.py +30 -0
- bb_integrations_lib/models/rita/bucket.py +17 -0
- bb_integrations_lib/models/rita/config.py +188 -0
- bb_integrations_lib/models/rita/constants.py +19 -0
- bb_integrations_lib/models/rita/crossroads_entities.py +293 -0
- bb_integrations_lib/models/rita/crossroads_mapping.py +428 -0
- bb_integrations_lib/models/rita/crossroads_monitoring.py +78 -0
- bb_integrations_lib/models/rita/crossroads_network.py +41 -0
- bb_integrations_lib/models/rita/crossroads_rules.py +80 -0
- bb_integrations_lib/models/rita/email.py +39 -0
- bb_integrations_lib/models/rita/issue.py +63 -0
- bb_integrations_lib/models/rita/mapping.py +227 -0
- bb_integrations_lib/models/rita/probe.py +58 -0
- bb_integrations_lib/models/rita/reference_data.py +110 -0
- bb_integrations_lib/models/rita/source_system.py +9 -0
- bb_integrations_lib/models/rita/workers.py +76 -0
- bb_integrations_lib/models/sd/bols_and_drops.py +241 -0
- bb_integrations_lib/models/sd/get_order.py +301 -0
- bb_integrations_lib/models/sd/orders.py +18 -0
- bb_integrations_lib/models/sd_api.py +115 -0
- bb_integrations_lib/pipelines/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/order_by_site_product_parser.py +50 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/tank_configs_parser.py +47 -0
- bb_integrations_lib/pipelines/parsers/dtn/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/dtn/dtn_price_parser.py +102 -0
- bb_integrations_lib/pipelines/parsers/dtn/model.py +79 -0
- bb_integrations_lib/pipelines/parsers/price_engine/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/price_engine/parse_accessorials_prices_parser.py +67 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_merge_parser.py +111 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_sync_parser.py +107 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/shared.py +81 -0
- bb_integrations_lib/pipelines/parsers/tank_reading_parser.py +155 -0
- bb_integrations_lib/pipelines/parsers/tank_sales_parser.py +144 -0
- bb_integrations_lib/pipelines/shared/__init__.py +0 -0
- bb_integrations_lib/pipelines/shared/allocation_matching.py +227 -0
- bb_integrations_lib/pipelines/shared/bol_allocation.py +2793 -0
- bb_integrations_lib/pipelines/steps/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/create_accessorials_step.py +80 -0
- bb_integrations_lib/pipelines/steps/distribution_report/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/distribution_report/distribution_report_datafram_to_raw_data.py +33 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_model_history_step.py +50 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_order_by_site_product_step.py +62 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_tank_configs_step.py +40 -0
- bb_integrations_lib/pipelines/steps/distribution_report/join_distribution_order_dos_step.py +85 -0
- bb_integrations_lib/pipelines/steps/distribution_report/upload_distribution_report_datafram_to_big_query.py +47 -0
- bb_integrations_lib/pipelines/steps/echo_step.py +14 -0
- bb_integrations_lib/pipelines/steps/export_dataframe_to_rawdata_step.py +28 -0
- bb_integrations_lib/pipelines/steps/exporting/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/exporting/bbd_export_payroll_file_step.py +107 -0
- bb_integrations_lib/pipelines/steps/exporting/bbd_export_readings_step.py +236 -0
- bb_integrations_lib/pipelines/steps/exporting/cargas_wholesale_bundle_upload_step.py +33 -0
- bb_integrations_lib/pipelines/steps/exporting/dataframe_flat_file_export.py +29 -0
- bb_integrations_lib/pipelines/steps/exporting/gcs_bucket_export_file_step.py +34 -0
- bb_integrations_lib/pipelines/steps/exporting/keyvu_export_step.py +356 -0
- bb_integrations_lib/pipelines/steps/exporting/pe_price_export_step.py +238 -0
- bb_integrations_lib/pipelines/steps/exporting/platform_science_order_sync_step.py +500 -0
- bb_integrations_lib/pipelines/steps/exporting/save_rawdata_to_disk.py +15 -0
- bb_integrations_lib/pipelines/steps/exporting/sftp_export_file_step.py +60 -0
- bb_integrations_lib/pipelines/steps/exporting/sftp_export_many_files_step.py +23 -0
- bb_integrations_lib/pipelines/steps/exporting/update_exported_orders_table_step.py +64 -0
- bb_integrations_lib/pipelines/steps/filter_step.py +22 -0
- bb_integrations_lib/pipelines/steps/get_latest_sync_date.py +34 -0
- bb_integrations_lib/pipelines/steps/importing/bbd_import_payroll_step.py +30 -0
- bb_integrations_lib/pipelines/steps/importing/get_order_numbers_to_export_step.py +138 -0
- bb_integrations_lib/pipelines/steps/importing/load_file_to_dataframe_step.py +46 -0
- bb_integrations_lib/pipelines/steps/importing/load_imap_attachment_step.py +172 -0
- bb_integrations_lib/pipelines/steps/importing/pe_bulk_sync_price_structure_step.py +68 -0
- bb_integrations_lib/pipelines/steps/importing/pe_price_merge_step.py +86 -0
- bb_integrations_lib/pipelines/steps/importing/sftp_file_config_step.py +124 -0
- bb_integrations_lib/pipelines/steps/importing/test_exact_file_match.py +57 -0
- bb_integrations_lib/pipelines/steps/null_step.py +15 -0
- bb_integrations_lib/pipelines/steps/pe_integration_job_step.py +32 -0
- bb_integrations_lib/pipelines/steps/processing/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/processing/archive_gcs_step.py +76 -0
- bb_integrations_lib/pipelines/steps/processing/archive_sftp_step.py +48 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_format_tank_readings_step.py +492 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_prices_step.py +54 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_tank_sales_step.py +124 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_tankreading_step.py +80 -0
- bb_integrations_lib/pipelines/steps/processing/convert_bbd_order_to_cargas_step.py +226 -0
- bb_integrations_lib/pipelines/steps/processing/delete_sftp_step.py +33 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/__init__.py +2 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/convert_dtn_invoice_to_sd_model.py +145 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/parse_dtn_invoice_step.py +38 -0
- bb_integrations_lib/pipelines/steps/processing/file_config_parser_step.py +720 -0
- bb_integrations_lib/pipelines/steps/processing/file_config_parser_step_v2.py +418 -0
- bb_integrations_lib/pipelines/steps/processing/get_sd_price_price_request.py +105 -0
- bb_integrations_lib/pipelines/steps/processing/keyvu_upload_deliveryplan_step.py +39 -0
- bb_integrations_lib/pipelines/steps/processing/mark_orders_exported_in_bbd_step.py +185 -0
- bb_integrations_lib/pipelines/steps/processing/pe_price_rows_processing_step.py +174 -0
- bb_integrations_lib/pipelines/steps/processing/send_process_report_step.py +47 -0
- bb_integrations_lib/pipelines/steps/processing/sftp_renamer_step.py +61 -0
- bb_integrations_lib/pipelines/steps/processing/tank_reading_touchup_steps.py +75 -0
- bb_integrations_lib/pipelines/steps/processing/upload_supplier_invoice_step.py +16 -0
- bb_integrations_lib/pipelines/steps/send_attached_in_rita_email_step.py +44 -0
- bb_integrations_lib/pipelines/steps/send_rita_email_step.py +34 -0
- bb_integrations_lib/pipelines/steps/sleep_step.py +24 -0
- bb_integrations_lib/pipelines/wrappers/__init__.py +0 -0
- bb_integrations_lib/pipelines/wrappers/accessorials_transformation.py +104 -0
- bb_integrations_lib/pipelines/wrappers/distribution_report.py +191 -0
- bb_integrations_lib/pipelines/wrappers/export_tank_readings.py +237 -0
- bb_integrations_lib/pipelines/wrappers/import_tank_readings.py +192 -0
- bb_integrations_lib/pipelines/wrappers/wrapper.py +81 -0
- bb_integrations_lib/protocols/__init__.py +0 -0
- bb_integrations_lib/protocols/flat_file.py +210 -0
- bb_integrations_lib/protocols/gravitate_client.py +104 -0
- bb_integrations_lib/protocols/pipelines.py +697 -0
- bb_integrations_lib/provider/__init__.py +0 -0
- bb_integrations_lib/provider/api/__init__.py +0 -0
- bb_integrations_lib/provider/api/cargas/__init__.py +0 -0
- bb_integrations_lib/provider/api/cargas/client.py +43 -0
- bb_integrations_lib/provider/api/cargas/model.py +49 -0
- bb_integrations_lib/provider/api/cargas/protocol.py +23 -0
- bb_integrations_lib/provider/api/dtn/__init__.py +0 -0
- bb_integrations_lib/provider/api/dtn/client.py +128 -0
- bb_integrations_lib/provider/api/dtn/protocol.py +9 -0
- bb_integrations_lib/provider/api/keyvu/__init__.py +0 -0
- bb_integrations_lib/provider/api/keyvu/client.py +30 -0
- bb_integrations_lib/provider/api/keyvu/model.py +149 -0
- bb_integrations_lib/provider/api/macropoint/__init__.py +0 -0
- bb_integrations_lib/provider/api/macropoint/client.py +28 -0
- bb_integrations_lib/provider/api/macropoint/model.py +40 -0
- bb_integrations_lib/provider/api/pc_miler/__init__.py +0 -0
- bb_integrations_lib/provider/api/pc_miler/client.py +130 -0
- bb_integrations_lib/provider/api/pc_miler/model.py +6 -0
- bb_integrations_lib/provider/api/pc_miler/web_services_apis.py +131 -0
- bb_integrations_lib/provider/api/platform_science/__init__.py +0 -0
- bb_integrations_lib/provider/api/platform_science/client.py +147 -0
- bb_integrations_lib/provider/api/platform_science/model.py +82 -0
- bb_integrations_lib/provider/api/quicktrip/__init__.py +0 -0
- bb_integrations_lib/provider/api/quicktrip/client.py +52 -0
- bb_integrations_lib/provider/api/telapoint/__init__.py +0 -0
- bb_integrations_lib/provider/api/telapoint/client.py +68 -0
- bb_integrations_lib/provider/api/telapoint/model.py +178 -0
- bb_integrations_lib/provider/api/warren_rogers/__init__.py +0 -0
- bb_integrations_lib/provider/api/warren_rogers/client.py +207 -0
- bb_integrations_lib/provider/aws/__init__.py +0 -0
- bb_integrations_lib/provider/aws/s3/__init__.py +0 -0
- bb_integrations_lib/provider/aws/s3/client.py +126 -0
- bb_integrations_lib/provider/ftp/__init__.py +0 -0
- bb_integrations_lib/provider/ftp/client.py +140 -0
- bb_integrations_lib/provider/ftp/interface.py +273 -0
- bb_integrations_lib/provider/ftp/model.py +76 -0
- bb_integrations_lib/provider/imap/__init__.py +0 -0
- bb_integrations_lib/provider/imap/client.py +228 -0
- bb_integrations_lib/provider/imap/model.py +3 -0
- bb_integrations_lib/provider/sqlserver/__init__.py +0 -0
- bb_integrations_lib/provider/sqlserver/client.py +106 -0
- bb_integrations_lib/secrets/__init__.py +4 -0
- bb_integrations_lib/secrets/adapters.py +98 -0
- bb_integrations_lib/secrets/credential_models.py +222 -0
- bb_integrations_lib/secrets/factory.py +85 -0
- bb_integrations_lib/secrets/providers.py +160 -0
- bb_integrations_lib/shared/__init__.py +0 -0
- bb_integrations_lib/shared/exceptions.py +25 -0
- bb_integrations_lib/shared/model.py +1039 -0
- bb_integrations_lib/shared/shared_enums.py +510 -0
- bb_integrations_lib/storage/README.md +236 -0
- bb_integrations_lib/storage/__init__.py +0 -0
- bb_integrations_lib/storage/aws/__init__.py +0 -0
- bb_integrations_lib/storage/aws/s3.py +8 -0
- bb_integrations_lib/storage/defaults.py +72 -0
- bb_integrations_lib/storage/gcs/__init__.py +0 -0
- bb_integrations_lib/storage/gcs/client.py +8 -0
- bb_integrations_lib/storage/gcsmanager/__init__.py +0 -0
- bb_integrations_lib/storage/gcsmanager/client.py +8 -0
- bb_integrations_lib/storage/setup.py +29 -0
- bb_integrations_lib/util/__init__.py +0 -0
- bb_integrations_lib/util/cache/__init__.py +0 -0
- bb_integrations_lib/util/cache/custom_ttl_cache.py +75 -0
- bb_integrations_lib/util/cache/protocol.py +9 -0
- bb_integrations_lib/util/config/__init__.py +0 -0
- bb_integrations_lib/util/config/manager.py +391 -0
- bb_integrations_lib/util/config/model.py +41 -0
- bb_integrations_lib/util/exception_logger/__init__.py +0 -0
- bb_integrations_lib/util/exception_logger/exception_logger.py +146 -0
- bb_integrations_lib/util/exception_logger/test.py +114 -0
- bb_integrations_lib/util/utils.py +364 -0
- bb_integrations_lib/workers/__init__.py +0 -0
- bb_integrations_lib/workers/groups.py +13 -0
- bb_integrations_lib/workers/rpc_worker.py +50 -0
- bb_integrations_lib/workers/topics.py +20 -0
- bb_integrations_library-3.0.11.dist-info/METADATA +59 -0
- bb_integrations_library-3.0.11.dist-info/RECORD +217 -0
- 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
|