DBExportTool / src /notion_integration.py
joycecast's picture
Upload 6 files
07be248 verified
Raw
History Blame Contribute Delete
230 kB
#!/usr/bin/env python3
"""
Notion API Integration Module
Handles all Notion database operations for MAWB and Entry management.
"""
import requests
import json
import logging
import os
import re
import time
import hashlib
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class NotionIntegration:
def __init__(self, api_key: str, database_ids: Dict[str, str], cache_ttl: int = 300):
"""
Initialize Notion API integration with smart caching.
Args:
api_key: Notion API key
database_ids: Dictionary mapping table names to database IDs
cache_ttl: Cache time-to-live in seconds (default: 5 minutes)
"""
self.api_key = api_key
self.database_ids = database_ids
self.base_url = "https://api.notion.com/v1"
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Notion-Version": "2025-09-03"
}
# Smart caching system
self._cache = {}
self._cache_ttl = cache_ttl
self._cache_stats = {"hits": 0, "misses": 0, "total_requests": 0}
def _get_cache_key(self, method: str, endpoint: str, data: Dict = None) -> str:
"""Generate a unique cache key for the request."""
# Create a deterministic string from the request parameters
key_data = f"{method}:{endpoint}"
if data:
# Sort the data to ensure consistent keys
key_data += f":{json.dumps(data, sort_keys=True)}"
return hashlib.md5(key_data.encode()).hexdigest()
def _is_cacheable_request(self, method: str, endpoint: str) -> bool:
"""Determine if a request should be cached."""
# Only cache GET requests and POST data source queries (read operations)
if method == "GET":
return True
if method == "POST" and "/data_sources/" in endpoint and "/query" in endpoint:
return True
return False
def _get_from_cache(self, cache_key: str) -> Optional[Dict]:
"""Get data from cache if valid."""
if cache_key in self._cache:
cached_data, timestamp = self._cache[cache_key]
if time.time() - timestamp < self._cache_ttl:
self._cache_stats["hits"] += 1
logging.debug(f"Cache hit for key: {cache_key[:8]}...")
return cached_data
else:
# Remove expired entry
del self._cache[cache_key]
self._cache_stats["misses"] += 1
return None
def _store_in_cache(self, cache_key: str, data: Dict) -> None:
"""Store data in cache with timestamp."""
self._cache[cache_key] = (data, time.time())
logging.debug(f"Cached response for key: {cache_key[:8]}...")
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache performance statistics."""
total = self._cache_stats["hits"] + self._cache_stats["misses"]
hit_rate = (self._cache_stats["hits"] / total * 100) if total > 0 else 0
mawb_cache_size = len(getattr(self, '_mawb_lookup_cache', {}))
return {
"cache_entries": len(self._cache),
"cache_hits": self._cache_stats["hits"],
"cache_misses": self._cache_stats["misses"],
"hit_rate_percent": round(hit_rate, 2),
"total_requests": self._cache_stats["total_requests"],
"mawb_cache_entries": mawb_cache_size,
"milestone_api_calls_saved": self._cache_stats.get("milestone_api_savings", 0)
}
def clear_cache(self) -> None:
"""Clear all cache entries."""
self._cache.clear()
logging.info("Cache cleared")
def clear_expired_cache(self) -> int:
"""Clear expired cache entries and return count cleared."""
current_time = time.time()
expired_keys = [
key for key, (_, timestamp) in self._cache.items()
if current_time - timestamp >= self._cache_ttl
]
for key in expired_keys:
del self._cache[key]
if expired_keys:
logging.debug(f"Cleared {len(expired_keys)} expired cache entries")
return len(expired_keys)
def invalidate_mawb_cache(self, mawb: str) -> int:
"""
Invalidate all cache entries related to a specific MAWB.
Called when MAWB-related data is updated to ensure consistency.
Args:
mawb: MAWB number to invalidate cache for
Returns:
Number of cache entries invalidated
"""
mawb_related_keys = []
# Find all cache keys that contain the MAWB number
for cache_key in self._cache.keys():
# Check if cache key contains MAWB-related queries
cached_data, _ = self._cache[cache_key]
cache_content = json.dumps(cached_data) if cached_data else ""
if mawb in cache_content or mawb in cache_key:
mawb_related_keys.append(cache_key)
# Remove the identified keys
for key in mawb_related_keys:
del self._cache[key]
if mawb_related_keys:
logging.debug(f"Invalidated {len(mawb_related_keys)} cache entries for MAWB {mawb}")
return len(mawb_related_keys)
def invalidate_task_cache(self, task_type: str = None) -> int:
"""
Invalidate cache entries related to task queries.
Called when task status is updated.
Args:
task_type: Optional task type filter (e.g., "Entry Submit")
Returns:
Number of cache entries invalidated
"""
task_related_keys = []
for cache_key in self._cache.keys():
cached_data, _ = self._cache[cache_key]
cache_content = json.dumps(cached_data) if cached_data else ""
# Check if this is a task-related query
is_task_query = (
"mawb_task" in cache_content or
"/databases/" in cache_key and "task" in cache_content.lower()
)
if is_task_query:
if task_type is None or task_type in cache_content:
task_related_keys.append(cache_key)
# Remove the identified keys
for key in task_related_keys:
del self._cache[key]
if task_related_keys:
logging.debug(f"Invalidated {len(task_related_keys)} task cache entries{f' for type {task_type}' if task_type else ''}")
return len(task_related_keys)
def _make_request(self, method: str, endpoint: str, data: Dict = None) -> Dict:
"""Make API request to Notion with smart caching and retry logic."""
self._cache_stats["total_requests"] += 1
# Check cache for read operations
if self._is_cacheable_request(method, endpoint):
cache_key = self._get_cache_key(method, endpoint, data)
cached_result = self._get_from_cache(cache_key)
if cached_result is not None:
return cached_result
url = f"{self.base_url}{endpoint}"
# Retry configuration
max_retries = 3
base_delay = 1.0 # seconds
timeout = 60 # 60 seconds timeout for batch operations
for attempt in range(max_retries):
try:
if method == "GET":
response = requests.get(url, headers=self.headers, json=data, timeout=timeout)
elif method == "POST":
response = requests.post(url, headers=self.headers, json=data, timeout=timeout)
elif method == "PATCH":
response = requests.patch(url, headers=self.headers, json=data, timeout=timeout)
response.raise_for_status()
result = response.json()
# Store in cache if cacheable
if self._is_cacheable_request(method, endpoint):
self._store_in_cache(cache_key, result)
# Periodically clean expired cache entries
if self._cache_stats["total_requests"] % 50 == 0:
self.clear_expired_cache()
return result
except requests.exceptions.Timeout as e:
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logging.warning(f"Request timeout (attempt {attempt + 1}/{max_retries}), retrying in {delay}s: {e}")
time.sleep(delay)
else:
logging.error(f"Request timeout after {max_retries} attempts: {e}")
raise
except requests.exceptions.HTTPError as e:
# Retry on rate limit (429) or server errors (5xx)
if e.response is not None:
status_code = e.response.status_code
if status_code == 429 or (500 <= status_code < 600):
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logging.warning(f"HTTP {status_code} error (attempt {attempt + 1}/{max_retries}), retrying in {delay}s")
time.sleep(delay)
continue
# Don't retry on client errors (4xx except 429)
logging.error(f"Notion API request failed: {e}")
if hasattr(e.response, 'text'):
logging.error(f"Response: {e.response.text}")
raise
except requests.exceptions.RequestException as e:
# Retry on network errors
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logging.warning(f"Network error (attempt {attempt + 1}/{max_retries}), retrying in {delay}s: {e}")
time.sleep(delay)
else:
logging.error(f"Network error after {max_retries} attempts: {e}")
raise
def _format_datetime_property(self, dt: datetime = None, timezone: str = None) -> Dict:
"""Format datetime for Notion date property with timezone conversion.
Args:
dt: datetime object (if None, uses current time)
timezone: timezone string to convert to (e.g., 'US/Eastern', 'US/Pacific')
Returns:
Notion date property dict with datetime in specified timezone
"""
if dt is None:
dt = datetime.now()
# Convert to specified timezone if provided
if timezone:
try:
target_tz = ZoneInfo(timezone)
if dt.tzinfo is None:
# If naive datetime, assume it's already in the target timezone
dt = dt.replace(tzinfo=target_tz)
else:
# Convert from current timezone to target timezone
dt = dt.astimezone(target_tz)
logging.debug(f"Converted datetime to {timezone}: {dt.isoformat()}")
except Exception as e:
logging.warning(f"Invalid timezone '{timezone}': {e}, using datetime as-is")
# For Notion API: use ISO 8601 format with timezone offset
# Note: Notion displays timezone offsets as "GMT-7", "GMT-8", etc. in UI
# This is controlled by Notion's display preferences, not our API format
if dt.tzinfo is not None:
# Use ISO format with timezone offset (e.g., 2025-09-11T12:22:00-07:00)
date_property = {
"date": {
"start": dt.isoformat() # Notion will display as "GMT-7" in UI
}
}
else:
# Naive datetime - assume UTC
date_property = {
"date": {
"start": dt.isoformat()
}
}
return date_property
def _format_text_property(self, text: str) -> Dict:
"""Format text for Notion rich text property."""
return {
"rich_text": [
{
"text": {
"content": text
}
}
]
}
def _format_title_property(self, text: str) -> Dict:
"""Format text for Notion title property."""
return {
"title": [
{
"text": {
"content": text
}
}
]
}
def _format_number_property(self, number) -> Dict:
"""Format number for Notion number property."""
return {
"number": float(number) if number is not None else None
}
def _format_checkbox_property(self, checked: bool) -> Dict:
"""Format checkbox for Notion checkbox property."""
return {
"checkbox": checked
}
def get_timezone_for_pod_code(self, pod_code: str) -> str:
"""Get timezone string for CBP port code.
Args:
pod_code: CBP port code (e.g., "4701", "2720")
Returns:
Timezone string (e.g., "US/Eastern") or default "US/Pacific"
"""
# Import constants here to avoid circular imports
try:
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from utils.constants import POD_STATE_MAPPINGS, POD_TIMEZONE_MAPPINGS
# Create reverse mapping: port code -> POD
PORT_CODE_TO_POD = {code: pod for pod, (code, _) in POD_STATE_MAPPINGS.items()}
# Get POD from port code
pod = PORT_CODE_TO_POD.get(pod_code)
logging.info(f"TIMEZONE DEBUG: Available port codes: {list(PORT_CODE_TO_POD.keys())}")
logging.info(f"TIMEZONE DEBUG: Looking up port code '{pod_code}' -> POD '{pod}'")
if pod:
timezone = POD_TIMEZONE_MAPPINGS.get(pod, "US/Pacific")
logging.info(f"TIMEZONE DEBUG: Mapped port code {pod_code} -> POD {pod} -> timezone {timezone}")
return timezone
else:
logging.warning(f"TIMEZONE DEBUG: Unknown port code '{pod_code}', using default timezone US/Pacific")
logging.info(f"TIMEZONE DEBUG: Available mappings: {POD_STATE_MAPPINGS}")
return "US/Pacific"
except Exception as e:
logging.error(f"Error mapping port code to timezone: {e}")
return "US/Pacific"
def format_datetime_with_timezone(self, dt: datetime, timezone: str) -> Dict:
"""Format datetime with specific timezone for Notion.
Args:
dt: datetime object
timezone: timezone string (e.g., 'US/Eastern')
Returns:
Notion date property dict
"""
return self._format_datetime_property(dt, timezone)
def _get_data_source_id(self, database_name: str) -> str:
"""
Get the data source ID for a database using 2025-09-03 API.
Args:
database_name: Name of the database
Returns:
Data source ID
"""
database_id = self.database_ids.get(database_name)
if not database_id:
raise ValueError(f"Database ID not configured for: {database_name}")
# Check cache first
cache_key = f"data_source_{database_name}"
if hasattr(self, '_data_source_cache') and cache_key in self._data_source_cache:
return self._data_source_cache[cache_key]
try:
# Get database info to extract data source ID
response = self._make_request("GET", f"/databases/{database_id}")
data_sources = response.get("data_sources", [])
if data_sources:
data_source_id = data_sources[0]["id"]
else:
# Fallback: use database_id as data_source_id
data_source_id = database_id
# Cache the result
if not hasattr(self, '_data_source_cache'):
self._data_source_cache = {}
self._data_source_cache[cache_key] = data_source_id
logging.debug(f"Found data source ID for {database_name}: {data_source_id}")
return data_source_id
except Exception as e:
logging.warning(f"Could not get data source ID for {database_name}, using database_id: {e}")
return database_id
def _make_database_query(self, database_name: str, query_data: Dict) -> Dict:
"""
Make a database query using the 2025-09-03 API format.
Args:
database_name: Name of the database
query_data: Query payload
Returns:
Query response
"""
try:
# Get the correct data source ID
data_source_id = self._get_data_source_id(database_name)
# Use data sources endpoint with POST method
endpoint = f"/data_sources/{data_source_id}/query"
logging.debug(f"Using data source endpoint for {database_name}: {data_source_id}")
return self._make_request("POST", endpoint, query_data)
except Exception as e:
logging.error(f"Database query failed for {database_name}: {e}")
raise
def find_mawb_with_filters(self, mawb: str, customer: str = None, exists_only: bool = False) -> Optional[Dict]:
"""
Unified MAWB lookup with optional customer filtering.
Args:
mawb: MAWB number
customer: Optional customer filter
exists_only: If True, return True/False, if False return full record
Returns:
MAWB record dict if found (exists_only=False), True/False (exists_only=True), or None
"""
try:
database_id = self.database_ids.get("mawb")
if not database_id:
logging.error("MAWB database ID not configured")
return False if exists_only else None
# Build filter - always include MAWB filter
filters = [{
"property": "mawb",
"title": {
"equals": mawb
}
}]
# Add customer filter if provided
if customer:
filters.append({
"property": "customer",
"rich_text": {
"equals": customer
}
})
# Build query data
if len(filters) == 1:
query_data = {"filter": filters[0]}
else:
query_data = {"filter": {"and": filters}}
response = self._make_database_query("mawb", query_data)
results = response.get("results", [])
if exists_only:
return len(results) > 0
else:
return results[0] if results else None
except Exception as e:
logging.error(f"Error finding MAWB record: {e}")
return False if exists_only else None
def find_mawb_record_by_number(self, mawb: str) -> Optional[Dict]:
"""
Find MAWB record by MAWB number (backward compatibility wrapper).
Args:
mawb: MAWB number
Returns:
MAWB record dict if found, None otherwise
"""
return self.find_mawb_with_filters(mawb, customer=None, exists_only=False)
def find_mawb_record_by_number_cached(self, mawb: str) -> Optional[Dict]:
"""
Enhanced MAWB lookup with specialized caching for milestone processing.
Args:
mawb: MAWB number
Returns:
MAWB record dict if found, None otherwise
"""
cache_key = f"mawb_lookup:{mawb}"
# Initialize cache if it doesn't exist
if not hasattr(self, '_mawb_lookup_cache'):
self._mawb_lookup_cache = {}
# Check specialized MAWB cache first
cached_result = self._mawb_lookup_cache.get(cache_key)
if cached_result and (time.time() - cached_result[1]) < 300: # 5-minute TTL
self._cache_stats["hits"] += 1
logging.debug(f"MAWB lookup cache hit for: {mawb}")
return cached_result[0]
# Cache miss - use existing method
self._cache_stats["misses"] += 1
result = self.find_mawb_record_by_number(mawb)
# Cache the result
if result:
self._mawb_lookup_cache[cache_key] = (result, time.time())
logging.debug(f"MAWB lookup cached for: {mawb}")
# Clean up old cache entries periodically
if len(self._mawb_lookup_cache) > 100:
self._cleanup_mawb_cache()
return result
def _cleanup_mawb_cache(self):
"""Clean up expired MAWB cache entries."""
current_time = time.time()
expired_keys = [
key for key, (_, timestamp) in self._mawb_lookup_cache.items()
if current_time - timestamp > 300
]
for key in expired_keys:
del self._mawb_lookup_cache[key]
logging.debug(f"Cleaned up {len(expired_keys)} expired MAWB cache entries")
def check_mawb_exists(self, mawb: str, customer: str) -> bool:
"""
Check if MAWB already exists in Notion database (optimized wrapper).
Args:
mawb: MAWB number
customer: Customer name
Returns:
True if MAWB exists, False otherwise
"""
return self.find_mawb_with_filters(mawb, customer=customer, exists_only=True)
def create_mawb_record(self, mawb: str, customer: str, entry_type: str, pod: str,
house_bill: str = None, cartons: int = None, pieces: int = None,
cwt: float = None, eta: datetime = None, awb_received: datetime = None,
manifest_received: datetime = None, password_received: datetime = None,
entry_line: int = None) -> bool:
"""
Create new MAWB record in Notion.
Args:
mawb: MAWB number
customer: Customer name
entry_type: Type of entry
pod: Port of Discharge
house_bill: House bill number (optional)
cartons: Number of cartons (optional)
pieces: Number of pieces (optional)
cwt: Chargeable weight from AWB (optional)
eta: Estimated time of arrival (optional)
awb_received: AWB received timestamp (optional)
manifest_received: Manifest received timestamp (optional)
password_received: Password received timestamp (optional)
entry_line: Number of entry lines in manifest (optional)
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("mawb")
if not database_id:
logging.error("MAWB database ID not configured")
return False
properties = {
"mawb": self._format_title_property(mawb),
"customer": self._format_text_property(customer),
"entry_type": self._format_text_property(entry_type),
"pod": self._format_text_property(pod),
"date_processed": self._format_datetime_property(),
"last_updated": self._format_datetime_property()
}
# Add optional fields
if house_bill:
properties["house_bill"] = self._format_text_property(house_bill)
if cartons is not None:
properties["cartons"] = self._format_number_property(cartons)
if pieces is not None:
properties["pieces"] = self._format_number_property(pieces)
if cwt is not None:
properties["cwt"] = self._format_number_property(cwt)
if eta:
properties["eta"] = self._format_datetime_property(eta)
if awb_received:
properties["awb_received"] = self._format_datetime_property(awb_received, "US/Pacific")
if manifest_received:
properties["manifest_received"] = self._format_datetime_property(manifest_received, "US/Pacific")
if password_received:
properties["password_received"] = self._format_datetime_property(password_received, "US/Pacific")
if entry_line is not None:
properties["entry_line"] = self._format_number_property(entry_line)
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("mawb")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
self._make_request("POST", "/pages", data)
logging.info(f"Created MAWB record: {mawb} for {customer}")
return True
except Exception as e:
logging.error(f"Error creating MAWB record: {e}")
return False
def update_mawb_status(self, mawb: str, customer: str, **updates) -> bool:
"""
Update MAWB record status fields.
Args:
mawb: MAWB number
customer: Customer name
**updates: Fields to update (awb_received, manifest_received, password_received, cwt, etc.)
Returns:
True if successful, False otherwise
"""
try:
# First find the record
database_id = self.database_ids.get("mawb")
if not database_id:
logging.error("MAWB database ID not configured")
return False
query_data = {
"filter": {
"and": [
{
"property": "mawb",
"title": {
"equals": mawb
}
},
{
"property": "customer",
"rich_text": {
"equals": customer
}
}
]
}
}
response = self._make_database_query("mawb", query_data)
results = response.get("results", [])
if not results:
logging.error(f"MAWB record not found: {mawb}")
return False
page_id = results[0]["id"]
# Format updates for Notion
properties = {"last_updated": self._format_datetime_property()}
for key, value in updates.items():
if isinstance(value, datetime):
# Apply timezone for specific field types
if key in ['awb_received', 'manifest_received', 'password_received']:
properties[key] = self._format_datetime_property(value, "US/Pacific")
else:
properties[key] = self._format_datetime_property(value)
elif isinstance(value, str):
properties[key] = self._format_text_property(value)
elif isinstance(value, (int, float)):
properties[key] = self._format_number_property(value)
elif isinstance(value, bool):
properties[key] = self._format_checkbox_property(value)
update_data = {
"properties": properties
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Updated MAWB {mawb} status: {updates}")
return True
except Exception as e:
logging.error(f"Error updating MAWB status: {e}")
return False
def create_entry_record(self, mawb: str, entry_number: str,
ams_submit_date: datetime = None, entry_submit_date: datetime = None,
cbp_hold_line: str = None, cbp_hold_date: datetime = None,
cbp_release_date: datetime = None, cpsc_review_line: str = None,
cpsc_review_date: datetime = None, cpsc_release_date: datetime = None,
estimated_review_date: datetime = None, auto_link_milestone_tasks: bool = True) -> bool:
"""
Create new Entry record in Notion with automatic MAWB relation linking.
Args:
mawb: MAWB number
entry_number: Entry number
ams_submit_date: AMS submit date (optional)
entry_submit_date: Entry submit date (optional)
cbp_hold_line: CBP hold line (optional)
cbp_hold_date: CBP hold date (optional)
cbp_release_date: CBP release date (optional)
cpsc_review_line: CPSC review line (optional)
cpsc_review_date: CPSC review date (optional)
cpsc_release_date: CPSC release date (optional)
estimated_review_date: Estimated review date from CPSC tracker (optional)
auto_link_milestone_tasks: Whether to automatically link to milestone tasks (default True)
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
# Find the MAWB record to link to
mawb_record = self.find_mawb_record_by_number(mawb)
# Extract POD for timezone determination
pod_timezone = "US/Pacific" # default
if mawb_record:
try:
mawb_properties = mawb_record.get("properties", {})
if "pod" in mawb_properties and mawb_properties["pod"].get("rich_text"):
pod = mawb_properties["pod"]["rich_text"][0].get("text", {}).get("content", "")
if pod:
pod_timezone = self.get_timezone_for_pod_code(pod)
logging.info(f"Using POD timezone {pod_timezone} for MAWB {mawb} entries")
except Exception as e:
logging.warning(f"Error extracting POD from MAWB {mawb}: {e}, using default timezone")
properties = {
"mawb": self._format_title_property(mawb),
"entry_number": self._format_text_property(entry_number)
}
# Add MAWB relation if found
if mawb_record:
properties["mawb_reference"] = {
"relation": [{"id": mawb_record["id"]}]
}
logging.info(f"Linking entry {entry_number} to MAWB record {mawb}")
else:
logging.warning(f"MAWB record not found for {mawb}, creating entry without relation")
# Add optional fields with appropriate timezone handling
if ams_submit_date:
properties["ams_submit_date"] = self._format_datetime_property(ams_submit_date, pod_timezone)
if entry_submit_date:
properties["entry_submit_date"] = self._format_datetime_property(entry_submit_date, pod_timezone)
if cbp_hold_line:
properties["cbp_hold_line"] = self._format_text_property(cbp_hold_line)
if cbp_hold_date:
properties["cbp_hold_date"] = self._format_datetime_property(cbp_hold_date, pod_timezone)
if cbp_release_date:
properties["cbp_release_date"] = self._format_datetime_property(cbp_release_date, pod_timezone)
if cpsc_review_line:
properties["cpsc_review_line"] = self._format_text_property(cpsc_review_line)
if cpsc_review_date:
properties["cpsc_review_date"] = self._format_datetime_property(cpsc_review_date, "US/Eastern")
if cpsc_release_date:
properties["cpsc_release_date"] = self._format_datetime_property(cpsc_release_date, "US/Eastern")
if estimated_review_date:
properties["estimated_review_date"] = self._format_datetime_property(estimated_review_date, "US/Eastern")
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("entry_record")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
self._make_request("POST", "/pages", data)
logging.info(f"Created entry record: {entry_number} for MAWB {mawb}")
# Auto-link to milestone and clearance tasks if enabled
if auto_link_milestone_tasks:
try:
# Get both milestone and clearance tasks
milestone_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Entry Milestone Update")
clearance_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Clearance Notice")
milestone_success = True
clearance_success = True
# Link milestone tasks to existing field
if milestone_tasks:
milestone_task_ids = [task["id"] for task in milestone_tasks]
milestone_success = self.create_entry_mawb_task_relation(entry_number, milestone_task_ids)
# Link clearance tasks to new field
if clearance_tasks:
clearance_task_ids = [task["id"] for task in clearance_tasks]
clearance_success = self.create_entry_clearance_task_relation(entry_number, clearance_task_ids)
# Log results
milestone_count = len(milestone_tasks) if milestone_tasks else 0
clearance_count = len(clearance_tasks) if clearance_tasks else 0
if milestone_success and clearance_success:
logging.info(f"Auto-linked entry {entry_number} to {milestone_count} milestone tasks and {clearance_count} clearance tasks")
else:
failed_types = []
if not milestone_success and milestone_count > 0:
failed_types.append("milestone")
if not clearance_success and clearance_count > 0:
failed_types.append("clearance")
if failed_types:
logging.warning(f"Failed to auto-link entry {entry_number} to {', '.join(failed_types)} tasks")
if milestone_count == 0 and clearance_count == 0:
logging.debug(f"No milestone or clearance tasks found for MAWB {mawb} to link to entry {entry_number}")
except Exception as link_error:
logging.error(f"Error auto-linking tasks for entry {entry_number}: {link_error}")
return True
except Exception as e:
logging.error(f"Error creating entry record: {e}")
return False
def create_entry_records_batch(self, entries_data: List[Dict]) -> Dict[str, int]:
"""
Create multiple entry records in batch for improved performance.
Args:
entries_data: List of entry data dictionaries, each containing:
- mawb: MAWB number
- entry_number: Entry number
- Optional fields: ams_submit_date, entry_submit_date, etc.
Returns:
Dict with statistics: {"created": count, "failed": count, "total": count}
"""
stats = {"created": 0, "failed": 0, "total": len(entries_data)}
if not entries_data:
logging.info("No entries to create in batch")
return stats
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
stats["failed"] = stats["total"]
return stats
# Pre-fetch MAWB records for all entries to avoid repeated lookups
# Note: Caller is responsible for checking existence and separating create vs update
unique_mawbs = list(set(entry.get("mawb", "") for entry in entries_data))
mawb_records_map = {}
for mawb in unique_mawbs:
if mawb:
mawb_record = self.find_mawb_record_by_number(mawb)
if mawb_record:
mawb_records_map[mawb] = mawb_record["id"]
logging.info(f"Pre-fetched {len(mawb_records_map)} MAWB records for batch entry creation")
# Create entries in batches (Notion API doesn't support true batch creation,
# but we can optimize by reducing redundant MAWB lookups)
for entry_data in entries_data:
try:
mawb = entry_data.get("mawb", "")
entry_number = entry_data.get("entry_number", "")
if not mawb or not entry_number:
logging.error(f"Missing required fields in entry data: {entry_data}")
stats["failed"] += 1
continue
properties = {
"mawb": self._format_title_property(mawb),
"entry_number": self._format_text_property(entry_number)
}
# Add MAWB relation if found in pre-fetched map
if mawb in mawb_records_map:
properties["mawb_reference"] = {
"relation": [{"id": mawb_records_map[mawb]}]
}
# Add optional fields
optional_fields = [
"ams_submit_date", "entry_submit_date", "entry_created", "entry_date", "cbp_hold_line",
"cbp_hold_date", "cbp_release_date", "cpsc_review_line",
"cpsc_review_date", "cpsc_release_date", "estimated_review_date"
]
for field in optional_fields:
value = entry_data.get(field)
if value:
if "date" in field or field == "entry_created":
# Apply field-specific timezone logic
timezone = None
source_timezone = None
entry_number = entry_data.get("entry_number", "unknown")
if field == 'entry_date':
# entry_date should always be in Pacific timezone to preserve the date value
timezone = "US/Pacific"
source_timezone = "US/Pacific"
elif field in ['estimated_review_date']:
timezone = "US/Eastern"
elif field in ['cpsc_review_date', 'cpsc_release_date']:
timezone = "US/Eastern"
elif field in ['entry_submit_date', 'entry_created', 'cbp_hold_date', 'cbp_release_date']:
# CBP fields should use POD timezone from entry_data, default to Pacific
timezone = entry_data.get("_timezone", "US/Pacific")
else:
# Other date fields use provided timezone or no conversion
timezone = entry_data.get("_timezone")
logging.info(f"BATCH TIMEZONE DEBUG: Entry {entry_number}, field {field}, timezone: {timezone}")
properties[field] = self._format_datetime_property(value, timezone, source_timezone=source_timezone)
else:
properties[field] = self._format_text_property(str(value))
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("entry_record")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
self._make_request("POST", "/pages", data)
stats["created"] += 1
# Auto-link to milestone and clearance tasks if enabled
if entry_data.get("auto_link_milestone_tasks", True):
try:
# Get both milestone and clearance tasks
milestone_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Entry Milestone Update")
clearance_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Clearance Notice")
milestone_success = True
clearance_success = True
# Link milestone tasks to existing field
if milestone_tasks:
milestone_task_ids = [task["id"] for task in milestone_tasks]
milestone_success = self.create_entry_mawb_task_relation(entry_number, milestone_task_ids)
# Link clearance tasks to new field
if clearance_tasks:
clearance_task_ids = [task["id"] for task in clearance_tasks]
clearance_success = self.create_entry_clearance_task_relation(entry_number, clearance_task_ids)
# Log results
milestone_count = len(milestone_tasks) if milestone_tasks else 0
clearance_count = len(clearance_tasks) if clearance_tasks else 0
if milestone_success and clearance_success:
if milestone_count > 0 or clearance_count > 0:
logging.info(f"Auto-linked entry {entry_number} to {milestone_count} milestone tasks and {clearance_count} clearance tasks")
else:
failed_types = []
if not milestone_success and milestone_count > 0:
failed_types.append("milestone")
if not clearance_success and clearance_count > 0:
failed_types.append("clearance")
if failed_types:
logging.warning(f"Failed to auto-link entry {entry_number} to {', '.join(failed_types)} tasks")
except Exception as link_error:
logging.error(f"Error auto-linking tasks for entry {entry_number}: {link_error}")
except Exception as e:
logging.error(f"Error creating entry {entry_data.get('entry_number', 'unknown')}: {e}")
stats["failed"] += 1
logging.info(f"Batch entry creation completed: {stats['created']} created, {stats['failed']} failed out of {stats['total']} total")
return stats
except Exception as e:
logging.error(f"Error in batch entry creation: {e}")
stats["failed"] = stats["total"]
return stats
def create_mawb_tasks_by_type(self, mawb: str, customer: str, entry_type: str) -> bool:
"""
Create multiple MAWB task records based on customer and entry type.
Args:
mawb: MAWB number
customer: Customer name (SHEIN, BELL)
entry_type: Entry type (T86, T01, T11)
Returns:
True if all tasks created successfully, False otherwise
"""
try:
# Find the MAWB record to link to
mawb_record = self.find_mawb_record_by_number(mawb)
if not mawb_record:
logging.error(f"MAWB record not found for {mawb}")
return False
# Define tasks based on customer and entry type
tasks = []
if customer == "SHEIN" and entry_type == "T01":
tasks = [
"Entry Submit",
"Clearance Notice",
"Customs Document Upload",
"Entry Milestone Update",
"MAWB Milestone Update"
]
elif entry_type in ["T86", "T11"]: # All customers for T86/T11
tasks = [
"AMS Submit",
"ACAS Notify",
"Clearance Notice"
]
else:
logging.warning(f"No tasks defined for {customer} {entry_type}")
return True # Not an error, just no tasks to create
# Create individual task records
database_id = self.database_ids.get("mawb_task")
if not database_id:
logging.error("MAWB task database ID not configured")
return False
success_count = 0
for task_action in tasks:
try:
properties = {
"mawb": self._format_title_property(mawb),
"action": self._format_text_property(task_action),
"done": self._format_checkbox_property(False),
"entry_type": self._format_text_property(entry_type),
"customer": self._format_text_property(customer),
"mawb_reference": {
"relation": [{"id": mawb_record["id"]}]
}
}
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("mawb_task")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
self._make_request("POST", "/pages", data)
success_count += 1
logging.info(f"Created task record: {mawb} - {task_action}")
except Exception as e:
logging.error(f"Error creating task record {mawb} - {task_action}: {e}")
if success_count == len(tasks):
logging.info(f"Successfully created all {len(tasks)} task records for MAWB {mawb}")
return True
else:
logging.warning(f"Created {success_count}/{len(tasks)} task records for MAWB {mawb}")
return False
except Exception as e:
logging.error(f"Error creating MAWB task records: {e}")
return False
def create_mawb_task_record(self, mawb: str, acas_feedback: bool = False,
bol_update: bool = False, correction_7501: bool = False,
clearance_notice: bool = False, entry_milestone_update: bool = False,
customs_doc_upload: bool = False, warehouse_in: datetime = None,
warehouse_out: datetime = None, created_date: datetime = None,
cargo_ready: datetime = None) -> bool:
"""
Create new MAWB task record in Notion.
Args:
mawb: MAWB number
acas_feedback: ACAS feedback completed (default: False)
bol_update: BOL update completed (default: False)
correction_7501: 7501 correction completed (default: False)
clearance_notice: Clearance notice sent (default: False)
entry_milestone_update: Entry milestone updated (default: False)
customs_doc_upload: Customs document uploaded (default: False)
warehouse_in: Warehouse in timestamp (optional)
warehouse_out: Warehouse out timestamp (optional)
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("mawb_task")
if not database_id:
logging.error("MAWB task database ID not configured")
return False
properties = {
"mawb": self._format_title_property(mawb),
"acas_feedback": self._format_checkbox_property(acas_feedback),
"bol_update": self._format_checkbox_property(bol_update),
"7501_correction": self._format_checkbox_property(correction_7501),
"clearance_notice": self._format_checkbox_property(clearance_notice),
"entry_milestone_update": self._format_checkbox_property(entry_milestone_update),
"customs_doc_upload": self._format_checkbox_property(customs_doc_upload),
"created_date": self._format_datetime_property(created_date or datetime.now())
}
# Add optional timestamp fields
if warehouse_in:
properties["warehouse_in"] = self._format_datetime_property(warehouse_in)
if warehouse_out:
properties["warehouse_out"] = self._format_datetime_property(warehouse_out)
if cargo_ready:
properties["cargo_ready"] = self._format_datetime_property(cargo_ready)
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("mawb_task")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
self._make_request("POST", "/pages", data)
logging.info(f"Created MAWB task record for: {mawb}")
return True
except Exception as e:
logging.error(f"Error creating MAWB task record: {e}")
return False
def create_mawb_milestone_record(self, mawb: str, last_mile_carrier: str, cartons: int = 0,
pallets: int = 0, gwt: float = None, cwt: float = None,
warehouse_in: datetime = None, cargo_ready: datetime = None,
warehouse_out: datetime = None, etd: datetime = None,
eta: datetime = None, cartons_left_at_airline: int = 0,
cartons_left_at_warehouse: int = 0) -> bool:
"""
Create new MAWB milestone record in Notion.
Args:
mawb: MAWB number (title field)
last_mile_carrier: Last mile service provider name
cartons: Number of cartons for this carrier
pallets: Number of pallets (default 0)
gwt: Gross weight (manual input by user)
cwt: Chargeable weight (manual input by user)
warehouse_in: Timestamp when cargo enters warehouse
cargo_ready: Timestamp when cargo is ready
warehouse_out: Timestamp when cargo leaves warehouse
etd: Estimated time of departure
eta: Estimated time of arrival
cartons_left_at_airline: Cartons remaining at airline (default 0)
cartons_left_at_warehouse: Cartons remaining at warehouse (default 0)
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return False
# Find the MAWB record to create relation
mawb_record = self.find_mawb_record_by_number(mawb)
mawb_record_id = mawb_record.get("id") if mawb_record else None
properties = {
"mawb": self._format_title_property(mawb),
"last_mile_carrier": self._format_rich_text_property(last_mile_carrier),
"cartons": self._format_number_property(cartons),
"pallets": self._format_number_property(pallets),
"cartons_left_at_airline": self._format_number_property(cartons_left_at_airline),
"cartons_left_at_warehouse": self._format_number_property(cartons_left_at_warehouse)
}
# Add optional fields
if gwt is not None:
properties["gwt"] = self._format_number_property(gwt)
if cwt is not None:
properties["cwt"] = self._format_number_property(cwt)
if warehouse_in:
properties["warehouse_in"] = self._format_datetime_property(warehouse_in)
if cargo_ready:
properties["cargo_ready"] = self._format_datetime_property(cargo_ready)
if warehouse_out:
properties["warehouse_out"] = self._format_datetime_property(warehouse_out)
if etd:
properties["etd"] = self._format_datetime_property(etd)
if eta:
properties["eta"] = self._format_datetime_property(eta)
# Add relation to MAWB record if found
if mawb_record_id:
properties["mawb_reference"] = {
"relation": [{"id": mawb_record_id}]
}
# Add relation to Clearance Notice tasks if they exist
try:
clearance_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Clearance Notice")
if clearance_tasks:
clearance_task_ids = [{"id": task["id"]} for task in clearance_tasks]
properties["clearance_notice_task_reference"] = {
"relation": clearance_task_ids
}
logging.debug(f"Linked milestone record to {len(clearance_task_ids)} clearance tasks for MAWB {mawb}")
except Exception as e:
logging.warning(f"Could not link clearance tasks to milestone for MAWB {mawb}: {e}")
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("mawb_milestone")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
self._make_request("POST", "/pages", data)
logging.info(f"Created MAWB milestone record for: {mawb} - {last_mile_carrier}")
return True
except Exception as e:
logging.error(f"Error creating MAWB milestone record: {e}")
return False
def update_mawb_milestone_record(self, mawb: str, last_mile_carrier: str, **updates) -> bool:
"""
Update MAWB milestone record.
Args:
mawb: MAWB number
last_mile_carrier: Last mile carrier name
**updates: Milestone fields to update
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return False
# Find the specific milestone record
query_data = {
"filter": {
"and": [
{
"property": "mawb",
"title": {
"equals": mawb
}
},
{
"property": "last_mile_carrier",
"rich_text": {
"equals": last_mile_carrier
}
}
]
}
}
response = self._make_database_query("mawb_milestone", query_data)
results = response.get("results", [])
if not results:
logging.error(f"MAWB milestone record not found: {mawb} - {last_mile_carrier}")
return False
page_id = results[0]["id"]
# Format updates for Notion
properties = {}
for key, value in updates.items():
if isinstance(value, datetime):
properties[key] = self._format_datetime_property(value)
elif isinstance(value, (int, float)):
properties[key] = self._format_number_property(value)
elif isinstance(value, str):
properties[key] = self._format_rich_text_property(value)
elif isinstance(value, bool):
properties[key] = self._format_checkbox_property(value)
update_data = {
"properties": properties
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Updated MAWB milestone {mawb} - {last_mile_carrier}: {updates}")
return True
except Exception as e:
logging.error(f"Error updating MAWB milestone record: {e}")
return False
def create_mawb_milestone_records_batch(self, milestone_records: List[Dict]) -> int:
"""
Create multiple milestone records efficiently with reduced API calls.
Args:
milestone_records: List of milestone record data dictionaries, each containing:
- properties: Formatted Notion properties dict
Returns:
Number of successfully created records
"""
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return 0
if not milestone_records:
return 0
# Pre-fetch clearance tasks for all MAWBs to optimize API calls
unique_mawbs = set()
for record_data in milestone_records:
properties = record_data.get("properties", {})
mawb_prop = properties.get("mawb", {}).get("title", [])
if mawb_prop:
mawb = mawb_prop[0].get("text", {}).get("content", "")
if mawb:
unique_mawbs.add(mawb)
# Batch fetch clearance tasks for all MAWBs
mawb_clearance_tasks = {}
for mawb in unique_mawbs:
try:
clearance_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Clearance Notice")
if clearance_tasks:
mawb_clearance_tasks[mawb] = [{"id": task["id"]} for task in clearance_tasks]
except Exception as e:
logging.warning(f"Could not fetch clearance tasks for MAWB {mawb}: {e}")
# Add clearance task relations to milestone records
for record_data in milestone_records:
properties = record_data.get("properties", {})
mawb_prop = properties.get("mawb", {}).get("title", [])
if mawb_prop:
mawb = mawb_prop[0].get("text", {}).get("content", "")
if mawb in mawb_clearance_tasks:
properties["clearance_notice_task_reference"] = {
"relation": mawb_clearance_tasks[mawb]
}
if mawb_clearance_tasks:
logging.info(f"Pre-fetched clearance tasks for {len(mawb_clearance_tasks)} MAWBs in batch milestone creation")
success_count = 0
# Process in chunks to respect API limits and prevent overwhelming
chunk_size = 10
for i in range(0, len(milestone_records), chunk_size):
chunk = milestone_records[i:i + chunk_size]
try:
for record_data in chunk:
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("mawb_milestone")
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": record_data["properties"]
}
response = self._make_request("POST", "/pages", data)
if response:
success_count += 1
# Small delay to prevent rate limiting
time.sleep(0.1)
except Exception as e:
logging.error(f"Error in batch milestone creation chunk: {e}")
continue
# Track API efficiency gains
individual_calls_saved = len(milestone_records) - (len(milestone_records) // chunk_size + (len(milestone_records) % chunk_size > 0))
if "milestone_api_savings" not in self._cache_stats:
self._cache_stats["milestone_api_savings"] = 0
self._cache_stats["milestone_api_savings"] += individual_calls_saved
logging.info(f"Batch created {success_count}/{len(milestone_records)} milestone records (saved {individual_calls_saved} API calls)")
return success_count
def update_mawb_task(self, mawb: str, **updates) -> bool:
"""
Update MAWB task record.
Args:
mawb: MAWB number
**updates: Task fields to update (boolean or datetime values)
Returns:
True if successful, False otherwise
"""
try:
# First find the record
database_id = self.database_ids.get("mawb_task")
if not database_id:
logging.error("MAWB task database ID not configured")
return False
query_data = {
"filter": {
"property": "mawb",
"title": {
"equals": mawb
}
}
}
response = self._make_database_query("mawb_task", query_data)
results = response.get("results", [])
if not results:
logging.error(f"MAWB task record not found: {mawb}")
return False
page_id = results[0]["id"]
# Format updates for Notion
properties = {}
for key, value in updates.items():
if isinstance(value, datetime):
properties[key] = self._format_datetime_property(value)
elif isinstance(value, bool):
properties[key] = self._format_checkbox_property(value)
update_data = {
"properties": properties
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Updated MAWB task {mawb}: {updates}")
return True
except Exception as e:
logging.error(f"Error updating MAWB task: {e}")
return False
def get_all_mawb_records(self) -> List[Dict]:
"""
Get all MAWB records from Notion database.
Returns:
List of MAWB records with their properties
"""
try:
database_id = self.database_ids.get("mawb")
if not database_id:
logging.error("MAWB database ID not configured")
return []
query_data = {
"page_size": 100
}
all_results = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("mawb", query_data)
results = response.get("results", [])
all_results.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
return all_results
except Exception as e:
logging.error(f"Error getting MAWB records: {e}")
return []
def get_entries_by_mawb_list(self, mawb_list: List[str]) -> List[Dict]:
"""
Get all entry records for a list of MAWBs using batched queries to handle large lists.
Args:
mawb_list: List of MAWB numbers
Returns:
List of entry records with their properties
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return []
if not mawb_list:
return []
# Split large lists into smaller batches to avoid API limits
BATCH_SIZE = 50 # Reasonable batch size for OR queries
all_entries = []
for i in range(0, len(mawb_list), BATCH_SIZE):
batch = mawb_list[i:i + BATCH_SIZE]
batch_num = i//BATCH_SIZE + 1
total_batches = (len(mawb_list) + BATCH_SIZE - 1)//BATCH_SIZE
logging.info(f"Processing entry batch {batch_num}/{total_batches} ({len(batch)} MAWBs)...")
# Build query for this batch
if len(batch) == 1:
query_filter = {
"property": "mawb",
"title": {"equals": batch[0].strip()}
}
else:
query_filter = {
"or": [
{
"property": "mawb",
"title": {"equals": mawb.strip()}
}
for mawb in batch
]
}
query_data = {
"filter": query_filter,
"page_size": 100
}
# Handle pagination for this batch
has_more = True
next_cursor = None
while has_more:
if next_cursor:
query_data["start_cursor"] = next_cursor
else:
query_data.pop("start_cursor", None)
response = self._make_database_query("entry_record", query_data)
if not response:
logging.warning(f"No response for entry batch {i//BATCH_SIZE + 1}")
break
results = response.get("results", [])
all_entries.extend(results)
has_more = response.get("has_more", False)
next_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_entries)} entries for {len(mawb_list)} MAWBs using {(len(mawb_list) + BATCH_SIZE - 1)//BATCH_SIZE} batches")
return all_entries
except Exception as e:
logging.error(f"Error getting entries by MAWB list: {e}")
return []
def get_mawb_info_by_list(self, mawb_list: List[str]) -> Dict[str, Dict]:
"""
Get MAWB information for a list of MAWBs.
Args:
mawb_list: List of MAWB numbers
Returns:
Dictionary mapping MAWB number to MAWB record data
"""
try:
database_id = self.database_ids.get("mawb")
if not database_id:
logging.error("MAWB database ID not configured")
return {}
mawb_info = {}
for mawb in mawb_list:
query_data = {
"filter": {
"property": "mawb",
"title": {
"equals": mawb.strip()
}
}
}
response = self._make_database_query("mawb", query_data)
results = response.get("results", [])
if results:
# Take the first match
mawb_record = results[0]
properties = mawb_record.get("properties", {})
# Extract key information
pod = ""
if "pod" in properties and properties["pod"].get("rich_text"):
pod = properties["pod"]["rich_text"][0].get("text", {}).get("content", "")
customer = ""
if "customer" in properties and properties["customer"].get("rich_text"):
customer = properties["customer"]["rich_text"][0].get("text", {}).get("content", "")
entry_type = ""
if "entry_type" in properties and properties["entry_type"].get("rich_text"):
entry_type = properties["entry_type"]["rich_text"][0].get("text", {}).get("content", "")
mawb_info[mawb.strip()] = {
"pod": pod,
"customer": customer,
"entry_type": entry_type,
"properties": properties
}
logging.info(f"Retrieved MAWB info for {len(mawb_info)} out of {len(mawb_list)} MAWBs")
return mawb_info
except Exception as e:
logging.error(f"Error getting MAWB info by list: {e}")
return {}
def get_milestone_records_bulk(self, mawb_list: List[str]) -> List[Dict]:
"""
Get milestone records for a list of MAWBs using batched queries to handle large lists.
Args:
mawb_list: List of MAWB numbers to query
Returns:
List of milestone records for all requested MAWBs
"""
try:
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return []
if not mawb_list:
return []
# Split large lists into smaller batches to avoid API limits
BATCH_SIZE = 50 # Reasonable batch size for OR queries
all_records = []
for i in range(0, len(mawb_list), BATCH_SIZE):
batch = mawb_list[i:i + BATCH_SIZE]
batch_num = i//BATCH_SIZE + 1
total_batches = (len(mawb_list) + BATCH_SIZE - 1)//BATCH_SIZE
logging.info(f"Processing milestone batch {batch_num}/{total_batches} ({len(batch)} MAWBs)...")
# Build query for this batch
if len(batch) == 1:
query_filter = {
"property": "mawb",
"title": {"equals": batch[0].strip()}
}
else:
query_filter = {
"or": [
{
"property": "mawb",
"title": {"equals": mawb.strip()}
}
for mawb in batch
]
}
query_data = {
"filter": query_filter,
"page_size": 100
}
# Handle pagination for this batch
has_more = True
next_cursor = None
while has_more:
if next_cursor:
query_data["start_cursor"] = next_cursor
else:
query_data.pop("start_cursor", None)
response = self._make_database_query("mawb_milestone", query_data)
if not response:
logging.warning(f"No response for milestone batch {i//BATCH_SIZE + 1}")
break
results = response.get("results", [])
all_records.extend(results)
has_more = response.get("has_more", False)
next_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_records)} milestone records for {len(mawb_list)} MAWBs using {(len(mawb_list) + BATCH_SIZE - 1)//BATCH_SIZE} batches")
return all_records
except Exception as e:
logging.error(f"Error getting milestone records bulk: {e}")
return []
def get_entries_for_cpsc_tracking(self) -> List[Dict]:
"""
Get entry records that need CPSC tracking (have cpsc_review_date but no cpsc_review_line).
Only queries entries created in the last 3 days to improve performance.
Returns:
List of entry records that need CPSC tracking
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return []
# Calculate date 3 days ago (include today)
from datetime import datetime, timedelta
three_days_ago = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d")
logging.info(f"Querying entries created on or after {three_days_ago}")
# Query entries with cpsc_review_date AND created in last 3 days
# Filter manually in Python for cpsc_review_line due to Notion API is_empty issues
query_data = {
"filter": {
"and": [
{
"property": "cpsc_review_date",
"date": {
"is_not_empty": True
}
},
{
"timestamp": "created_time",
"created_time": {
"on_or_after": three_days_ago
}
}
]
},
"page_size": 100
}
all_entries = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
all_entries.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
# Filter manually in Python - only entries with empty cpsc_review_line
filtered_entries = []
for entry in all_entries:
try:
properties = entry.get("properties", {})
cpsc_line_prop = properties.get("cpsc_review_line", {})
cpsc_line_text = cpsc_line_prop.get("rich_text", [])
# Check if cpsc_review_line is truly empty
if not cpsc_line_text or len(cpsc_line_text) == 0:
# No rich_text array means empty
filtered_entries.append(entry)
else:
# Check if the text content is empty
content = cpsc_line_text[0].get("text", {}).get("content", "")
if content.strip() == "":
filtered_entries.append(entry)
except Exception as e:
logging.warning(f"Error checking entry {entry.get('id', 'unknown')}: {e}")
continue
logging.info(f"Found {len(filtered_entries)} entries that need CPSC tracking (out of {len(all_entries)} created in last 3 days with cpsc_review_date)")
return filtered_entries
except Exception as e:
logging.error(f"Error getting entries for CPSC tracking: {e}")
return []
def get_entries_by_ids(self, entry_ids: List[str]) -> List[Dict]:
"""
Get specific entry records by their IDs.
This is much more efficient for parallel processing than querying all entries.
Args:
entry_ids: List of Notion page IDs to retrieve
Returns:
List of entry record dictionaries
"""
try:
if not entry_ids:
return []
entries = []
logging.info(f"Fetching {len(entry_ids)} specific entries by ID...")
# Fetch each entry individually by ID
for entry_id in entry_ids:
try:
response = self._make_request("GET", f"/pages/{entry_id}", data=None)
if response:
entries.append(response)
except Exception as e:
logging.warning(f"Error fetching entry {entry_id}: {e}")
continue
logging.info(f"Successfully fetched {len(entries)} entries")
return entries
except Exception as e:
logging.error(f"Error getting entries by IDs: {e}")
return []
def update_missing_mawb_references(self) -> Dict[str, int]:
"""
Update missing MAWB references in entry records.
Finds all entry records with blank mawb_reference relation field
and links them to their corresponding MAWB record.
Returns:
Dict with statistics: {'processed': int, 'updated': int, 'errors': int}
"""
stats = {'processed': 0, 'updated': 0, 'errors': 0}
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return stats
# Get all entry records (we'll filter manually for empty mawb_reference)
query_data = {"page_size": 100}
all_entries = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
all_entries.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_entries)} total entry records for MAWB reference check")
# Filter for entries with empty mawb_reference
entries_to_update = []
for entry in all_entries:
try:
properties = entry.get("properties", {})
mawb_ref_prop = properties.get("mawb_reference", {})
mawb_relation = mawb_ref_prop.get("relation", [])
# Check if mawb_reference relation is empty
if not mawb_relation or len(mawb_relation) == 0:
# Get the MAWB number from the entry
mawb_prop = properties.get("mawb", {})
mawb_title = mawb_prop.get("title", [])
if mawb_title and len(mawb_title) > 0:
mawb_number = mawb_title[0].get("text", {}).get("content", "").strip()
if mawb_number:
entries_to_update.append({
'entry_id': entry.get("id"),
'mawb_number': mawb_number
})
except Exception as e:
logging.warning(f"Error checking entry {entry.get('id', 'unknown')}: {e}")
stats['errors'] += 1
continue
logging.info(f"Found {len(entries_to_update)} entries with missing MAWB references")
# Update each entry with missing reference
for entry_info in entries_to_update:
try:
stats['processed'] += 1
entry_id = entry_info['entry_id']
mawb_number = entry_info['mawb_number']
# Find the corresponding MAWB record
mawb_record = self.find_mawb_record_by_number(mawb_number)
if not mawb_record:
logging.warning(f"No MAWB record found for MAWB number: {mawb_number}")
stats['errors'] += 1
continue
# Update the entry record with MAWB reference
update_data = {
"properties": {
"mawb_reference": {
"relation": [{"id": mawb_record["id"]}]
}
}
}
response = self._make_request("PATCH", f"/pages/{entry_id}", update_data)
if response:
stats['updated'] += 1
logging.info(f"Updated MAWB reference for entry with MAWB {mawb_number}")
else:
stats['errors'] += 1
except Exception as e:
logging.error(f"Error updating entry {entry_info.get('entry_id', 'unknown')}: {e}")
stats['errors'] += 1
continue
logging.info(f"MAWB reference update completed: {stats['updated']} updated, {stats['errors']} errors out of {stats['processed']} processed")
return stats
except Exception as e:
logging.error(f"Error updating missing MAWB references: {e}")
stats['errors'] = stats['processed'] # Mark all as errors if batch fails
return stats
def update_entry_record_cpsc_data(self, mawb: str, entry_number: str,
cpsc_review_line: str = None,
estimated_review_date: datetime = None) -> bool:
"""
Update entry record with CPSC tracking data.
Args:
mawb: MAWB number
entry_number: Entry number
cpsc_review_line: CPSC review line (optional)
estimated_review_date: Estimated review date (optional)
Returns:
True if successful, False otherwise
"""
try:
# First find the record
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
query_data = {
"filter": {
"and": [
{
"property": "mawb",
"title": {
"equals": mawb
}
},
{
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
]
}
}
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
if not results:
logging.error(f"Entry record not found: {mawb} - {entry_number}")
return False
page_id = results[0]["id"]
# Format updates for Notion
properties = {}
if cpsc_review_line:
properties["cpsc_review_line"] = self._format_text_property(cpsc_review_line)
if estimated_review_date:
properties["estimated_review_date"] = self._format_datetime_property(estimated_review_date, "US/Eastern")
if not properties:
logging.warning(f"No CPSC data to update for {mawb} - {entry_number}")
return True
update_data = {
"properties": properties
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Updated entry record {mawb} - {entry_number} with CPSC data")
return True
except Exception as e:
logging.error(f"Error updating entry record CPSC data: {e}")
return False
def check_entry_exists(self, entry_number: str) -> bool:
"""
Check if an entry record exists by entry number.
Args:
entry_number: Entry number to check
Returns:
True if entry exists, False otherwise
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
query_data = {
"filter": {
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
}
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
return len(results) > 0
except Exception as e:
logging.error(f"Error checking if entry exists: {e}")
return False
def check_entry_field_populated(self, entry_number: str, field_name: str) -> bool:
"""
Check if a specific field is populated for an entry record.
Args:
entry_number: Entry number to check
field_name: Name of the field to check
Returns:
True if field is populated, False otherwise
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
query_data = {
"filter": {
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
}
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
if not results:
logging.warning(f"Entry {entry_number} not found")
return False
# Check if the field is populated
properties = results[0].get("properties", {})
field_data = properties.get(field_name, {})
# Handle different field types
if "date" in field_data:
return field_data["date"] is not None
elif "rich_text" in field_data:
rich_text_list = field_data["rich_text"]
return len(rich_text_list) > 0 and rich_text_list[0].get("text", {}).get("content", "").strip() != ""
elif "number" in field_data:
return field_data["number"] is not None
elif "checkbox" in field_data:
return field_data["checkbox"] is not None
else:
logging.warning(f"Unknown field type for {field_name}")
return False
except Exception as e:
logging.error(f"Error checking entry field: {e}")
return False
def update_entry_record_field(self, entry_number: str, field_name: str, field_value) -> bool:
"""
Update a specific field for an entry record.
Args:
entry_number: Entry number to update
field_name: Name of the field to update
field_value: Value to set (datetime, string, number, or boolean)
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
# First find the entry record with retry for newly created records
query_data = {
"filter": {
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
}
page_id = None
max_retries = 3
for attempt in range(max_retries):
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
if results:
page_id = results[0]["id"]
break
elif attempt < max_retries - 1:
# Wait briefly before retrying for newly created records
import time
logging.info(f"Entry record {entry_number} not found on attempt {attempt + 1}, retrying...")
time.sleep(1)
else:
logging.error(f"Entry record not found after {max_retries} attempts: {entry_number}")
return False
# Format the field value based on type
properties = {}
# Special handling for known fields
if isinstance(field_value, datetime):
# Apply timezone for specific field types
if field_name in ['awb_received', 'manifest_received', 'password_received']:
properties[field_name] = self._format_datetime_property(field_value, "US/Pacific")
elif field_name == 'entry_date':
# entry_date should always be in Pacific timezone to preserve the date value
properties[field_name] = self._format_datetime_property(field_value, "US/Pacific", source_timezone="US/Pacific")
elif field_name == 'estimated_review_date':
# Only estimated_review_date defaults to Eastern timezone
properties[field_name] = self._format_datetime_property(field_value, "US/Eastern")
elif field_name in ['cpsc_review_date', 'cpsc_release_date']:
# CPSC fields use Eastern timezone
properties[field_name] = self._format_datetime_property(field_value, "US/Eastern")
elif field_name in ['entry_submit_date', 'entry_created', 'cbp_hold_date', 'cbp_release_date']:
# CBP fields should use POD timezone - need to get POD from entry's MAWB
try:
# Get the entry record to find its MAWB
entry_record = results[0] # We already have this from the query above
mawb_relation = entry_record.get("properties", {}).get("mawb_reference", {}).get("relation", [])
pod_timezone = "US/Pacific" # default
if mawb_relation:
# Get the MAWB record to extract POD
mawb_id = mawb_relation[0]["id"]
mawb_record = self._make_request("GET", f"/pages/{mawb_id}")
if mawb_record:
mawb_properties = mawb_record.get("properties", {})
if "pod" in mawb_properties and mawb_properties["pod"].get("rich_text"):
pod = mawb_properties["pod"]["rich_text"][0].get("text", {}).get("content", "")
if pod:
pod_timezone = self.get_timezone_for_pod_code(pod)
logging.info(f"Using POD timezone {pod_timezone} for CBP field {field_name} in entry {entry_number}")
properties[field_name] = self._format_datetime_property(field_value, pod_timezone)
except Exception as e:
logging.warning(f"Error getting POD timezone for CBP field {field_name}: {e}, using Pacific default")
properties[field_name] = self._format_datetime_property(field_value, "US/Pacific")
else:
# Other fields use no timezone conversion
properties[field_name] = self._format_datetime_property(field_value)
elif isinstance(field_value, str):
properties[field_name] = self._format_text_property(field_value)
elif isinstance(field_value, (int, float)):
properties[field_name] = self._format_number_property(field_value)
elif isinstance(field_value, bool):
properties[field_name] = self._format_checkbox_property(field_value)
else:
logging.error(f"Unsupported field value type: {type(field_value)}")
return False
update_data = {
"properties": properties
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Updated entry {entry_number} field {field_name}")
return True
except Exception as e:
logging.error(f"Error updating entry record field: {e}")
return False
def update_entry_record(self, entry_number: str, **kwargs) -> bool:
"""
Update an entry record with multiple fields.
Args:
entry_number: Entry number to update
**kwargs: Field name-value pairs to update
Returns:
True if successful, False otherwise
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
# Find the entry record
query_data = {
"filter": {
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
}
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
if not results:
logging.warning(f"Entry {entry_number} not found")
return False
page_id = results[0]["id"]
# Build properties to update
properties = {}
# Extract timezone information if available
pod_timezone = kwargs.get('_timezone')
for field_name, field_value in kwargs.items():
# Skip internal fields that shouldn't be sent to Notion
if field_name.startswith('_'):
continue
if field_value is not None:
if isinstance(field_value, datetime):
# Apply timezone for specific field types
if field_name in ['awb_received', 'manifest_received', 'password_received']:
properties[field_name] = self._format_datetime_property(field_value, "US/Pacific")
elif field_name == 'entry_date':
# entry_date should always be in Pacific timezone to preserve the date value
properties[field_name] = self._format_datetime_property(field_value, "US/Pacific", source_timezone="US/Pacific")
elif field_name == 'estimated_review_date':
# Only estimated_review_date defaults to Eastern timezone
properties[field_name] = self._format_datetime_property(field_value, "US/Eastern")
elif field_name in ['cpsc_review_date', 'cpsc_release_date']:
# CPSC fields use Eastern timezone (or POD timezone if explicitly provided)
timezone = pod_timezone if pod_timezone else "US/Eastern"
properties[field_name] = self._format_datetime_property(field_value, timezone)
elif field_name in ['entry_submit_date', 'entry_created', 'cbp_hold_date', 'cbp_release_date']:
# CBP fields should use POD timezone - get it from entry's MAWB if not provided
if pod_timezone:
properties[field_name] = self._format_datetime_property(field_value, pod_timezone)
else:
# Need to get POD timezone from entry's MAWB relation
try:
entry_record = results[0] # We already have this from the query above
mawb_relation = entry_record.get("properties", {}).get("mawb_reference", {}).get("relation", [])
cbp_timezone = "US/Pacific" # default
if mawb_relation:
# Get the MAWB record to extract POD
mawb_id = mawb_relation[0]["id"]
mawb_record = self._make_request("GET", f"/pages/{mawb_id}")
if mawb_record:
mawb_properties = mawb_record.get("properties", {})
if "pod" in mawb_properties and mawb_properties["pod"].get("rich_text"):
pod = mawb_properties["pod"]["rich_text"][0].get("text", {}).get("content", "")
if pod:
cbp_timezone = self.get_timezone_for_pod_code(pod)
logging.info(f"Using POD timezone {cbp_timezone} for CBP field {field_name} in entry {entry_number}")
properties[field_name] = self._format_datetime_property(field_value, cbp_timezone)
except Exception as e:
logging.warning(f"Error getting POD timezone for CBP field {field_name}: {e}, using Pacific default")
properties[field_name] = self._format_datetime_property(field_value, "US/Pacific")
else:
# Other fields use no timezone conversion
properties[field_name] = self._format_datetime_property(field_value)
elif isinstance(field_value, str):
properties[field_name] = self._format_text_property(field_value)
elif isinstance(field_value, (int, float)):
properties[field_name] = {"number": field_value}
elif isinstance(field_value, bool):
properties[field_name] = {"checkbox": field_value}
else:
logging.warning(f"Unsupported field type for {field_name}: {type(field_value)}")
continue
if not properties:
logging.info(f"No properties to update for entry {entry_number}")
return True
# Update the record
update_data = {"properties": properties}
response = self._make_request("PATCH", f"/pages/{page_id}", update_data)
if response:
logging.info(f"Updated entry {entry_number} with fields: {list(properties.keys())}")
return True
else:
logging.error(f"Failed to update entry {entry_number}")
return False
except Exception as e:
logging.error(f"Error updating entry record: {e}")
return False
def get_entry_records_with_incomplete_milestone_tasks(self) -> List[Dict]:
"""
Get entry records that have related mawb_task with action='Entry Milestone Update' and done=unchecked.
Uses the new relation properties: entry_record.mawb_task_reference and mawb_task.entry_record_reference
Returns:
List of entry records with incomplete milestone tasks
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return []
# Note: Direct filtering on related properties in Notion API can be complex
# We'll use a two-step approach:
# 1. Get all mawb_task records with action='Entry Milestone Update' and done=false
# 2. Get entry_records that reference those tasks
# Step 1: Get incomplete milestone tasks
mawb_task_db_id = self.database_ids.get("mawb_task")
if not mawb_task_db_id:
logging.error("MAWB task database ID not configured")
return []
milestone_task_query = {
"filter": {
"and": [
{
"property": "action",
"rich_text": {
"equals": "Entry Milestone Update"
}
},
{
"property": "done",
"checkbox": {
"equals": False
}
}
]
},
"page_size": 100
}
# Handle pagination for milestone tasks
incomplete_milestone_tasks = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
milestone_task_query["start_cursor"] = start_cursor
milestone_response = self._make_database_query("mawb_task", milestone_task_query)
tasks = milestone_response.get("results", [])
incomplete_milestone_tasks.extend(tasks)
has_more = milestone_response.get("has_more", False)
start_cursor = milestone_response.get("next_cursor")
if start_cursor:
milestone_task_query["start_cursor"] = start_cursor
else:
milestone_task_query.pop("start_cursor", None)
if not incomplete_milestone_tasks:
logging.info("No incomplete Entry Milestone Update tasks found")
return []
# Extract task IDs
incomplete_task_ids = [task["id"] for task in incomplete_milestone_tasks]
logging.info(f"Found {len(incomplete_task_ids)} incomplete Entry Milestone Update tasks")
# Step 2: Get entry_records that reference these tasks
all_entry_results = []
# Query entry_records for each incomplete task ID with pagination
for task_id in incomplete_task_ids:
entry_query = {
"filter": {
"property": "mawb_task_reference",
"relation": {
"contains": task_id
}
},
"page_size": 100
}
# Handle pagination for each task's entry records
has_more = True
start_cursor = None
while has_more:
if start_cursor:
entry_query["start_cursor"] = start_cursor
entry_response = self._make_database_query("entry_record", entry_query)
entry_results = entry_response.get("results", [])
# Add task info to each entry record for context
for entry in entry_results:
entry["_incomplete_milestone_task_id"] = task_id
all_entry_results.extend(entry_results)
has_more = entry_response.get("has_more", False)
start_cursor = entry_response.get("next_cursor")
if start_cursor:
entry_query["start_cursor"] = start_cursor
else:
entry_query.pop("start_cursor", None)
# Remove duplicates (same entry might be linked to multiple incomplete tasks)
seen_entries = set()
unique_entries = []
for entry in all_entry_results:
entry_id = entry["id"]
if entry_id not in seen_entries:
seen_entries.add(entry_id)
unique_entries.append(entry)
logging.info(f"Found {len(unique_entries)} unique entry records with incomplete Entry Milestone Update tasks")
return unique_entries
except Exception as e:
logging.error(f"Error querying entry records with incomplete milestone tasks: {e}")
return []
def get_unique_mawbs_with_unchecked_milestones(self) -> str:
"""
Get unique MAWB numbers from entry records that have unchecked milestone tasks.
Returns:
Comma-separated string of unique MAWB numbers
"""
try:
# Get entry records with incomplete milestone tasks
entries_with_incomplete_tasks = self.get_entry_records_with_incomplete_milestone_tasks()
if not entries_with_incomplete_tasks:
logging.info("No entries found with unchecked milestone tasks")
return ""
# Extract unique MAWB numbers
unique_mawbs = set()
for entry in entries_with_incomplete_tasks:
properties = entry.get("properties", {})
# Extract MAWB from title property
if "mawb" in properties and properties["mawb"].get("title"):
mawb = properties["mawb"]["title"][0].get("text", {}).get("content", "")
if mawb.strip():
unique_mawbs.add(mawb.strip())
# Convert to sorted list for consistent output
mawb_list = sorted(list(unique_mawbs))
logging.info(f"Found {len(mawb_list)} unique MAWBs with unchecked milestone tasks")
# Return as comma-separated string
return ", ".join(mawb_list)
except Exception as e:
logging.error(f"Error getting unique MAWBs with unchecked milestones: {e}")
return ""
def create_entry_mawb_task_relation(self, entry_number: str, mawb_task_ids: List[str]) -> bool:
"""
Create relation between entry_record and mawb_task records using the new relation properties.
Args:
entry_number: Entry number to find the record
mawb_task_ids: List of mawb_task page IDs to relate
Returns:
True if successful, False otherwise
"""
import time
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
# Find the entry record with retry mechanism for newly created records
query_data = {
"filter": {
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
}
page_id = None
max_retries = 3
for attempt in range(max_retries):
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
if results:
page_id = results[0]["id"]
break
elif attempt < max_retries - 1:
# Wait briefly before retrying for newly created records
logging.info(f"Entry record {entry_number} not found on attempt {attempt + 1}, retrying...")
time.sleep(1)
else:
logging.error(f"Entry record not found after {max_retries} attempts: {entry_number}")
return False
# Create relation data for mawb_task_reference
mawb_task_relations = []
for task_id in mawb_task_ids:
mawb_task_relations.append({"id": task_id})
# Update the entry record with mawb_task relations
update_data = {
"properties": {
"mawb_task_reference": {
"relation": mawb_task_relations
}
}
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Created mawb_task relations for entry {entry_number}: {len(mawb_task_ids)} tasks")
return True
except Exception as e:
logging.error(f"Error creating entry-mawb_task relation: {e}")
return False
def create_entry_clearance_task_relation(self, entry_number: str, clearance_task_ids: List[str]) -> bool:
"""
Create relation between entry_record and mawb_task records for clearance tasks using clearance_notice_task_reference.
Args:
entry_number: Entry number to find the record
clearance_task_ids: List of clearance task page IDs to relate
Returns:
True if successful, False otherwise
"""
import time
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return False
# Find the entry record with retry mechanism for newly created records
query_data = {
"filter": {
"property": "entry_number",
"rich_text": {
"equals": entry_number
}
}
}
page_id = None
max_retries = 3
for attempt in range(max_retries):
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
if results:
page_id = results[0]["id"]
break
elif attempt < max_retries - 1:
# Wait briefly before retrying for newly created records
logging.info(f"Entry record {entry_number} not found on attempt {attempt + 1}, retrying...")
time.sleep(1)
else:
logging.error(f"Entry record not found after {max_retries} attempts: {entry_number}")
return False
# Create relation data for clearance_notice_task_reference
clearance_task_relations = []
for task_id in clearance_task_ids:
clearance_task_relations.append({"id": task_id})
# Update the entry record with clearance task relations
update_data = {
"properties": {
"clearance_notice_task_reference": {
"relation": clearance_task_relations
}
}
}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
logging.info(f"Created clearance_task relations for entry {entry_number}: {len(clearance_task_ids)} tasks")
return True
except Exception as e:
logging.error(f"Error creating entry-clearance_task relation: {e}")
return False
def get_mawb_tasks_by_action_and_mawb(self, mawb: str, action: str = "Entry Milestone Update") -> List[Dict]:
"""
Get mawb_task records by MAWB and action.
Args:
mawb: MAWB number
action: Task action to filter by
Returns:
List of mawb_task records
"""
try:
database_id = self.database_ids.get("mawb_task")
if not database_id:
logging.error("MAWB task database ID not configured")
return []
# Find MAWB record first to get its ID
mawb_record = self.find_mawb_record_by_number(mawb)
if not mawb_record:
logging.warning(f"MAWB record not found for: {mawb}")
return []
mawb_id = mawb_record["id"]
query_data = {
"filter": {
"and": [
{
"property": "mawb_reference",
"relation": {
"contains": mawb_id
}
},
{
"property": "action",
"rich_text": {
"equals": action
}
}
]
}
}
response = self._make_database_query("mawb_task", query_data)
results = response.get("results", [])
logging.info(f"Found {len(results)} mawb_task records for MAWB {mawb} with action {action}")
return results
except Exception as e:
logging.error(f"Error querying mawb_task records: {e}")
return []
def check_mawb_task_by_action(self, mawb: str, action: str) -> bool:
"""
Find and check (mark as done) unchecked mawb_task for specific MAWB and action.
Args:
mawb: MAWB number
action: Task action (e.g., "Entry Milestone Update", "MAWB Milestone Update")
Returns:
True if task was found and successfully checked, False otherwise
"""
try:
database_id = self.database_ids.get("mawb_task")
if not database_id:
logging.error("MAWB task database ID not configured")
return False
# Query for unchecked task with specific MAWB and action
query_data = {
"filter": {
"and": [
{
"property": "mawb",
"title": {"equals": mawb}
},
{
"property": "action",
"rich_text": {"equals": action}
},
{
"property": "done",
"checkbox": {"equals": False}
}
]
}
}
response = self._make_database_query("mawb_task", query_data)
if not response:
logging.warning(f"No response when querying tasks for MAWB {mawb} action {action}")
return False
results = response.get("results", [])
if not results:
logging.debug(f"No unchecked {action} task found for MAWB {mawb}")
return False
if len(results) > 1:
logging.warning(f"Multiple unchecked {action} tasks found for MAWB {mawb}, updating first one")
# Update the first matching task
task_id = results[0]["id"]
update_data = {
"properties": {
"done": {
"checkbox": True
}
}
}
update_response = self._make_request("PATCH", f"/pages/{task_id}", update_data)
if update_response and update_response.get("id"):
logging.info(f"Successfully checked {action} task for MAWB {mawb}")
return True
else:
logging.error(f"Failed to update {action} task for MAWB {mawb}")
return False
except Exception as e:
logging.error(f"Error checking {action} task for MAWB {mawb}: {e}")
return False
def check_mawb_expected_entry_count_match(self, mawb: str) -> bool:
"""
Check if MAWB record's expected_entry_count formula field equals "Match".
Args:
mawb: MAWB number
Returns:
True if expected_entry_count equals "Match", False otherwise
"""
try:
mawb_record = self.find_mawb_record_by_number(mawb)
if not mawb_record:
logging.warning(f"MAWB record not found for: {mawb}")
return False
properties = mawb_record.get("properties", {})
expected_count_prop = properties.get("expected_entry_count", {})
# Formula fields in Notion return different types based on the result
if "formula" in expected_count_prop:
formula_result = expected_count_prop["formula"]
# Check if it's a string result
if "string" in formula_result and formula_result["string"]:
result_value = formula_result["string"].strip()
is_match = result_value.lower() == "match"
logging.info(f"MAWB {mawb} expected_entry_count: '{result_value}' -> Match: {is_match}")
return is_match
# Check if it's a number result
elif "number" in formula_result:
logging.info(f"MAWB {mawb} expected_entry_count returned number: {formula_result['number']}")
return False
# Check if it's boolean result
elif "boolean" in formula_result:
logging.info(f"MAWB {mawb} expected_entry_count returned boolean: {formula_result['boolean']}")
return False
else:
logging.warning(f"MAWB {mawb} expected_entry_count formula returned unknown type: {formula_result}")
return False
else:
logging.warning(f"MAWB {mawb} expected_entry_count field is not a formula or is empty")
return False
except Exception as e:
logging.error(f"Error checking MAWB expected entry count match for {mawb}: {e}")
return False
def check_mawb_entry_submit_task_done(self, mawb: str) -> bool:
"""
Check if MAWB task with action "Entry Submit" has done field checked.
Args:
mawb: MAWB number
Returns:
True if Entry Submit task is marked as done, False otherwise
"""
try:
entry_submit_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Entry Submit")
if not entry_submit_tasks:
logging.info(f"No Entry Submit task found for MAWB {mawb}")
return False
# Check if any Entry Submit task is marked as done
for task in entry_submit_tasks:
properties = task.get("properties", {})
done_prop = properties.get("done", {})
if "checkbox" in done_prop:
is_done = done_prop["checkbox"]
if is_done:
logging.info(f"MAWB {mawb} Entry Submit task is marked as done")
return True
else:
logging.info(f"MAWB {mawb} Entry Submit task is not done")
else:
logging.warning(f"MAWB {mawb} Entry Submit task missing done checkbox field")
return False
except Exception as e:
logging.error(f"Error checking MAWB Entry Submit task status for {mawb}: {e}")
return False
def update_entry_submit_task_checkbox(self, mawb: str) -> bool:
"""
Update MAWB task with action "Entry Submit" to mark done field as checked.
Only updates if the checkbox is currently unchecked.
Args:
mawb: MAWB number
Returns:
True if task was updated or already done, False if error or not found
"""
try:
entry_submit_tasks = self.get_mawb_tasks_by_action_and_mawb(mawb, "Entry Submit")
if not entry_submit_tasks:
logging.warning(f"No Entry Submit task found for MAWB {mawb}")
return False
# Update all Entry Submit tasks that are not done
updated_count = 0
for task in entry_submit_tasks:
task_id = task.get("id")
properties = task.get("properties", {})
done_prop = properties.get("done", {})
if "checkbox" in done_prop:
is_done = done_prop["checkbox"]
if not is_done:
# Update the task to mark as done
update_data = {
"properties": {
"done": self._format_checkbox_property(True)
}
}
self._make_request("PATCH", f"/pages/{task_id}", update_data)
logging.info(f"Updated MAWB {mawb} Entry Submit task to done=true")
updated_count += 1
else:
logging.debug(f"MAWB {mawb} Entry Submit task already marked as done")
else:
logging.warning(f"MAWB {mawb} Entry Submit task missing done checkbox field")
return True
except Exception as e:
logging.error(f"Error updating MAWB Entry Submit task checkbox for {mawb}: {e}")
return False
def validate_mawb_ready_for_entry_submission(self, mawb: str) -> Dict[str, Any]:
"""
Validate if MAWB is ready for entry submission by checking:
1. expected_entry_count formula field equals "Match"
2. Entry Submit task is marked as done
Args:
mawb: MAWB number
Returns:
Dictionary with validation results:
{
'ready': bool,
'expected_count_match': bool,
'entry_submit_done': bool,
'mawb': str,
'validation_message': str
}
"""
try:
result = {
'ready': False,
'expected_count_match': False,
'entry_submit_done': False,
'mawb': mawb,
'validation_message': ''
}
# Check if MAWB record exists
mawb_record = self.find_mawb_record_by_number(mawb)
if not mawb_record:
result['validation_message'] = f"MAWB record not found: {mawb}"
logging.warning(result['validation_message'])
return result
# Check expected_entry_count match
result['expected_count_match'] = self.check_mawb_expected_entry_count_match(mawb)
# Check Entry Submit task completion
result['entry_submit_done'] = self.check_mawb_entry_submit_task_done(mawb)
# Determine if MAWB is ready
result['ready'] = result['expected_count_match'] and result['entry_submit_done']
# Create validation message
if result['ready']:
result['validation_message'] = f"MAWB {mawb} is ready for entry submission: expected count matches and Entry Submit task is complete"
logging.info(result['validation_message'])
else:
issues = []
if not result['expected_count_match']:
issues.append("expected entry count does not match")
if not result['entry_submit_done']:
issues.append("Entry Submit task is not completed")
result['validation_message'] = f"MAWB {mawb} is NOT ready for entry submission: {', '.join(issues)}"
logging.warning(result['validation_message'])
return result
except Exception as e:
error_msg = f"Error validating MAWB readiness for {mawb}: {e}"
logging.error(error_msg)
return {
'ready': False,
'expected_count_match': False,
'entry_submit_done': False,
'mawb': mawb,
'validation_message': error_msg
}
def get_mawbs_ready_for_entry_submission(self, mawb_list: List[str] = None, limit: int = None) -> List[Dict[str, Any]]:
"""
Get list of MAWBs that are ready for entry submission.
Args:
mawb_list: Optional list of specific MAWBs to check. If None, checks all MAWBs.
limit: Optional limit on number of MAWBs to check (for performance)
Returns:
List of validation results for MAWBs that are ready for submission
"""
try:
if mawb_list is None:
# Get all MAWB records
all_mawb_records = self.get_all_mawb_records()
mawb_list = []
for record in all_mawb_records:
properties = record.get("properties", {})
mawb_prop = properties.get("mawb", {})
if "title" in mawb_prop and mawb_prop["title"]:
mawb_number = mawb_prop["title"][0].get("text", {}).get("content", "").strip()
if mawb_number:
mawb_list.append(mawb_number)
# Apply limit if specified
if limit and len(mawb_list) > limit:
mawb_list = mawb_list[:limit]
logging.info(f"Limited check to first {limit} MAWBs for performance")
logging.info(f"Checking readiness for {len(mawb_list)} MAWBs from database")
else:
logging.info(f"Checking readiness for {len(mawb_list)} specified MAWBs")
ready_mawbs = []
total_checked = 0
for mawb in mawb_list:
total_checked += 1
validation_result = self.validate_mawb_ready_for_entry_submission(mawb)
if validation_result['ready']:
ready_mawbs.append(validation_result)
logging.info(f"Found {len(ready_mawbs)} MAWBs ready for entry submission out of {total_checked} checked")
return ready_mawbs
except Exception as e:
logging.error(f"Error getting MAWBs ready for entry submission: {e}")
return []
def get_mawbs_ready_for_entry_submission_batch(self, batch_size: int = 50, max_total: int = 200) -> List[Dict[str, Any]]:
"""
Optimized batch validation for large MAWB datasets with performance controls.
Args:
batch_size: Number of MAWBs to process in each batch
max_total: Maximum total MAWBs to check (prevents runaway processing)
Returns:
List of validation results for MAWBs that are ready for submission
"""
try:
logging.info(f"Starting batch MAWB validation (batch_size={batch_size}, max_total={max_total})")
# Get MAWB records with formula field pre-filtered if possible
all_mawb_records = self.get_all_mawb_records()
if not all_mawb_records:
logging.info("No MAWB records found")
return []
# Extract MAWB data for batch processing
mawb_data = []
for record in all_mawb_records:
try:
properties = record.get("properties", {})
mawb_prop = properties.get("mawb", {})
if "title" in mawb_prop and mawb_prop["title"]:
mawb_number = mawb_prop["title"][0].get("text", {}).get("content", "").strip()
if mawb_number:
# Pre-check expected_entry_count to avoid unnecessary task queries
expected_count_match = self._check_expected_count_from_record(record)
mawb_data.append({
'mawb': mawb_number,
'record': record,
'expected_count_match': expected_count_match
})
# Apply max_total limit
if len(mawb_data) >= max_total:
logging.info(f"Reached max_total limit of {max_total} MAWBs")
break
except Exception as e:
logging.warning(f"Error processing MAWB record: {e}")
continue
logging.info(f"Processing {len(mawb_data)} MAWBs in batches of {batch_size}")
# Process in batches
ready_mawbs = []
total_processed = 0
for i in range(0, len(mawb_data), batch_size):
batch = mawb_data[i:i + batch_size]
batch_number = (i // batch_size) + 1
total_batches = (len(mawb_data) + batch_size - 1) // batch_size
logging.info(f"Processing batch {batch_number}/{total_batches} ({len(batch)} MAWBs)")
# Process batch
batch_ready = self._process_mawb_validation_batch(batch)
ready_mawbs.extend(batch_ready)
total_processed += len(batch)
logging.info(f"Batch {batch_number} completed: {len(batch_ready)} ready out of {len(batch)}")
logging.info(f"Batch validation completed: {len(ready_mawbs)} ready MAWBs out of {total_processed} processed")
return ready_mawbs
except Exception as e:
logging.error(f"Error in batch MAWB validation: {e}")
return []
def _check_expected_count_from_record(self, mawb_record: Dict) -> bool:
"""
Check expected_entry_count from pre-loaded MAWB record (optimization).
Args:
mawb_record: Pre-loaded MAWB record from Notion
Returns:
True if expected_entry_count equals "Match", False otherwise
"""
try:
properties = mawb_record.get("properties", {})
expected_count_prop = properties.get("expected_entry_count", {})
if "formula" in expected_count_prop:
formula_result = expected_count_prop["formula"]
if "string" in formula_result and formula_result["string"]:
result_value = formula_result["string"].strip()
return result_value.lower() == "match"
return False
except Exception as e:
logging.warning(f"Error checking expected count from record: {e}")
return False
def _process_mawb_validation_batch(self, batch: List[Dict]) -> List[Dict[str, Any]]:
"""
Process a batch of MAWBs for validation.
Args:
batch: List of MAWB data dictionaries
Returns:
List of ready MAWBs from this batch
"""
ready_mawbs = []
try:
# First, filter by expected_count_match (already pre-checked)
candidates = [item for item in batch if item['expected_count_match']]
if not candidates:
logging.debug(f"No candidates with matching expected count in batch of {len(batch)}")
return ready_mawbs
logging.debug(f"Checking Entry Submit tasks for {len(candidates)} candidates out of {len(batch)} in batch")
# Check Entry Submit tasks for candidates only
for item in candidates:
try:
mawb = item['mawb']
# Check Entry Submit task completion
entry_submit_done = self.check_mawb_entry_submit_task_done(mawb)
if entry_submit_done:
# MAWB is ready for submission
ready_result = {
'ready': True,
'expected_count_match': True,
'entry_submit_done': True,
'mawb': mawb,
'validation_message': f"MAWB {mawb} is ready for entry submission: expected count matches and Entry Submit task is complete"
}
ready_mawbs.append(ready_result)
logging.debug(f"MAWB {mawb} is ready for submission")
else:
logging.debug(f"MAWB {mawb} has matching count but Entry Submit task not done")
except Exception as e:
logging.warning(f"Error validating MAWB {item.get('mawb', 'unknown')} in batch: {e}")
continue
return ready_mawbs
except Exception as e:
logging.error(f"Error processing validation batch: {e}")
return ready_mawbs
def get_entries_for_mawbs(self, mawb_list: List[str]) -> Dict[str, List[Dict]]:
"""
Get entries organized by MAWB for interactive editing.
Args:
mawb_list: List of MAWB numbers
Returns:
Dictionary mapping MAWB number to list of entry records with flattened data
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return {}
entries_by_mawb = {}
for mawb in mawb_list:
query_data = {
"filter": {
"property": "mawb",
"title": {
"equals": mawb.strip()
}
},
"page_size": 100
}
all_entries = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
# Flatten the entries for easier editing
for entry in results:
flattened = self._flatten_entry_for_editing(entry)
if flattened:
all_entries.append(flattened)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
entries_by_mawb[mawb.strip()] = all_entries
logging.info(f"Retrieved {len(all_entries)} entries for MAWB {mawb}")
return entries_by_mawb
except Exception as e:
logging.error(f"Error getting entries for MAWBs: {e}")
return {}
def _flatten_entry_for_editing(self, entry_record: Dict) -> Optional[Dict]:
"""
Flatten entry record for easier editing in DataFrame.
Args:
entry_record: Raw Notion entry record
Returns:
Flattened entry data or None if error
"""
try:
properties = entry_record.get("properties", {})
flattened = {
"id": entry_record.get("id", "") # Keep ID for updates
}
# Extract MAWB (title field)
if "mawb" in properties and properties["mawb"].get("title"):
flattened["mawb"] = properties["mawb"]["title"][0].get("text", {}).get("content", "")
else:
flattened["mawb"] = ""
# Extract Entry Number (rich_text field)
if "entry_number" in properties and properties["entry_number"].get("rich_text"):
flattened["entry_number"] = properties["entry_number"]["rich_text"][0].get("text", {}).get("content", "")
else:
flattened["entry_number"] = ""
# Extract date fields
date_fields = [
"entry_submit_date", "cbp_hold_date", "cbp_release_date",
"cpsc_review_date", "cpsc_release_date"
]
for field in date_fields:
if field in properties and properties[field].get("date"):
date_str = properties[field]["date"].get("start")
if date_str:
try:
# Convert to user-friendly format for Excel editing
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
# Format as MM/DD/YYYY HH:MM AM/PM (Excel-friendly)
flattened[field] = dt.strftime('%m/%d/%Y %I:%M %p')
except:
flattened[field] = date_str
else:
flattened[field] = ""
else:
flattened[field] = ""
# Extract text fields
text_fields = ["cbp_hold_line", "cpsc_review_line"]
for field in text_fields:
if field in properties and properties[field].get("rich_text"):
flattened[field] = properties[field]["rich_text"][0].get("text", {}).get("content", "")
else:
flattened[field] = ""
return flattened
except Exception as e:
logging.error(f"Error flattening entry record: {e}")
return None
def update_entries_from_dataframe(self, df) -> Dict[str, int]:
"""
Update or create entry records from DataFrame.
Args:
df: DataFrame with entry data (must include 'mawb' and 'entry_number' columns)
Returns:
Dictionary with statistics: {'updated': int, 'errors': int, 'total': int}
"""
stats = {'updated': 0, 'errors': 0, 'total': 0}
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return stats
# Process each row
for index, row in df.iterrows():
stats['total'] += 1
try:
# Validate required fields
mawb = str(row.get('mawb', '')).strip()
entry_number = str(row.get('entry_number', '')).strip()
if not mawb or not entry_number:
logging.warning(f"Row {index + 1}: Missing MAWB or Entry Number")
stats['errors'] += 1
continue
# Check if entry exists (use ID if available, otherwise search)
entry_id = None
if 'id' in row and str(row['id']).strip():
entry_id = str(row['id']).strip()
else:
# Search for existing entry
existing_entry = self._find_entry_by_mawb_and_number(mawb, entry_number)
if existing_entry:
entry_id = existing_entry.get("id")
# Prepare properties for update/create
properties = {}
# Handle date fields
date_fields = [
"entry_submit_date", "cbp_hold_date", "cbp_release_date",
"cpsc_review_date", "cpsc_release_date"
]
for field in date_fields:
if field in row and row[field] is not None:
date_val = row[field]
# Handle pandas NaT and invalid date strings
import pandas as pd
if pd.isna(date_val):
continue
date_str = str(date_val).strip()
if not date_str or date_str.lower() in ['nan', 'nat', 'none']:
continue
if hasattr(date_val, 'isoformat'):
# It's already a datetime object
properties[field] = self._format_datetime_property(date_val)
else:
# Try to parse the date string (support multiple formats)
try:
if date_str:
# Try different date formats
dt = None
date_formats = [
'%m/%d/%Y %I:%M %p', # MM/DD/YYYY HH:MM AM/PM (our export format)
'%m/%d/%Y %H:%M', # MM/DD/YYYY HH:MM (24-hour)
'%m/%d/%Y', # MM/DD/YYYY (date only)
'%Y-%m-%d %H:%M:%S', # YYYY-MM-DD HH:MM:SS
'%Y-%m-%dT%H:%M:%S', # ISO format without timezone
'%Y-%m-%d', # YYYY-MM-DD (date only)
]
for fmt in date_formats:
try:
dt = datetime.strptime(date_str, fmt)
break
except ValueError:
continue
if dt is None:
# Fallback to ISO parsing
dt = datetime.fromisoformat(date_str.replace('Z', ''))
properties[field] = self._format_datetime_property(dt)
except Exception as e:
logging.warning(f"Could not parse date '{date_val}' for field {field}: {e}")
# Handle text fields
text_fields = ["cbp_hold_line", "cpsc_review_line"]
for field in text_fields:
if field in row and row[field] is not None and str(row[field]).strip():
properties[field] = self._format_text_property(str(row[field]).strip())
# Update or create entry
if entry_id:
# Update existing entry
if properties: # Only update if there are changes
update_data = {"properties": properties}
self._make_request("PATCH", f"/pages/{entry_id}", update_data)
logging.info(f"Updated entry {mawb} - {entry_number}")
else:
logging.info(f"No changes for entry {mawb} - {entry_number}")
else:
# Create new entry
properties["mawb"] = self._format_title_property(mawb)
properties["entry_number"] = self._format_text_property(entry_number)
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("entry_record")
create_data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
response = self._make_request("POST", "/pages", create_data)
logging.info(f"Created new entry {mawb} - {entry_number}")
stats['updated'] += 1
except Exception as e:
logging.error(f"Error processing row {index + 1} ({mawb} - {entry_number}): {e}")
stats['errors'] += 1
continue
logging.info(f"Batch update completed: {stats['updated']} successful, {stats['errors']} errors out of {stats['total']} total")
return stats
except Exception as e:
logging.error(f"Error in batch update from DataFrame: {e}")
stats['errors'] = stats['total'] # Mark all as errors if batch fails
return stats
def _find_entry_by_mawb_and_number(self, mawb: str, entry_number: str) -> Optional[Dict]:
"""
Find entry record by MAWB and entry number.
Args:
mawb: MAWB number
entry_number: Entry number
Returns:
Entry record or None if not found
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
return None
# Search with compound filter
query_data = {
"filter": {
"and": [
{
"property": "mawb",
"title": {
"equals": mawb.strip()
}
},
{
"property": "entry_number",
"rich_text": {
"equals": entry_number.strip()
}
}
]
},
"page_size": 1
}
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
return results[0] if results else None
except Exception as e:
logging.error(f"Error finding entry by MAWB and number: {e}")
return None
def get_mawb_records_with_entry_count_data(self) -> List[Dict]:
"""
Get MAWB records with expected_entry_count data pre-loaded.
Returns records with embedded formula results to avoid re-fetching.
"""
try:
database_id = self.database_ids.get("mawb")
if not database_id:
logging.error("MAWB database ID not configured")
return []
# Fetch all records with pagination but include formula fields
query_data = {
"page_size": 100,
# Request specific properties to reduce payload size
"filter": {
"property": "mawb",
"title": {
"is_not_empty": True
}
}
}
# Use pagination efficiently
all_results = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("mawb", query_data)
results = response.get("results", [])
all_results.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_results)} MAWB records with entry count data")
return all_results
except Exception as e:
logging.error(f"Error getting MAWB records with entry count data: {e}")
return []
def get_entry_submit_tasks_batch(self, mawb_ids: List[str]) -> List[Dict]:
"""
Efficiently query Entry Submit tasks for multiple MAWBs in batches.
"""
try:
database_id = self.database_ids.get("mawb_task")
if not database_id:
logging.error("MAWB task database ID not configured")
return []
# Notion API has limitations on OR filters, so we may need to batch
BATCH_SIZE = 10 # Notion API limit for OR conditions
all_tasks = []
for i in range(0, len(mawb_ids), BATCH_SIZE):
batch_ids = mawb_ids[i:i + BATCH_SIZE]
# Create OR filter for this batch
or_conditions = []
for mawb_id in batch_ids:
or_conditions.append({
"property": "mawb_reference",
"relation": {"contains": mawb_id}
})
query_data = {
"filter": {
"and": [
{
"property": "action",
"rich_text": {"equals": "Entry Submit"}
},
{
"or": or_conditions
}
]
}
}
response = self._make_database_query("mawb_task", query_data)
batch_tasks = response.get("results", [])
all_tasks.extend(batch_tasks)
logging.info(f"Retrieved {len(all_tasks)} Entry Submit tasks for {len(mawb_ids)} MAWBs")
return all_tasks
except Exception as e:
logging.error(f"Error getting Entry Submit tasks batch: {e}")
return []
def update_entry_submit_task_checkbox_by_id(self, task_id: str) -> bool:
"""
Update Entry Submit task checkbox directly by task ID (no lookup required).
"""
try:
update_data = {
"properties": {
"done": {"checkbox": True}
}
}
response = self._make_request("PATCH", f"/pages/{task_id}", update_data)
return response is not None
except Exception as e:
logging.error(f"Error updating Entry Submit task {task_id}: {e}")
return False
def _check_expected_count_from_record(self, record: Dict) -> bool:
"""
Check if a MAWB record's expected_entry_count formula field equals "Match".
Uses the record data directly instead of making additional API calls.
"""
try:
properties = record.get("properties", {})
expected_count_prop = properties.get("expected_entry_count", {})
# Formula fields in Notion return different types based on the result
if "formula" in expected_count_prop:
formula_result = expected_count_prop["formula"]
# Check if it's a string result
if "string" in formula_result and formula_result["string"]:
result_value = formula_result["string"].strip()
return result_value.lower() == "match"
# Check if it's a number result
elif "number" in formula_result:
# If it's a number, it's not "Match"
return False
# Check if it's null/empty
else:
return False
return False
except Exception as e:
logging.error(f"Error checking expected count from record: {e}")
return False
def get_all_entry_records_paginated(self) -> List[Dict]:
"""
Get all entry records using pagination for large datasets.
Returns:
List of all entry records
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return []
all_records = []
query_data = {"page_size": 100}
has_more = True
next_cursor = None
while has_more:
if next_cursor:
query_data["start_cursor"] = next_cursor
else:
query_data.pop("start_cursor", None)
response = self._make_database_query("entry_record", query_data)
if not response:
break
results = response.get("results", [])
all_records.extend(results)
has_more = response.get("has_more", False)
next_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_records)} entry records total")
return all_records
except Exception as e:
logging.error(f"Error getting all entry records: {e}")
return []
def check_entries_exist_batch(self, entry_numbers: List[str]) -> Dict[str, bool]:
"""
Check if multiple entry numbers exist in batch for improved performance.
Args:
entry_numbers: List of entry numbers to check
Returns:
Dict mapping entry numbers to existence (True/False)
Raises:
Exception: If API calls fail, raises exception to prevent duplicate creation
"""
database_id = self.database_ids.get("entry_record")
if not database_id:
error_msg = "Entry record database ID not configured"
logging.error(error_msg)
raise ValueError(error_msg)
if not entry_numbers:
return {}
# Split large lists into smaller batches to avoid API limits
BATCH_SIZE = 50
existence_map = {}
for i in range(0, len(entry_numbers), BATCH_SIZE):
batch = entry_numbers[i:i + BATCH_SIZE]
# Build OR query for this batch
if len(batch) == 1:
query_filter = {
"property": "entry_number",
"rich_text": {"equals": batch[0].strip()}
}
else:
query_filter = {
"or": [
{
"property": "entry_number",
"rich_text": {"equals": entry.strip()}
}
for entry in batch
]
}
query_data = {
"filter": query_filter,
"page_size": 100
}
try:
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
# Mark found entries as existing
found_entries = set()
for result in results:
entry_rich_text = result.get("properties", {}).get("entry_number", {}).get("rich_text", [])
if entry_rich_text:
entry_number = entry_rich_text[0].get("text", {}).get("content", "").strip()
found_entries.add(entry_number)
# Update existence map for this batch
for entry in batch:
existence_map[entry.strip()] = entry.strip() in found_entries
except Exception as e:
# CRITICAL: Do not default to False on API failures
# This would cause duplicate entries to be created
error_msg = f"Failed to check entry existence for batch {i//BATCH_SIZE + 1}: {e}"
logging.error(error_msg)
logging.error(f"Batch entries: {batch}")
raise RuntimeError(error_msg) from e
logging.info(f"Batch existence check completed: {sum(existence_map.values())}/{len(entry_numbers)} entries exist")
return existence_map
def get_entries_by_numbers(self, entry_numbers: List[str]) -> List[Dict]:
"""
Get entry records by entry numbers using batched queries.
Args:
entry_numbers: List of entry numbers to retrieve
Returns:
List of entry records
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return []
if not entry_numbers:
return []
# Split large lists into smaller batches
BATCH_SIZE = 50
all_records = []
for i in range(0, len(entry_numbers), BATCH_SIZE):
batch = entry_numbers[i:i + BATCH_SIZE]
# Build OR query for this batch
if len(batch) == 1:
query_filter = {
"property": "entry_number",
"rich_text": {"equals": batch[0].strip()}
}
else:
query_filter = {
"or": [
{
"property": "entry_number",
"rich_text": {"equals": entry.strip()}
}
for entry in batch
]
}
query_data = {
"filter": query_filter,
"page_size": 100
}
response = self._make_database_query("entry_record", query_data)
if response:
results = response.get("results", [])
all_records.extend(results)
logging.info(f"Retrieved {len(all_records)} entry records by numbers")
return all_records
except Exception as e:
logging.error(f"Error getting entries by numbers: {e}")
return []
def update_entry_records_batch(self, updates: List[Dict]) -> Dict[str, int]:
"""
Update multiple entry records in batch for improved performance.
Args:
updates: List of update dictionaries containing:
- entry_number: Entry number to update
- updates: Dict of field updates
Returns:
Dict with statistics: {'updated': count, 'failed': count, 'total': count}
"""
import concurrent.futures
import time
stats = {'updated': 0, 'failed': 0, 'total': len(updates)}
if not updates:
logging.info("No records to update in batch")
return stats
logging.info(f"Starting batch update of {len(updates)} entry records...")
def update_single_record(update_data):
"""Update a single record - for concurrent execution."""
try:
entry_number = update_data.get('entry_number')
field_updates = update_data.get('updates', {})
if not entry_number:
logging.error(f"Missing entry number in update data: {update_data}")
return False
success = self.update_entry_record(entry_number, **field_updates)
if success:
logging.info(f"Batch updated {entry_number}: {list(field_updates.keys())}")
else:
logging.error(f"Failed to update {entry_number}")
return success
except Exception as e:
logging.error(f"Error in batch update for {update_data.get('entry_number', 'unknown')}: {e}")
return False
# Process updates concurrently (5 at a time to avoid overwhelming the API)
max_concurrent = 5
batch_size = max_concurrent
for i in range(0, len(updates), batch_size):
batch = updates[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (len(updates) + batch_size - 1) // batch_size
logging.info(f"Processing batch {batch_num}/{total_batches} ({len(batch)} records)...")
# Use ThreadPoolExecutor for concurrent API calls
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrent) as executor:
# Submit all tasks in this batch
futures = [executor.submit(update_single_record, update_data) for update_data in batch]
# Wait for all tasks to complete and collect results
for future in concurrent.futures.as_completed(futures):
try:
success = future.result(timeout=30) # 30 second timeout per update
if success:
stats['updated'] += 1
else:
stats['failed'] += 1
except concurrent.futures.TimeoutError:
logging.error("Update timed out after 30 seconds")
stats['failed'] += 1
except Exception as e:
logging.error(f"Unexpected error in batch update: {e}")
stats['failed'] += 1
# Small delay between batches to be respectful to the API
if i + batch_size < len(updates):
time.sleep(0.5)
logging.info(f"Batch update completed: {stats['updated']} updated, {stats['failed']} failed out of {stats['total']} total")
return stats
def get_milestone_records_bulk(self, mawb_list: List[str]) -> List[Dict]:
"""
Get milestone records for a list of MAWBs using batched queries to handle large lists.
Args:
mawb_list: List of MAWB numbers to query (empty list gets all records)
Returns:
List of milestone records for all requested MAWBs
"""
try:
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return []
# If empty list provided, get all records
if not mawb_list:
query_data = {"page_size": 100}
all_records = []
has_more = True
next_cursor = None
while has_more:
if next_cursor:
query_data["start_cursor"] = next_cursor
else:
query_data.pop("start_cursor", None)
response = self._make_database_query("mawb_milestone", query_data)
if not response:
logging.warning("No response for milestone query")
break
results = response.get("results", [])
all_records.extend(results)
has_more = response.get("has_more", False)
next_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_records)} milestone records (all records)")
return all_records
# Split large lists into smaller batches to avoid API limits
BATCH_SIZE = 50 # Reasonable batch size for OR queries
all_records = []
for i in range(0, len(mawb_list), BATCH_SIZE):
batch = mawb_list[i:i + BATCH_SIZE]
batch_num = i//BATCH_SIZE + 1
total_batches = (len(mawb_list) + BATCH_SIZE - 1)//BATCH_SIZE
logging.info(f"Processing milestone batch {batch_num}/{total_batches} ({len(batch)} MAWBs)...")
# Build query for this batch
if len(batch) == 1:
query_filter = {
"property": "mawb",
"title": {"equals": batch[0].strip()}
}
else:
query_filter = {
"or": [
{
"property": "mawb",
"title": {"equals": mawb.strip()}
}
for mawb in batch
]
}
query_data = {
"filter": query_filter,
"page_size": 100
}
# Handle pagination for this batch
has_more = True
next_cursor = None
while has_more:
if next_cursor:
query_data["start_cursor"] = next_cursor
else:
query_data.pop("start_cursor", None)
response = self._make_database_query("mawb_milestone", query_data)
if not response:
logging.warning(f"No response for milestone batch {i//BATCH_SIZE + 1}")
break
results = response.get("results", [])
all_records.extend(results)
has_more = response.get("has_more", False)
next_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_records)} milestone records for {len(mawb_list)} MAWBs using {(len(mawb_list) + BATCH_SIZE - 1)//BATCH_SIZE} batches")
return all_records
except Exception as e:
logging.error(f"Error getting milestone records bulk: {e}")
return []
def update_mawb_milestone_records_batch(self, updates: List[Dict]) -> Dict[str, int]:
"""
Update multiple MAWB milestone records in batch for improved performance.
Args:
updates: List of update dictionaries containing:
- mawb: MAWB number
- last_mile_carrier: Carrier name
- updates: Dict of field updates
Returns:
Dict with statistics: {'updated': count, 'failed': count, 'total': count}
"""
import concurrent.futures
import time
stats = {'updated': 0, 'failed': 0, 'total': len(updates)}
if not updates:
logging.info("No records to update in batch")
return stats
logging.info(f"Starting batch update of {len(updates)} milestone records...")
def update_single_record(update_data):
"""Update a single record - for concurrent execution."""
try:
mawb = update_data.get('mawb')
carrier = update_data.get('last_mile_carrier')
field_updates = update_data.get('updates', {})
if not mawb or not carrier:
logging.error(f"Missing MAWB or carrier in update data: {update_data}")
return False
success = self.update_mawb_milestone_record(mawb, carrier, **field_updates)
if success:
logging.info(f"Batch updated {mawb}-{carrier}: {list(field_updates.keys())}")
else:
logging.error(f"Failed to update {mawb}-{carrier}")
return success
except Exception as e:
logging.error(f"Error in batch update for {update_data.get('mawb', 'unknown')}: {e}")
return False
# Process updates concurrently (5 at a time to avoid overwhelming the API)
max_concurrent = 5
batch_size = max_concurrent
for i in range(0, len(updates), batch_size):
batch = updates[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (len(updates) + batch_size - 1) // batch_size
logging.info(f"Processing batch {batch_num}/{total_batches} ({len(batch)} records)...")
# Use ThreadPoolExecutor for concurrent API calls
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrent) as executor:
# Submit all tasks in this batch
futures = [executor.submit(update_single_record, update_data) for update_data in batch]
# Wait for all tasks to complete and collect results
for future in concurrent.futures.as_completed(futures):
try:
success = future.result(timeout=30) # 30 second timeout per update
if success:
stats['updated'] += 1
else:
stats['failed'] += 1
except concurrent.futures.TimeoutError:
logging.error("Update timed out after 30 seconds")
stats['failed'] += 1
except Exception as e:
logging.error(f"Unexpected error in batch update: {e}")
stats['failed'] += 1
# Small delay between batches to be respectful to the API
if i + batch_size < len(updates):
time.sleep(0.5)
logging.info(f"Batch update completed: {stats['updated']} updated, {stats['failed']} failed out of {stats['total']} total")
return stats
def check_entry_fields_batch(self, entry_field_pairs: List[Dict[str, str]]) -> Dict[str, Dict[str, bool]]:
"""
Check if specific fields are populated for multiple entries in batch for improved performance.
Args:
entry_field_pairs: List of dictionaries with 'entry_number' and 'field_name' keys
Example: [
{'entry_number': '12345', 'field_name': 'cpsc_review_date'},
{'entry_number': '12346', 'field_name': 'cpsc_release_date'}
]
Returns:
Dict mapping entry numbers to field status dictionaries
Example: {
'12345': {'cpsc_review_date': True},
'12346': {'cpsc_release_date': False}
}
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return {}
if not entry_field_pairs:
return {}
# Extract unique entry numbers for batch lookup
entry_numbers = list(set([pair['entry_number'] for pair in entry_field_pairs]))
# Get all entries in batch
entry_records = self.get_entries_by_numbers(entry_numbers)
# Create lookup map for quick access
entry_lookup = {}
for record in entry_records:
entry_title = record.get("properties", {}).get("entry_number", {}).get("title", [])
if entry_title:
entry_number = entry_title[0].get("text", {}).get("content", "").strip()
entry_lookup[entry_number] = record
# Build results by checking each requested field
results = {}
for pair in entry_field_pairs:
entry_number = pair['entry_number']
field_name = pair['field_name']
# Initialize entry result if not exists
if entry_number not in results:
results[entry_number] = {}
# Check if entry exists
if entry_number not in entry_lookup:
logging.warning(f"Entry {entry_number} not found")
results[entry_number][field_name] = False
continue
# Check if the field is populated
properties = entry_lookup[entry_number].get("properties", {})
field_data = properties.get(field_name, {})
# Handle different field types
is_populated = False
if "date" in field_data:
is_populated = field_data["date"] is not None
elif "rich_text" in field_data:
rich_text_list = field_data["rich_text"]
is_populated = len(rich_text_list) > 0 and rich_text_list[0].get("text", {}).get("content", "").strip() != ""
elif "number" in field_data:
is_populated = field_data["number"] is not None
elif "checkbox" in field_data:
is_populated = field_data["checkbox"] is not None
else:
logging.warning(f"Unknown field type for {field_name}")
is_populated = False
results[entry_number][field_name] = is_populated
logging.info(f"Batch field check completed for {len(entry_field_pairs)} entry-field pairs across {len(entry_numbers)} entries")
return results
except Exception as e:
logging.error(f"Error checking entry fields in batch: {e}")
return {}
def format_entry_number(self, entry_number: str) -> str:
"""
Format entry number by removing prefixes and formatting consistently.
Args:
entry_number: Raw entry number (e.g., "9G3-0000251-7")
Returns:
Formatted entry number (e.g., "00002517")
"""
try:
# Remove common prefixes and clean formatting
cleaned = entry_number.strip()
# Remove "9G3-" prefix if present
if cleaned.upper().startswith("9G3-"):
cleaned = cleaned[4:]
# Remove all dashes and spaces
cleaned = cleaned.replace("-", "").replace(" ", "")
# If it ends with a single digit (check digit), keep it
# Format: 00002517 (8 digits total)
if cleaned.isdigit():
return cleaned
return cleaned
except Exception as e:
logging.warning(f"Error formatting entry number '{entry_number}': {e}")
return entry_number.strip()
def format_master_bl(self, master_bl: str) -> str:
"""
Format Master B/L number consistently.
Args:
master_bl: Raw Master B/L number
Returns:
Formatted Master B/L number
"""
try:
# Basic cleaning - remove extra whitespace
cleaned = master_bl.strip()
# Add dash after first 3 characters if not already present
if len(cleaned) > 3 and cleaned[3] != '-':
cleaned = cleaned[:3] + '-' + cleaned[3:]
return cleaned
except Exception as e:
logging.warning(f"Error formatting Master B/L '{master_bl}': {e}")
return master_bl
def get_mawb_milestone_for_dwell_report(self, target_date: datetime = None) -> List[Dict]:
"""
Get MAWB milestone records for dwell report generation.
Main filter: warehouse_out IS NULL (cargo still in warehouse)
Args:
target_date: Date for reference (defaults to current date)
Returns:
List of milestone records where warehouse_out is null
"""
if target_date is None:
target_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
try:
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return []
# Query for records where warehouse_out is null (cargo still in warehouse)
query_data = {
"filter": {
"property": "warehouse_out",
"date": {
"is_empty": True
}
}
}
logging.info(f"Querying milestone records where warehouse_out IS NULL")
response = self._make_database_query("mawb_milestone", query_data)
if not response:
logging.warning("No response from milestone dwell report query")
return []
results = response.get("results", [])
logging.info(f"Retrieved {len(results)} milestone records for dwell report (warehouse_out IS NULL)")
return results
except Exception as e:
logging.error(f"Error getting milestone records for dwell report: {e}")
return []
def _get_all_milestone_records(self) -> List[Dict]:
"""
Get ALL milestone records without any date filtering.
Returns:
List of all milestone records
"""
try:
database_id = self.database_ids.get("mawb_milestone")
if not database_id:
logging.error("MAWB milestone database ID not configured")
return []
# Query with pagination to retrieve all records (no filters)
query_data: Dict[str, Any] = {"page_size": 100}
all_results: List[Dict] = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("mawb_milestone", query_data)
if not response:
logging.warning("No response from milestone database query")
break
results = response.get("results", [])
all_results.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_results)} total milestone records (paginated)")
return all_results
except Exception as e:
logging.error(f"Error getting all milestone records: {e}")
return []
def get_rollup_property_value(self, page_id: str, property_name: str) -> Dict:
"""
Retrieve rollup property value from a page.
Args:
page_id: The ID of the page
property_name: Name of the rollup property
Returns:
Dictionary containing rollup property data
"""
try:
# Note: property_name here should be the property ID from the page's properties dict
endpoint = f"/pages/{page_id}/properties/{property_name}"
response = self._make_request("GET", endpoint)
if response:
logging.debug(f"Retrieved rollup property {property_name} for page {page_id}")
return response
else:
logging.warning(f"No response for rollup property {property_name} on page {page_id}")
return {}
except Exception as e:
logging.error(f"Error retrieving rollup property {property_name} for page {page_id}: {e}")
return {}
def _extract_rollup_checkbox(self, prop_response: Dict) -> Optional[bool]:
"""Extract a boolean checkbox value from a rollup property response."""
try:
if not prop_response:
return None
# New Notion API may return either a top-level rollup or paginated results
if isinstance(prop_response, dict) and prop_response.get('type') == 'rollup':
rollup = prop_response.get('rollup', {})
if rollup.get('type') == 'array':
for item in rollup.get('array', []):
if item.get('type') == 'checkbox':
val = item.get('checkbox')
if isinstance(val, bool):
return val
if item.get('type') == 'formula':
formula = item.get('formula', {})
if formula.get('type') == 'boolean':
return bool(formula.get('boolean'))
elif rollup.get('type') == 'number':
# Treat any positive number as True
num = rollup.get('number')
return bool(num) if num is not None else None
if isinstance(prop_response, dict) and 'results' in prop_response:
# Paginated property results
for item in prop_response.get('results', []):
if item.get('type') == 'checkbox':
val = item.get('checkbox')
if isinstance(val, bool):
return val
if item.get('type') == 'formula':
formula = item.get('formula', {})
if formula.get('type') == 'boolean':
return bool(formula.get('boolean'))
return None
except Exception:
return None
def create_dwell_report_record(self, date: datetime, pod: str, last_mile_carrier: str,
volume: int, pallets_remaining: int = 0, volume_remaining_on_dock: int = 0, comments: str = "") -> bool:
"""
Create a dwell report record in Notion.
Args:
date: Report date
pod: Port of discharge
last_mile_carrier: Last mile carrier/product
volume: Total volume (sum of parcels)
pallets_remaining: Number of pallets remaining on dock
volume_remaining_on_dock: Volume remaining on dock (sum of parcels where cargo ready but warehouse out is blank)
comments: Comment about clearance status
Returns:
True if successful, False otherwise
"""
try:
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("dwell_report")
# Dwell report Date should be current date at 09:00 US/Pacific
# Build a timezone-aware datetime at 9 AM Pacific for the provided date
pacific = ZoneInfo("US/Pacific")
date_pacific_9am = datetime(
year=date.year,
month=date.month,
day=date.day,
hour=9,
minute=0,
second=0,
tzinfo=pacific,
)
properties = {
"Date": self._format_datetime_property(date_pacific_9am),
"Broker Name": self._format_title_property("APEX"),
"Port": self._format_text_property(pod),
"LM Carrier/Product": self._format_text_property(last_mile_carrier),
"Volume": self._format_number_property(volume),
"# of Pallets Remaining on Dock": self._format_number_property(pallets_remaining),
"Volume Remaining On Dock": self._format_number_property(volume_remaining_on_dock),
"Comments": self._format_text_property(comments)
}
data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
result = self._make_request("POST", "/pages", data)
if result:
logging.info(f"Created dwell report record: {pod}-{last_mile_carrier} on {date.strftime('%Y-%m-%d')}")
return True
else:
logging.error(f"Failed to create dwell report record: {pod}-{last_mile_carrier}")
return False
except Exception as e:
logging.error(f"Error creating dwell report record: {e}")
return False
def generate_daily_dwell_report(self, target_date: datetime = None) -> Dict[str, int]:
"""
Generate daily dwell report by aggregating MAWB milestone data.
Args:
target_date: Date to generate report for (defaults to current date)
Returns:
Dictionary with generation statistics
"""
if target_date is None:
target_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
try:
logging.info(f"Generating dwell report for {target_date.strftime('%Y-%m-%d')}")
# Get ALL milestone records (no date filtering at query level)
# We'll apply different filters for different calculations
all_milestone_records = self._get_all_milestone_records()
if not all_milestone_records:
logging.info("No milestone records found")
return {"processed": 0, "created": 0, "errors": 0}
# Group by POD and last_mile_carrier
grouped_data = {}
# Step 1: Process records for "remaining" calculations
# Filter: cargo_ready IS NOT NULL AND warehouse_out IS NULL
remaining_records = []
for record in all_milestone_records:
properties = record.get("properties", {})
cargo_ready_prop = properties.get("cargo_ready", {})
cargo_ready_date = cargo_ready_prop.get("date", {}).get("start") if cargo_ready_prop.get("date") else None
warehouse_out_prop = properties.get("warehouse_out", {})
warehouse_out_date = warehouse_out_prop.get("date", {}).get("start") if warehouse_out_prop.get("date") else None
if cargo_ready_date is not None and warehouse_out_date is None:
remaining_records.append(record)
# Step 2: Process records for "volume" calculations
# New logic: Sum parcels for all records where
# warehouse_out IS NULL AND ETA is before today (strictly < target_date)
volume_records = []
for record in all_milestone_records:
properties = record.get("properties", {})
warehouse_out_prop = properties.get("warehouse_out", {})
warehouse_out_date = warehouse_out_prop.get("date", {}).get("start") if warehouse_out_prop.get("date") else None
eta_prop = properties.get("eta", {})
eta_date_str = eta_prop.get("date", {}).get("start") if eta_prop.get("date") else None
if warehouse_out_date is None and eta_date_str:
try:
eta_date = datetime.fromisoformat(eta_date_str.replace('Z', '+00:00')).replace(tzinfo=None)
eta_date = eta_date.replace(hour=0, minute=0, second=0, microsecond=0)
if eta_date < target_date:
volume_records.append(record)
except Exception as e:
logging.warning(f"Error parsing ETA date {eta_date_str}: {e}")
# Process remaining records for grouping
for record in remaining_records:
properties = record.get("properties", {})
# Extract POD and last mile carrier
pod_prop = properties.get("pod", {})
pod = ""
if pod_prop.get("select"):
pod = pod_prop["select"].get("name", "")
elif pod_prop.get("rich_text"):
pod = pod_prop["rich_text"][0].get("text", {}).get("content", "") if pod_prop["rich_text"] else ""
lm_carrier_prop = properties.get("last_mile_carrier", {})
lm_carrier = ""
if lm_carrier_prop.get("select"):
lm_carrier = lm_carrier_prop["select"].get("name", "")
elif lm_carrier_prop.get("rich_text"):
lm_carrier = lm_carrier_prop["rich_text"][0].get("text", {}).get("content", "") if lm_carrier_prop["rich_text"] else ""
# Skip records without POD or last mile carrier
if not pod or not lm_carrier:
continue
# Create group key
group_key = f"{pod}_{lm_carrier}"
if group_key not in grouped_data:
grouped_data[group_key] = {
"pod": pod,
"last_mile_carrier": lm_carrier,
"volume": 0,
"pallets_remaining": 0,
"volume_remaining_on_dock": 0,
"customs_released_pallets": 0,
"customs_not_released_pallets": 0
}
# Add pallets remaining on dock
pallets_prop = properties.get("pallets", {})
if pallets_prop.get("number") is not None:
grouped_data[group_key]["pallets_remaining"] += pallets_prop["number"]
# Add volume remaining on dock (parcels)
parcels_prop = properties.get("parcel", {})
if parcels_prop.get("number") is None:
parcels_prop = properties.get("parcels", {})
if parcels_prop.get("number") is not None:
grouped_data[group_key]["volume_remaining_on_dock"] += parcels_prop["number"]
# Defer customs released/not released calculation to clearance rollup stage
# Process volume records for grouping
for record in volume_records:
properties = record.get("properties", {})
# Extract POD and last mile carrier
pod_prop = properties.get("pod", {})
pod = ""
if pod_prop.get("select"):
pod = pod_prop["select"].get("name", "")
elif pod_prop.get("rich_text"):
pod = pod_prop["rich_text"][0].get("text", {}).get("content", "") if pod_prop["rich_text"] else ""
lm_carrier_prop = properties.get("last_mile_carrier", {})
lm_carrier = ""
if lm_carrier_prop.get("select"):
lm_carrier = lm_carrier_prop["select"].get("name", "")
elif lm_carrier_prop.get("rich_text"):
lm_carrier = lm_carrier_prop["rich_text"][0].get("text", {}).get("content", "") if lm_carrier_prop["rich_text"] else ""
# Skip records without POD or last mile carrier
if not pod or not lm_carrier:
continue
# Create group key
group_key = f"{pod}_{lm_carrier}"
if group_key not in grouped_data:
grouped_data[group_key] = {
"pod": pod,
"last_mile_carrier": lm_carrier,
"volume": 0,
"pallets_remaining": 0,
"volume_remaining_on_dock": 0,
"customs_released_pallets": 0,
"customs_not_released_pallets": 0
}
# Add volume (parcels)
parcels_prop = properties.get("parcel", {})
if parcels_prop.get("number") is None:
parcels_prop = properties.get("parcels", {})
if parcels_prop.get("number") is not None:
grouped_data[group_key]["volume"] += parcels_prop["number"]
# Calculate customs released/not released pallets for comments using clearance rollup
# Get all records with warehouse_out IS NULL to calculate totals per group
all_warehouse_records = []
for record in all_milestone_records:
properties = record.get("properties", {})
warehouse_out_prop = properties.get("warehouse_out", {})
warehouse_out_date = warehouse_out_prop.get("date", {}).get("start") if warehouse_out_prop.get("date") else None
if warehouse_out_date is None:
all_warehouse_records.append(record)
# Build page_id -> clearance status map using rollup property
clearance_status_map = {}
for record in all_warehouse_records:
page_id = record.get("id")
props = record.get("properties", {})
rollup_meta = props.get("clearance_notice_task_status", {})
prop_id = rollup_meta.get("id")
if page_id and prop_id:
try:
resp = self.get_rollup_property_value(page_id, prop_id)
is_cleared = self._extract_rollup_checkbox(resp)
if is_cleared is not None:
clearance_status_map[page_id] = is_cleared
except Exception:
pass
# Aggregate pallets by clearance status per group
for record in all_warehouse_records:
properties = record.get("properties", {})
# Extract POD and last mile carrier
pod_prop = properties.get("pod", {})
pod = ""
if pod_prop.get("select"):
pod = pod_prop["select"].get("name", "")
elif pod_prop.get("rich_text"):
pod = pod_prop["rich_text"][0].get("text", {}).get("content", "") if pod_prop["rich_text"] else ""
lm_carrier_prop = properties.get("last_mile_carrier", {})
lm_carrier = ""
if lm_carrier_prop.get("select"):
lm_carrier = lm_carrier_prop["select"].get("name", "")
elif lm_carrier_prop.get("rich_text"):
lm_carrier = lm_carrier_prop["rich_text"][0].get("text", {}).get("content", "") if lm_carrier_prop["rich_text"] else ""
if not pod or not lm_carrier:
continue
group_key = f"{pod}_{lm_carrier}"
if group_key not in grouped_data:
# Initialize if not present (e.g., group had no remaining_records but has volume records)
grouped_data[group_key] = {
"pod": pod,
"last_mile_carrier": lm_carrier,
"volume": 0,
"pallets_remaining": 0,
"volume_remaining_on_dock": 0,
"customs_released_pallets": 0,
"customs_not_released_pallets": 0
}
pallets_prop = properties.get("pallets", {})
pallets_count = pallets_prop.get("number", 0) if pallets_prop.get("number") is not None else 0
is_cleared = clearance_status_map.get(record.get("id"))
if is_cleared is True:
grouped_data[group_key]["customs_released_pallets"] += pallets_count
elif is_cleared is False:
grouped_data[group_key]["customs_not_released_pallets"] += pallets_count
# Create dwell report records
created_count = 0
error_count = 0
for group_key, group_data in grouped_data.items():
# Generate comments
released_pallets = group_data["customs_released_pallets"]
not_released_pallets = group_data["customs_not_released_pallets"]
comments = f"{released_pallets} pallets customs released, {not_released_pallets} pallets not released yet"
# Create the dwell report record
success = self.create_dwell_report_record(
date=target_date,
pod=group_data["pod"],
last_mile_carrier=group_data["last_mile_carrier"],
volume=group_data["volume"],
pallets_remaining=group_data["pallets_remaining"],
volume_remaining_on_dock=group_data["volume_remaining_on_dock"],
comments=comments
)
if success:
created_count += 1
else:
error_count += 1
total_processed = len(remaining_records) + len(volume_records)
logging.info(f"Dwell report generation completed: {created_count} created, {error_count} errors")
return {
"processed": total_processed,
"created": created_count,
"errors": error_count
}
except Exception as e:
logging.error(f"Error generating daily dwell report: {e}")
return {"processed": 0, "created": 0, "errors": 1}
def get_all_dwell_report_records_paginated(self, page_size: int = 100) -> List[Dict]:
"""
Get all dwell report records from Notion database with pagination.
Args:
page_size: Number of records per page
Returns:
List of all dwell report records
"""
try:
database_id = self.database_ids.get("dwell_report")
if not database_id:
logging.error("Dwell report database ID not configured")
return []
query_data = {"page_size": page_size}
all_results = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("dwell_report", query_data)
results = response.get("results", [])
all_results.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_results)} total dwell report records")
return all_results
except Exception as e:
logging.error(f"Error getting all dwell report records: {e}")
return []
def expand_env_variables(value):
"""Recursively expand environment variables in string values like ${VAR_NAME}"""
if isinstance(value, dict):
return {key: expand_env_variables(val) for key, val in value.items()}
elif isinstance(value, list):
return [expand_env_variables(item) for item in value]
elif isinstance(value, str):
# Replace ${VAR_NAME} with environment variable value
pattern = r'\$\{([^}]+)\}'
matches = re.findall(pattern, value)
for match in matches:
env_value = os.getenv(match)
if env_value:
value = value.replace(f'${{{match}}}', env_value)
else:
logging.warning(f"Environment variable {match} not found")
return value
def get_all_entry_records_paginated(self, page_size: int = 100) -> List[Dict]:
"""
Get all entry records from Notion database with pagination.
Args:
page_size: Number of records per page
Returns:
List of all entry records
"""
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return []
query_data = {"page_size": page_size}
all_results = []
has_more = True
start_cursor = None
while has_more:
if start_cursor:
query_data["start_cursor"] = start_cursor
response = self._make_database_query("entry_record", query_data)
results = response.get("results", [])
all_results.extend(results)
has_more = response.get("has_more", False)
start_cursor = response.get("next_cursor")
logging.info(f"Retrieved {len(all_results)} total entry records")
return all_results
except Exception as e:
logging.error(f"Error getting all entry records: {e}")
return []
def format_entry_number(self, entry_number: str) -> str:
"""
Format entry number by removing prefixes and formatting consistently.
Args:
entry_number: Raw entry number (e.g., "9G3-0000251-7")
Returns:
Formatted entry number (e.g., "00002517")
"""
try:
# Remove common prefixes and clean formatting
cleaned = entry_number.strip()
# Remove "9G3-" prefix if present
if cleaned.upper().startswith("9G3-"):
cleaned = cleaned[4:]
# Remove all dashes and spaces
cleaned = cleaned.replace("-", "").replace(" ", "")
# If it ends with a single digit (check digit), keep it
# Format: 00002517 (8 digits total)
if cleaned.isdigit():
return cleaned
return cleaned
except Exception as e:
logging.warning(f"Error formatting entry number '{entry_number}': {e}")
return entry_number.strip()
def format_master_bl(self, master_bl: str) -> str:
"""
Format Master B/L number consistently.
Args:
master_bl: Raw Master B/L number
Returns:
Formatted Master B/L number
"""
try:
# Basic cleaning - remove extra whitespace
return master_bl.strip()
except Exception as e:
logging.warning(f"Error formatting Master B/L '{master_bl}': {e}")
return master_bl
def create_entry_records_batch(self, entry_data_list: List[Dict]) -> Dict[str, int]:
"""
Create multiple entry records in batch.
Args:
entry_data_list: List of entry data dictionaries with required fields
Returns:
Dictionary with statistics: {'created': int, 'errors': int, 'total': int}
"""
stats = {'created': 0, 'errors': 0, 'total': 0}
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return stats
# Pre-fetch MAWB records for linking
mawb_numbers = list(set([entry.get('mawb', '') for entry in entry_data_list if entry.get('mawb')]))
mawb_lookup = {}
for mawb_num in mawb_numbers:
mawb_record = self.find_mawb_record_by_number(mawb_num)
if mawb_record:
mawb_lookup[mawb_num] = mawb_record
# Pre-fetch milestone tasks for auto-linking
milestone_tasks_lookup = {}
for mawb_num in mawb_numbers:
tasks = self.get_mawb_tasks_by_action_and_mawb(mawb_num, "Entry Milestone Update")
if tasks:
milestone_tasks_lookup[mawb_num] = [task["id"] for task in tasks]
# Process each entry
for entry_data in entry_data_list:
stats['total'] += 1
try:
mawb = entry_data.get('mawb', '').strip()
entry_number = entry_data.get('entry_number', '').strip()
if not mawb or not entry_number:
logging.warning(f"Missing required fields for entry: {entry_data}")
stats['errors'] += 1
continue
# Build properties
properties = {
"mawb": self._format_title_property(mawb),
"entry_number": self._format_text_property(entry_number)
}
# Add MAWB relation if available
if mawb in mawb_lookup:
properties["mawb_reference"] = {
"relation": [{"id": mawb_lookup[mawb]["id"]}]
}
# Add optional date fields
date_fields = [
'entry_submit_date', 'entry_created', 'entry_date', 'cbp_hold_date',
'cbp_release_date', 'cpsc_review_date', 'cpsc_release_date',
'estimated_review_date'
]
for field in date_fields:
if field in entry_data and entry_data[field]:
if isinstance(entry_data[field], datetime):
properties[field] = self._format_datetime_property(entry_data[field])
elif isinstance(entry_data[field], str):
try:
dt = datetime.fromisoformat(entry_data[field].replace('Z', ''))
properties[field] = self._format_datetime_property(dt)
except:
logging.warning(f"Could not parse date '{entry_data[field]}' for field {field}")
# Add optional text fields
text_fields = ['cbp_hold_line', 'cpsc_review_line']
for field in text_fields:
if field in entry_data and entry_data[field]:
properties[field] = self._format_text_property(str(entry_data[field]))
# Get the actual data source ID for page creation
data_source_id = self._get_data_source_id("entry_record")
# Create the entry
create_data = {
"parent": {
"type": "data_source_id",
"data_source_id": data_source_id
},
"properties": properties
}
response = self._make_request("POST", "/pages", create_data)
entry_id = response.get("id")
# Auto-link to milestone tasks
if mawb in milestone_tasks_lookup and milestone_tasks_lookup[mawb]:
try:
self.create_entry_mawb_task_relation(entry_number, milestone_tasks_lookup[mawb])
except Exception as link_error:
logging.warning(f"Failed to auto-link milestone tasks for {entry_number}: {link_error}")
stats['created'] += 1
logging.info(f"Created entry record: {mawb} - {entry_number}")
except Exception as e:
logging.error(f"Error creating entry record: {e}")
stats['errors'] += 1
continue
logging.info(f"Batch entry creation completed: {stats['created']} created, {stats['errors']} errors out of {stats['total']} total")
return stats
except Exception as e:
logging.error(f"Error in batch entry creation: {e}")
stats['errors'] = stats['total']
return stats
def update_entry_records_batch(self, updates: List[Dict]) -> Dict[str, int]:
"""
Update multiple entry records in batch.
Args:
updates: List of update dictionaries with 'entry_number' and field updates
Returns:
Dictionary with statistics: {'updated': int, 'errors': int, 'total': int}
"""
stats = {'updated': 0, 'errors': 0, 'total': 0}
try:
database_id = self.database_ids.get("entry_record")
if not database_id:
logging.error("Entry record database ID not configured")
return stats
# Get entry numbers to update
entry_numbers = [update.get('entry_number', '') for update in updates if update.get('entry_number')]
# Get existing entries
existing_entries = self.get_entries_by_numbers(entry_numbers)
# Process each update
for update in updates:
stats['total'] += 1
try:
entry_number = update.get('entry_number', '').strip()
if not entry_number:
logging.warning("Missing entry_number in update")
stats['errors'] += 1
continue
if entry_number not in existing_entries:
logging.warning(f"Entry record not found for update: {entry_number}")
stats['errors'] += 1
continue
entry_record = existing_entries[entry_number]
page_id = entry_record["id"]
# Build update properties
properties = {}
# Handle date fields
date_fields = [
'entry_submit_date', 'entry_created', 'entry_date', 'cbp_hold_date',
'cbp_release_date', 'cpsc_review_date', 'cpsc_release_date',
'estimated_review_date'
]
for field in date_fields:
if field in update and update[field] is not None:
if isinstance(update[field], datetime):
properties[field] = self._format_datetime_property(update[field])
elif isinstance(update[field], str) and update[field].strip():
try:
dt = datetime.fromisoformat(update[field].replace('Z', ''))
properties[field] = self._format_datetime_property(dt)
except:
logging.warning(f"Could not parse date '{update[field]}' for field {field}")
# Handle text fields
text_fields = ['cbp_hold_line', 'cpsc_review_line']
for field in text_fields:
if field in update and update[field] is not None:
properties[field] = self._format_text_property(str(update[field]))
# Only update if there are properties to update
if properties:
update_data = {"properties": properties}
self._make_request("PATCH", f"/pages/{page_id}", update_data)
stats['updated'] += 1
logging.info(f"Updated entry record: {entry_number}")
else:
logging.info(f"No changes for entry record: {entry_number}")
except Exception as e:
logging.error(f"Error updating entry record: {e}")
stats['errors'] += 1
continue
logging.info(f"Batch entry update completed: {stats['updated']} updated, {stats['errors']} errors out of {stats['total']} total")
return stats
except Exception as e:
logging.error(f"Error in batch entry update: {e}")
stats['errors'] = stats['total']
return stats
def load_notion_config() -> Dict:
"""Load Notion configuration from customer_config.json with environment variable expansion"""
try:
with open('config/customer_config.json', 'r', encoding='utf-8') as f:
config = json.load(f)
notion_settings = config.get("notion_settings", {})
# Expand environment variables in all string values
for key, value in notion_settings.items():
notion_settings[key] = expand_env_variables(value)
return notion_settings
except Exception as e:
logging.error(f"Error loading Notion config: {e}")
return {}
# Example usage
if __name__ == "__main__":
# Load configuration
notion_config = load_notion_config()
if not notion_config.get("api_key") or not notion_config.get("database_ids"):
print("Please configure Notion API key and database IDs in config/customer_config.json")
exit(1)
# Initialize Notion integration
notion = NotionIntegration(
api_key=notion_config["api_key"],
database_ids=notion_config["database_ids"]
)
# Example operations
mawb = "TEST123456"
customer = "SHEIN"
# Check if MAWB exists
if not notion.check_mawb_exists(mawb, customer):
# Create MAWB record
notion.create_mawb_record(
mawb=mawb,
customer=customer,
entry_type="T86",
pod="LAX",
manifest_received=datetime.now()
)
# Create task record
notion.create_mawb_task_record(mawb=mawb)
# Create entry records
notion.create_entry_record(mawb=mawb, entry_number="E001")
notion.create_entry_record(mawb=mawb, entry_number="E002")
# Update MAWB status
notion.update_mawb_status(mawb, customer, awb_received=datetime.now())
# Entry counts are now handled automatically by Notion rollup properties
print(f"Notion integration test completed for MAWB: {mawb}")