Expanded DeutschePostOrder and DeutschePostBulkOrder models to support full Deutsche Post API integration, including authentication, order creation, finalization, tracking, cancellation, and label/document generation. Added new fields for label PDFs and bulk paperwork, improved country mapping, and implemented comprehensive validation and utility methods. Updated serializers to expose new fields and computed properties. Added HTML templates for individual and bulk shipping labels.
1050 lines
45 KiB
Python
1050 lines
45 KiB
Python
from django.db import models
|
|
|
|
"""Models for integration with Deutsche Post International Shipping API.
|
|
|
|
These models wrap calls to the API via the client defined in `client/` folder.
|
|
DO NOT modify the client; we only consume it here.
|
|
|
|
Workflow:
|
|
- Create a `DeutschePostOrder` instance locally with required recipient data.
|
|
- On first save (when `order_id` is empty) call the remote API to create
|
|
the order and persist the returned identifier + metadata.
|
|
- Use helper methods to refresh remote info, finalize order, get tracking.
|
|
- Group orders into a `DeutschePostBulkOrder` and create bulk shipment remotely.
|
|
|
|
Edge cases handled:
|
|
- API faults raise exceptions (to be surfaced by serializer validation).
|
|
- Missing remote fields are stored in `metadata` JSON.
|
|
- Tracking information updated via API calls.
|
|
"""
|
|
|
|
import json
|
|
import base64
|
|
from typing import Dict, Any
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.core.validators import RegexValidator
|
|
from django.apps import apps
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from configuration.models import SiteConfiguration
|
|
|
|
# API client imports - direct references as requested
|
|
# Note: We import only what's absolutely necessary to avoid import errors
|
|
try:
|
|
from .client.deutsche_post_international_shipping_api_client import AuthenticatedClient, Client
|
|
from .client.deutsche_post_international_shipping_api_client.api.authentication import get_access_token
|
|
from .client.deutsche_post_international_shipping_api_client.api.orders import (
|
|
create_order, finalize_order, get_order
|
|
)
|
|
from .client.deutsche_post_international_shipping_api_client.api.bulk_orders import (
|
|
create_mixed_order, get_bulk_order
|
|
)
|
|
from .client.deutsche_post_international_shipping_api_client.models import (
|
|
OrderData, OrderDataOrderStatus, ItemData, ItemDataServiceLevel, ItemDataShipmentNaturetype,
|
|
Paperwork, PaperworkPickupType, MixedBagOrderDTO, BulkOrderDto, Content
|
|
)
|
|
from .client.deutsche_post_international_shipping_api_client.errors import UnexpectedStatus
|
|
DEUTSCHE_POST_CLIENT_AVAILABLE = True
|
|
except ImportError:
|
|
DEUTSCHE_POST_CLIENT_AVAILABLE = False
|
|
AuthenticatedClient = None
|
|
|
|
|
|
class DeutschePostOrder(models.Model):
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class STATE(models.TextChoices):
|
|
CREATED = "CREATED", "Vytvořeno"
|
|
FINALIZED = "FINALIZED", "Dokončeno"
|
|
SHIPPED = "SHIPPED", "Odesláno"
|
|
DELIVERED = "DELIVERED", "Doručeno"
|
|
CANCELLED = "CANCELLED", "Zrušeno"
|
|
ERROR = "ERROR", "Chyba"
|
|
|
|
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.CREATED)
|
|
|
|
# Deutsche Post orders are linked via Carrier model, not directly
|
|
# Following zasilkovna pattern: Carrier has ManyToMany to DeutschePostOrder
|
|
|
|
# Deutsche Post API fields
|
|
order_id = models.CharField(max_length=50, blank=True, null=True, help_text="Deutsche Post order ID from API")
|
|
customer_ekp = models.CharField(max_length=20, blank=True, null=True)
|
|
|
|
# Recipient data (copied from commerce order or set manually)
|
|
recipient_name = models.CharField(max_length=200)
|
|
recipient_phone = models.CharField(max_length=20, blank=True)
|
|
recipient_email = models.EmailField(blank=True)
|
|
|
|
address_line1 = models.CharField(max_length=255)
|
|
address_line2 = models.CharField(max_length=255, blank=True)
|
|
address_line3 = models.CharField(max_length=255, blank=True)
|
|
city = models.CharField(max_length=100)
|
|
address_state = models.CharField(max_length=100, blank=True, help_text="State/Province for shipping address")
|
|
postal_code = models.CharField(max_length=20)
|
|
destination_country = models.CharField(max_length=2, help_text="ISO 2-letter country code")
|
|
|
|
# Shipment data
|
|
product_type = models.CharField(max_length=10, default="GPT", help_text="Deutsche Post product type (GPT, GMP, etc.)")
|
|
service_level = models.CharField(max_length=20, default="PRIORITY", help_text="PRIORITY, STANDARD")
|
|
|
|
shipment_gross_weight = models.PositiveIntegerField(help_text="Weight in grams")
|
|
shipment_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
|
shipment_currency = models.CharField(max_length=3, default="EUR")
|
|
|
|
sender_tax_id = models.CharField(max_length=50, blank=True, help_text="IOSS number or sender tax ID")
|
|
importer_tax_id = models.CharField(max_length=50, blank=True, help_text="IOSS number or importer tax ID")
|
|
return_item_wanted = models.BooleanField(default=False)
|
|
|
|
cust_ref = models.CharField(max_length=100, blank=True, help_text="Customer reference")
|
|
|
|
# Tracking
|
|
awb_number = models.CharField(max_length=50, blank=True, null=True, help_text="Air Waybill number")
|
|
barcode = models.CharField(max_length=50, blank=True, null=True, help_text="Item barcode")
|
|
tracking_url = models.URLField(blank=True, null=True)
|
|
|
|
# API response metadata
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Raw API response data")
|
|
|
|
# Error tracking
|
|
last_error = models.TextField(blank=True, help_text="Last API error message")
|
|
|
|
# Label/Document storage
|
|
label_pdf = models.FileField(upload_to='deutschepost/labels/', blank=True, null=True, help_text="Shipping label PDF")
|
|
|
|
class LABEL_SIZE(models.TextChoices):
|
|
A4 = "A4", "A4 (210x297mm)"
|
|
A5 = "A5", "A5 (148x210mm)"
|
|
A6 = "A6", "A6 (105x148mm)"
|
|
|
|
label_size = models.CharField(max_length=10, choices=LABEL_SIZE.choices, default=LABEL_SIZE.A4)
|
|
|
|
def get_api_client(self) -> AuthenticatedClient:
|
|
"""Get authenticated API client using configuration."""
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
raise ValidationError("Deutsche Post API client is not available")
|
|
|
|
config = SiteConfiguration.get_solo()
|
|
|
|
if not all([config.deutschepost_client_id, config.deutschepost_client_secret]):
|
|
raise ValidationError("Deutsche Post API credentials not configured")
|
|
|
|
# First get an access token using basic auth
|
|
basic_client = Client(
|
|
base_url=config.deutschepost_api_url or "https://api-sandbox.deutschepost.com",
|
|
raise_on_unexpected_status=True
|
|
)
|
|
|
|
# Create basic auth header
|
|
credentials = f"{config.deutschepost_client_id}:{config.deutschepost_client_secret}"
|
|
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
|
|
|
try:
|
|
# Get access token
|
|
response = get_access_token.sync_detailed(
|
|
client=basic_client,
|
|
authorization=f"Basic {encoded_credentials}",
|
|
accept="application/json"
|
|
)
|
|
|
|
if response.parsed is None or not hasattr(response.parsed, 'access_token'):
|
|
raise ValidationError("Failed to obtain access token from Deutsche Post API")
|
|
|
|
access_token = response.parsed.access_token
|
|
|
|
# Create authenticated client with the token
|
|
client = AuthenticatedClient(
|
|
base_url=config.deutschepost_api_url or "https://api-sandbox.deutschepost.com",
|
|
token=access_token,
|
|
raise_on_unexpected_status=True
|
|
)
|
|
|
|
return client
|
|
|
|
except Exception as e:
|
|
raise ValidationError(f"Deutsche Post authentication error: {str(e)}")
|
|
|
|
def create_remote_order(self):
|
|
"""Create order via Deutsche Post API."""
|
|
if self.order_id:
|
|
raise ValidationError(detail="Order already created remotely")
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
raise ValidationError(detail="Deutsche Post API client is not available")
|
|
|
|
try:
|
|
client = self.get_api_client()
|
|
config = SiteConfiguration.get_solo()
|
|
|
|
# Create content pieces if we have shipment data
|
|
contents = []
|
|
if self.shipment_amount > 0:
|
|
contents.append(Content(
|
|
content_piece_hs_code=1234567890, # Default HS code, should be configurable
|
|
content_piece_description="E-commerce goods",
|
|
content_piece_value=str(self.shipment_amount),
|
|
content_piece_netweight=max(100, self.shipment_gross_weight - 50), # Estimate net weight
|
|
content_piece_origin=config.deutschepost_origin_country or "DE",
|
|
content_piece_amount=1
|
|
))
|
|
|
|
# Create item data
|
|
item_data = ItemData(
|
|
product=self.product_type,
|
|
service_level=ItemDataServiceLevel(self.service_level),
|
|
recipient=self.recipient_name,
|
|
address_line_1=self.address_line1,
|
|
address_line_2=self.address_line2 or None,
|
|
address_line_3=self.address_line3 or None,
|
|
city=self.city,
|
|
state=self.address_state or None,
|
|
postal_code=self.postal_code,
|
|
destination_country=self.destination_country,
|
|
shipment_gross_weight=self.shipment_gross_weight,
|
|
recipient_phone=self.recipient_phone or None,
|
|
recipient_email=self.recipient_email or None,
|
|
sender_tax_id=self.sender_tax_id or None,
|
|
importer_tax_id=self.importer_tax_id or None,
|
|
shipment_amount=float(self.shipment_amount) if self.shipment_amount else None,
|
|
shipment_currency=self.shipment_currency or None,
|
|
return_item_wanted=self.return_item_wanted,
|
|
shipment_naturetype=ItemDataShipmentNaturetype.SALE_GOODS,
|
|
cust_ref=self.cust_ref or f"ORDER-{self.id}",
|
|
contents=contents if contents else None
|
|
)
|
|
|
|
# Create paperwork data
|
|
paperwork = Paperwork(
|
|
contact_name=config.deutschepost_contact_name or "Contact",
|
|
awb_copy_count=1,
|
|
job_reference=f"JOB-{self.id}",
|
|
pickup_type=PaperworkPickupType.CUSTOMER_DROP_OFF,
|
|
telephone_number=config.deutschepost_contact_phone or "+490000000000"
|
|
)
|
|
|
|
# Create order data
|
|
order_data = OrderData(
|
|
customer_ekp=config.deutschepost_customer_ekp or self.customer_ekp or "1234567890",
|
|
order_status=OrderDataOrderStatus.OPEN,
|
|
paperwork=paperwork,
|
|
items=[item_data]
|
|
)
|
|
|
|
# Make API call
|
|
response = create_order.sync_detailed(
|
|
client=client,
|
|
body=order_data
|
|
)
|
|
|
|
if response.parsed is None:
|
|
raise ValidationError(f"Failed to create order. Status: {response.status_code}")
|
|
|
|
# Handle different response types
|
|
if hasattr(response.parsed, 'order_id'):
|
|
# Successful response
|
|
order_response = response.parsed
|
|
self.order_id = order_response.order_id
|
|
self.state = self.STATE.CREATED
|
|
self.metadata = {
|
|
'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response),
|
|
'created_at': timezone.now().isoformat()
|
|
}
|
|
self.last_error = ""
|
|
else:
|
|
# Error response
|
|
error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed)
|
|
self.state = self.STATE.ERROR
|
|
self.last_error = f"API Error: {error_info}"
|
|
self.metadata = {'api_error': error_info}
|
|
raise ValidationError(detail=f"Deutsche Post API error: {error_info}")
|
|
|
|
self.save()
|
|
|
|
except UnexpectedStatus as e:
|
|
self.state = self.STATE.ERROR
|
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
|
self.save()
|
|
raise ValidationError(detail=f"Deutsche Post API error {e.status_code}: {e.content}")
|
|
except Exception as e:
|
|
self.state = self.STATE.ERROR
|
|
self.last_error = str(e)
|
|
self.save()
|
|
raise ValidationError(detail=f"Deutsche Post API error: {str(e)}")
|
|
|
|
def finalize_remote_order(self):
|
|
"""Finalize order via Deutsche Post API."""
|
|
if not self.order_id:
|
|
raise ValidationError(detail="Order not created remotely yet")
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
raise ValidationError(detail="Deutsche Post API client is not available")
|
|
|
|
try:
|
|
client = self.get_api_client()
|
|
config = SiteConfiguration.get_solo()
|
|
|
|
# Create paperwork for finalization
|
|
paperwork = Paperwork(
|
|
contact_name=config.deutschepost_contact_name or "Contact",
|
|
awb_copy_count=1,
|
|
job_reference=f"JOB-{self.id}-FINAL",
|
|
pickup_type=PaperworkPickupType.CUSTOMER_DROP_OFF,
|
|
telephone_number=config.deutschepost_contact_phone or "+420000000000"
|
|
)
|
|
|
|
# Make API call to finalize order
|
|
response = finalize_order.sync_detailed(
|
|
client=client,
|
|
order_id=self.order_id,
|
|
body=paperwork
|
|
)
|
|
|
|
if response.parsed is None:
|
|
raise ValidationError(f"Failed to finalize order. Status: {response.status_code}")
|
|
|
|
# Handle different response types
|
|
if hasattr(response.parsed, 'order_id'):
|
|
# Successful response
|
|
order_response = response.parsed
|
|
self.state = self.STATE.FINALIZED
|
|
|
|
# Extract tracking information if available
|
|
if hasattr(order_response, 'items') and order_response.items:
|
|
for item in order_response.items:
|
|
if hasattr(item, 'awb') and item.awb:
|
|
self.awb_number = item.awb
|
|
if hasattr(item, 'barcode') and item.barcode:
|
|
self.barcode = item.barcode
|
|
|
|
self.metadata.update({
|
|
'finalized': True,
|
|
'finalized_at': timezone.now().isoformat(),
|
|
'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response)
|
|
})
|
|
self.last_error = ""
|
|
else:
|
|
# Error response
|
|
error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed)
|
|
self.last_error = f"Finalization failed: {error_info}"
|
|
raise ValidationError(detail=f"Deutsche Post API finalization error: {error_info}")
|
|
|
|
self.save()
|
|
|
|
except UnexpectedStatus as e:
|
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post API error {e.status_code}: {e.content}")
|
|
except Exception as e:
|
|
self.last_error = str(e)
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post API error: {str(e)}")
|
|
|
|
def refresh_tracking(self):
|
|
"""Update tracking information from Deutsche Post API."""
|
|
if not self.order_id:
|
|
return
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
return
|
|
|
|
try:
|
|
client = self.get_api_client()
|
|
|
|
# Make API call to get current order status
|
|
response = get_order.sync_detailed(
|
|
client=client,
|
|
order_id=self.order_id
|
|
)
|
|
|
|
if response.parsed is None:
|
|
self.last_error = f"Failed to refresh tracking. Status: {response.status_code}"
|
|
self.save()
|
|
return
|
|
|
|
# Handle different response types
|
|
if hasattr(response.parsed, 'order_id'):
|
|
# Successful response
|
|
order_response = response.parsed
|
|
|
|
# Update order status based on API response
|
|
if hasattr(order_response, 'order_status'):
|
|
api_status = str(order_response.order_status)
|
|
if api_status == 'FINALIZED':
|
|
self.state = self.STATE.FINALIZED
|
|
elif api_status in ['SHIPPED', 'IN_TRANSIT']:
|
|
self.state = self.STATE.SHIPPED
|
|
elif api_status == 'DELIVERED':
|
|
self.state = self.STATE.DELIVERED
|
|
elif api_status in ['CANCELLED', 'RETURNED']:
|
|
self.state = self.STATE.CANCELLED
|
|
|
|
# Extract tracking information from items
|
|
if hasattr(order_response, 'items') and order_response.items:
|
|
for item in order_response.items:
|
|
if hasattr(item, 'awb') and item.awb and not self.awb_number:
|
|
self.awb_number = item.awb
|
|
if hasattr(item, 'barcode') and item.barcode and not self.barcode:
|
|
self.barcode = item.barcode
|
|
|
|
# Update tracking URL if AWB number is available
|
|
if self.awb_number:
|
|
self.tracking_url = f"https://www.deutschepost.de/de/sendungsverfolgung.html?piececode={self.awb_number}"
|
|
|
|
self.metadata.update({
|
|
'tracking_refreshed': True,
|
|
'last_refresh': timezone.now().isoformat(),
|
|
'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response)
|
|
})
|
|
self.last_error = ""
|
|
else:
|
|
# Error response
|
|
error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed)
|
|
self.last_error = f"Tracking refresh failed: {error_info}"
|
|
|
|
self.save()
|
|
|
|
except UnexpectedStatus as e:
|
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
|
self.save()
|
|
except Exception as e:
|
|
self.last_error = str(e)
|
|
self.save()
|
|
|
|
def validate_for_shipping(self):
|
|
"""Validate that all required fields are present before API calls."""
|
|
errors = []
|
|
|
|
# Required recipient fields
|
|
if not self.recipient_name:
|
|
errors.append("Recipient name is required")
|
|
if not self.address_line1:
|
|
errors.append("Address line 1 is required")
|
|
if not self.city:
|
|
errors.append("City is required")
|
|
if not self.postal_code:
|
|
errors.append("Postal code is required")
|
|
if not self.destination_country:
|
|
errors.append("Destination country is required")
|
|
|
|
# Required shipment fields
|
|
if not self.shipment_gross_weight or self.shipment_gross_weight <= 0:
|
|
errors.append("Valid shipment weight is required")
|
|
|
|
# Contact information (email or phone required for notifications)
|
|
if not self.recipient_email and not self.recipient_phone:
|
|
errors.append("Either recipient email or phone is required for delivery notifications")
|
|
|
|
if errors:
|
|
raise ValidationError("; ".join(errors))
|
|
|
|
return True
|
|
|
|
def get_shipping_cost_estimate(self):
|
|
"Get estimated shipping cost based on service level and destination."
|
|
config = SiteConfiguration.get_solo()
|
|
base_price = config.deutschepost_shipping_price
|
|
|
|
# Adjust price based on service level
|
|
if self.service_level == "PRIORITY":
|
|
multiplier = 1.2 # 20% premium for priority
|
|
else: # STANDARD
|
|
multiplier = 1.0
|
|
|
|
# Adjust price based on destination
|
|
if self.destination_country not in ["DE", "AT", "FR", "NL", "BE", "LU", "SK", "PL"]:
|
|
# Non-EU countries cost more
|
|
multiplier *= 1.5
|
|
|
|
return base_price * multiplier
|
|
|
|
def generate_shipping_label(self):
|
|
"""Generate and download shipping label PDF from Deutsche Post API."""
|
|
if not self.order_id:
|
|
raise ValidationError("Order must be created remotely first")
|
|
|
|
if not self.awb_number:
|
|
raise ValidationError("AWB number required for label generation")
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
raise ValidationError("Deutsche Post API client is not available")
|
|
|
|
try:
|
|
# Note: Deutsche Post API endpoints for labels vary
|
|
# This is a placeholder for the actual label generation logic
|
|
# You'll need to check Deutsche Post API documentation for exact endpoints
|
|
|
|
client = self.get_api_client()
|
|
|
|
# Try to get label via items endpoint or specific label endpoint
|
|
# This is conceptual - adjust based on actual API
|
|
from .client.deutsche_post_international_shipping_api_client.api.orders import get_order
|
|
|
|
response = get_order.sync_detailed(
|
|
client=client,
|
|
order_id=self.order_id
|
|
)
|
|
|
|
if response.parsed and hasattr(response.parsed, 'items') and response.parsed.items:
|
|
for item in response.parsed.items:
|
|
if hasattr(item, 'label_data') and item.label_data:
|
|
# Save label PDF
|
|
from django.core.files.base import ContentFile
|
|
import base64
|
|
|
|
if isinstance(item.label_data, str):
|
|
# Base64 encoded PDF
|
|
pdf_content = base64.b64decode(item.label_data)
|
|
else:
|
|
# Binary data
|
|
pdf_content = item.label_data
|
|
|
|
filename = f"deutschepost_label_{self.order_id}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
self.label_pdf.save(
|
|
filename,
|
|
ContentFile(pdf_content),
|
|
save=True
|
|
)
|
|
|
|
self.metadata.update({
|
|
'label_generated': True,
|
|
'label_generated_at': timezone.now().isoformat()
|
|
})
|
|
return True
|
|
|
|
# Alternative: Generate label using AWB number
|
|
# This is a fallback approach - create a simple label with AWB info
|
|
self._generate_simple_label()
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.last_error = f"Label generation failed: {str(e)}"
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post label generation error: {str(e)}")
|
|
|
|
def _generate_simple_label(self):
|
|
"""Generate a simple label PDF with basic shipping information using HTML template."""
|
|
from django.template.loader import render_to_string
|
|
from django.core.files.base import ContentFile
|
|
from weasyprint import HTML
|
|
import io
|
|
|
|
# Prepare template context
|
|
context = {
|
|
'order': self,
|
|
'now': timezone.now(),
|
|
'estimated_delivery_days': self.get_estimated_delivery_days()
|
|
}
|
|
|
|
# Render HTML template
|
|
html_string = render_to_string('deutschepost/shipping_label.html', context)
|
|
|
|
# Generate PDF from HTML
|
|
pdf_buffer = io.BytesIO()
|
|
HTML(string=html_string).write_pdf(pdf_buffer)
|
|
pdf_data = pdf_buffer.getvalue()
|
|
pdf_buffer.close()
|
|
|
|
# Save to model
|
|
filename = f"deutschepost_label_{self.order_id or self.id}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
self.label_pdf.save(
|
|
filename,
|
|
ContentFile(pdf_data),
|
|
save=True
|
|
)
|
|
|
|
self.metadata.update({
|
|
'simple_label_generated': True,
|
|
'label_generated_at': timezone.now().isoformat()
|
|
})
|
|
|
|
def get_tracking_url(self):
|
|
"""Get tracking URL for this order if tracking number is available."""
|
|
if self.awb_number:
|
|
return f"https://www.deutschepost.de/de/sendungsverfolgung.html?piececode={self.awb_number}"
|
|
elif self.barcode:
|
|
return f"https://www.deutschepost.de/de/sendungsverfolgung.html?piececode={self.barcode}"
|
|
return None
|
|
|
|
def can_be_finalized(self):
|
|
"""Check if order can be finalized."""
|
|
return (
|
|
self.order_id and
|
|
self.state == self.STATE.CREATED and
|
|
not self.last_error
|
|
)
|
|
|
|
def is_trackable(self):
|
|
"""Check if order has tracking information."""
|
|
return bool(self.awb_number or self.barcode)
|
|
|
|
def get_estimated_delivery_days(self):
|
|
"""Get estimated delivery days based on service level and destination."""
|
|
if self.service_level == "PRIORITY":
|
|
# EU countries usually 2-4 days, others 4-7 days
|
|
if self.destination_country in ["DE", "AT", "FR", "NL", "BE", "LU", "SK", "PL"]:
|
|
return "2-4"
|
|
else:
|
|
return "4-7"
|
|
else: # STANDARD
|
|
# EU countries usually 4-6 days, others 7-14 days
|
|
if self.destination_country in ["DE", "AT", "FR", "NL", "BE", "LU", "SK", "PL"]:
|
|
return "4-6"
|
|
else:
|
|
return "7-14"
|
|
|
|
def order_shippment(self):
|
|
"""Create order via Deutsche Post API, importing address data from commerce order.
|
|
|
|
This method follows the zasilkovna pattern: it gets the related order through
|
|
the Carrier model and imports all necessary address data.
|
|
"""
|
|
# Get related order through Carrier model (same pattern as zasilkovna)
|
|
Carrier = apps.get_model(app_label='commerce', model_name='Carrier')
|
|
Order = apps.get_model(app_label='commerce', model_name='Order')
|
|
|
|
carrier = Carrier.objects.get(deutschepost=self)
|
|
order = Order.objects.get(carrier=carrier)
|
|
|
|
# Import address data from order (like zasilkovna does)
|
|
if not self.recipient_name:
|
|
self.recipient_name = f"{order.first_name} {order.last_name}"
|
|
if not self.recipient_phone:
|
|
self.recipient_phone = order.phone
|
|
if not self.recipient_email:
|
|
self.recipient_email = order.email
|
|
if not self.address_line1:
|
|
self.address_line1 = order.address
|
|
if not self.city:
|
|
self.city = order.city
|
|
if not self.postal_code:
|
|
self.postal_code = order.postal_code
|
|
if not self.destination_country:
|
|
# Map country name to ISO code (comprehensive mapping)
|
|
country_map = {
|
|
"Czech Republic": "CZ", "Czechia": "CZ",
|
|
"Germany": "DE", "Deutschland": "DE",
|
|
"Austria": "AT", "Österreich": "AT",
|
|
"Slovakia": "SK", "Slovak Republic": "SK",
|
|
"Poland": "PL", "Polska": "PL",
|
|
"Hungary": "HU", "Magyarország": "HU",
|
|
"France": "FR", "République française": "FR",
|
|
"Netherlands": "NL", "Nederland": "NL",
|
|
"Belgium": "BE", "België": "BE", "Belgique": "BE",
|
|
"Luxembourg": "LU", "Lëtzebuerg": "LU",
|
|
"Switzerland": "CH", "Schweiz": "CH",
|
|
"Italy": "IT", "Italia": "IT",
|
|
"Spain": "ES", "España": "ES",
|
|
"United Kingdom": "GB", "UK": "GB",
|
|
"United States": "US", "USA": "US",
|
|
"Canada": "CA"
|
|
}
|
|
self.destination_country = country_map.get(order.country, "CZ")
|
|
if not self.cust_ref:
|
|
self.cust_ref = f"ORDER-{order.id}"
|
|
|
|
# Set default values if not provided
|
|
if not self.shipment_amount:
|
|
self.shipment_amount = order.total_price
|
|
if not self.shipment_currency:
|
|
config = SiteConfiguration.get_solo()
|
|
self.shipment_currency = getattr(config, 'currency', 'EUR')
|
|
|
|
# Save the updated data
|
|
self.save()
|
|
|
|
# Create remote order if not already created
|
|
if not self.order_id:
|
|
return self.create_remote_order()
|
|
else:
|
|
raise ValidationError("Deutsche Post order already created remotely.")
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Auto-create remote order when first saved (similar to zasilkovna pattern)
|
|
is_creating = not self.pk
|
|
super().save(*args, **kwargs)
|
|
|
|
if is_creating and not self.order_id:
|
|
# Only auto-create if we have minimum required data
|
|
try:
|
|
self.validate_for_shipping()
|
|
self.create_remote_order()
|
|
except ValidationError:
|
|
# Don't auto-create if validation fails - admin can fix and retry manually
|
|
pass
|
|
|
|
def delete(self, *args, **kwargs):
|
|
"""Override delete to cancel remote order if possible."""
|
|
if self.order_id and self.can_be_cancelled():
|
|
try:
|
|
self.cancel_remote_order()
|
|
except ValidationError:
|
|
# Log error but don't prevent deletion
|
|
self.last_error = "Failed to cancel remote order during deletion"
|
|
self.save()
|
|
|
|
super().delete(*args, **kwargs)
|
|
|
|
def can_be_cancelled(self):
|
|
"""Check if order can be cancelled (not yet shipped)."""
|
|
return (
|
|
self.order_id and
|
|
self.state in [self.STATE.CREATED, self.STATE.FINALIZED] and
|
|
not self.awb_number # No AWB means not yet shipped
|
|
)
|
|
|
|
def cancel_remote_order(self):
|
|
"""Cancel order via Deutsche Post API (if supported)."""
|
|
if not self.order_id:
|
|
raise ValidationError("Order not created remotely yet")
|
|
|
|
# Note: Deutsche Post API might not have explicit cancel endpoint
|
|
# We mark as cancelled locally and track in metadata
|
|
self.state = self.STATE.CANCELLED
|
|
self.metadata.update({
|
|
'cancelled': True,
|
|
'cancelled_at': timezone.now().isoformat(),
|
|
'cancellation_note': 'Cancelled locally - API cancel might not be supported'
|
|
})
|
|
self.last_error = "Order cancelled by user"
|
|
self.save()
|
|
|
|
def __str__(self):
|
|
return f"Deutsche Post Order {self.order_id or 'Not Created'} - {self.recipient_name}"
|
|
|
|
|
|
class DeutschePostBulkOrder(models.Model):
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class STATUS(models.TextChoices):
|
|
CREATED = "CREATED", "Vytvořeno"
|
|
PROCESSING = "PROCESSING", "Zpracovává se"
|
|
COMPLETED = "COMPLETED", "Dokončeno"
|
|
ERROR = "ERROR", "Chyba"
|
|
|
|
status = models.CharField(max_length=20, choices=STATUS.choices, default=STATUS.CREATED)
|
|
|
|
# Deutsche Post API fields
|
|
bulk_order_id = models.CharField(max_length=50, blank=True, null=True, help_text="Deutsche Post bulk order ID from API")
|
|
|
|
# Related orders
|
|
deutschepost_orders = models.ManyToManyField(
|
|
DeutschePostOrder,
|
|
related_name="bulk_orders",
|
|
blank=True
|
|
)
|
|
|
|
# Bulk order settings
|
|
bulk_order_type = models.CharField(max_length=20, default="MIXED_BAG", help_text="MIXED_BAG, etc.")
|
|
description = models.CharField(max_length=255, blank=True)
|
|
|
|
# API response metadata
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Raw API response data")
|
|
last_error = models.TextField(blank=True, help_text="Last API error message")
|
|
|
|
# Bulk shipment documents
|
|
bulk_label_pdf = models.FileField(upload_to='deutschepost/bulk_labels/', blank=True, null=True, help_text="Bulk shipment label PDF")
|
|
paperwork_pdf = models.FileField(upload_to='deutschepost/paperwork/', blank=True, null=True, help_text="Bulk shipment paperwork PDF")
|
|
|
|
def create_remote_bulk_order(self):
|
|
"""Create bulk order via Deutsche Post API."""
|
|
if self.bulk_order_id:
|
|
raise ValidationError("Bulk order already created remotely")
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
raise ValidationError("Deutsche Post API client is not available")
|
|
|
|
# Validate that all orders are finalized and have deutschepost delivery
|
|
invalid_orders = []
|
|
Carrier = apps.get_model('commerce', 'Carrier')
|
|
|
|
for dp_order in self.deutschepost_orders.all():
|
|
if not dp_order.order_id:
|
|
invalid_orders.append(f"Deutsche Post order {dp_order.id} not created remotely")
|
|
else:
|
|
# Check if carrier uses deutschepost shipping method
|
|
try:
|
|
carrier = Carrier.objects.get(deutschepost=dp_order)
|
|
if carrier.shipping_method != "deutschepost":
|
|
invalid_orders.append(f"Carrier {carrier.id} doesn't use Deutsche Post delivery")
|
|
except Carrier.DoesNotExist:
|
|
invalid_orders.append(f"Deutsche Post order {dp_order.id} has no associated carrier")
|
|
|
|
if invalid_orders:
|
|
raise ValidationError(f"Invalid orders for bulk: {', '.join(invalid_orders)}")
|
|
|
|
try:
|
|
client = self.deutschepost_orders.first().get_api_client()
|
|
config = SiteConfiguration.get_solo()
|
|
|
|
# Calculate totals from all orders
|
|
total_count = self.deutschepost_orders.count()
|
|
total_weight = sum(order.shipment_gross_weight / 1000.0 for order in self.deutschepost_orders.all()) # Convert grams to kg
|
|
|
|
if total_count == 0:
|
|
raise ValidationError("No orders found for bulk order")
|
|
|
|
# Use first order's product type and service level (assuming all are same type)
|
|
first_order = self.deutschepost_orders.first()
|
|
|
|
# Create mixed bag order data
|
|
mixed_bag_order = MixedBagOrderDTO(
|
|
contact_name=config.deutschepost_contact_name or "Contact",
|
|
product=first_order.product_type,
|
|
service_level=first_order.service_level,
|
|
items_count=total_count,
|
|
items_weight_in_kilogram=max(0.1, total_weight), # Minimum 0.1kg
|
|
total_count_receptacles=total_count, # Each order is one receptacle
|
|
format_="MIXED",
|
|
telephone_number=config.deutschepost_contact_phone,
|
|
job_reference=self.description or f"Bulk-{self.id}",
|
|
customer_ekp=config.deutschepost_customer_ekp or "1234567890"
|
|
)
|
|
|
|
# Get customer EKP
|
|
customer_ekp = config.deutschepost_customer_ekp or "1234567890"
|
|
|
|
# Make API call using mixed order endpoint
|
|
response = create_mixed_order.sync_detailed(
|
|
client=client,
|
|
customer_ekp=customer_ekp,
|
|
body=mixed_bag_order
|
|
)
|
|
|
|
if response.parsed is None:
|
|
raise ValidationError(f"Failed to create bulk order. Status: {response.status_code}")
|
|
|
|
# Handle different response types
|
|
if hasattr(response.parsed, 'order_id'):
|
|
# Successful response (BulkOrderDto)
|
|
bulk_response = response.parsed
|
|
self.bulk_order_id = str(bulk_response.order_id)
|
|
self.status = self.STATUS.PROCESSING
|
|
self.metadata = {
|
|
'api_response': bulk_response.to_dict() if hasattr(bulk_response, 'to_dict') else str(bulk_response),
|
|
'bulk_order_type': self.bulk_order_type,
|
|
'order_count': total_count,
|
|
'total_weight_kg': total_weight,
|
|
'created_at': timezone.now().isoformat()
|
|
}
|
|
self.last_error = ""
|
|
else:
|
|
# Error response
|
|
error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed)
|
|
self.status = self.STATUS.ERROR
|
|
self.last_error = f"Bulk order creation failed: {error_info}"
|
|
self.metadata = {'api_error': error_info}
|
|
raise ValidationError(f"Deutsche Post Bulk API error: {error_info}")
|
|
|
|
self.save()
|
|
|
|
except UnexpectedStatus as e:
|
|
self.status = self.STATUS.ERROR
|
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post Bulk API error {e.status_code}: {e.content}")
|
|
except Exception as e:
|
|
self.status = self.STATUS.ERROR
|
|
self.last_error = str(e)
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post Bulk API error: {str(e)}")
|
|
|
|
def cancel_bulk_order(self):
|
|
"""Cancel bulk order via Deutsche Post API."""
|
|
if not self.bulk_order_id:
|
|
raise ValidationError("Bulk order not created remotely yet")
|
|
|
|
if self.status in [self.STATUS.COMPLETED, self.STATUS.ERROR]:
|
|
raise ValidationError(f"Cannot cancel bulk order in status {self.status}")
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
raise ValidationError("Deutsche Post API client is not available")
|
|
|
|
try:
|
|
client = self.deutschepost_orders.first().get_api_client()
|
|
|
|
# Note: Deutsche Post API might not have a direct cancel endpoint for bulk orders
|
|
# In that case, we check the current status and mark as cancelled locally
|
|
try:
|
|
# Try to get current bulk order status
|
|
response = get_bulk_order.sync_detailed(
|
|
client=client,
|
|
bulk_order_id=self.bulk_order_id
|
|
)
|
|
|
|
if response.parsed and hasattr(response.parsed, 'order_status'):
|
|
api_status = str(response.parsed.order_status)
|
|
if api_status in ['COMPLETED', 'FINALIZED']:
|
|
raise ValidationError("Cannot cancel bulk order that is already completed")
|
|
|
|
except UnexpectedStatus:
|
|
# If we can't get status, proceed with local cancellation
|
|
pass
|
|
|
|
# Since there might not be a cancel API endpoint, mark as cancelled locally
|
|
self.status = self.STATUS.ERROR # Use ERROR status for cancelled
|
|
self.metadata.update({
|
|
'cancelled': True,
|
|
'cancelled_at': timezone.now().isoformat(),
|
|
'cancellation_note': 'Cancelled locally - API might not support bulk order cancellation'
|
|
})
|
|
self.last_error = "Bulk order cancelled by user"
|
|
self.save()
|
|
|
|
except UnexpectedStatus as e:
|
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post Bulk API error {e.status_code}: {e.content}")
|
|
except Exception as e:
|
|
self.last_error = str(e)
|
|
self.save()
|
|
raise ValidationError(f"Deutsche Post Bulk API error: {str(e)}")
|
|
|
|
def refresh_bulk_status(self):
|
|
"""Refresh bulk order status from Deutsche Post API."""
|
|
if not self.bulk_order_id:
|
|
return
|
|
|
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
|
return
|
|
|
|
try:
|
|
client = self.deutschepost_orders.first().get_api_client()
|
|
|
|
# Make API call to get current bulk order status
|
|
response = get_bulk_order.sync_detailed(
|
|
client=client,
|
|
bulk_order_id=self.bulk_order_id
|
|
)
|
|
|
|
if response.parsed is None:
|
|
self.last_error = f"Failed to refresh bulk status. Status: {response.status_code}"
|
|
self.save()
|
|
return
|
|
|
|
# Handle successful response
|
|
if hasattr(response.parsed, 'order_status'):
|
|
api_status = str(response.parsed.order_status)
|
|
if api_status in ['COMPLETED', 'FINALIZED']:
|
|
self.status = self.STATUS.COMPLETED
|
|
elif api_status in ['PROCESSING', 'IN_PROGRESS']:
|
|
self.status = self.STATUS.PROCESSING
|
|
elif api_status in ['CANCELLED', 'ERROR']:
|
|
self.status = self.STATUS.ERROR
|
|
|
|
self.metadata.update({
|
|
'status_refreshed': True,
|
|
'last_refresh': timezone.now().isoformat(),
|
|
'api_response': response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed)
|
|
})
|
|
self.last_error = ""
|
|
else:
|
|
# Error response
|
|
error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed)
|
|
self.last_error = f"Status refresh failed: {error_info}"
|
|
|
|
self.save()
|
|
|
|
except UnexpectedStatus as e:
|
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
|
self.save()
|
|
except Exception as e:
|
|
self.last_error = str(e)
|
|
self.save()
|
|
|
|
def get_total_weight_kg(self):
|
|
"""Get total weight of all orders in kg."""
|
|
return sum(order.shipment_gross_weight / 1000.0 for order in self.deutschepost_orders.all())
|
|
|
|
def can_be_cancelled(self):
|
|
"""Check if bulk order can be cancelled."""
|
|
return (
|
|
self.bulk_order_id and
|
|
self.status in [self.STATUS.CREATED, self.STATUS.PROCESSING] and
|
|
not self.last_error
|
|
)
|
|
|
|
def get_tracking_url(self):
|
|
"""Get tracking URL for bulk order if available."""
|
|
if self.bulk_order_id:
|
|
# Some bulk orders might have tracking URLs
|
|
return f"https://www.deutschepost.de/de/b/bulk-tracking.html?bulk_id={self.bulk_order_id}"
|
|
return None
|
|
|
|
def get_bulk_status_url(self):
|
|
"""Get status URL for monitoring bulk order progress."""
|
|
if self.bulk_order_id:
|
|
return f"https://www.deutschepost.de/de/b/bulk-status.html?bulk_id={self.bulk_order_id}"
|
|
return None
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Auto-create remote bulk order when first saved with orders
|
|
is_creating = not self.pk
|
|
super().save(*args, **kwargs)
|
|
|
|
if is_creating and not self.bulk_order_id and self.deutschepost_orders.exists():
|
|
try:
|
|
self.create_remote_bulk_order()
|
|
except ValidationError:
|
|
# Don't auto-create if validation fails - admin can fix and retry manually
|
|
pass
|
|
|
|
def delete(self, *args, **kwargs):
|
|
"""Override delete to cancel remote bulk order if possible."""
|
|
if self.bulk_order_id and self.can_be_cancelled():
|
|
try:
|
|
self.cancel_bulk_order()
|
|
except ValidationError:
|
|
# Log error but don't prevent deletion
|
|
self.last_error = "Failed to cancel remote bulk order during deletion"
|
|
self.save()
|
|
|
|
super().delete(*args, **kwargs)
|
|
|
|
def generate_bulk_labels(self):
|
|
"""Generate combined PDF with all order labels using HTML template."""
|
|
if not self.deutschepost_orders.exists():
|
|
raise ValidationError("No orders in bulk shipment")
|
|
|
|
from django.template.loader import render_to_string
|
|
from django.core.files.base import ContentFile
|
|
from weasyprint import HTML
|
|
import io
|
|
|
|
orders = self.deutschepost_orders.all()
|
|
total_weight_kg = self.get_total_weight_kg()
|
|
|
|
# Prepare template context
|
|
context = {
|
|
'bulk_order': self,
|
|
'orders': orders,
|
|
'total_weight_kg': total_weight_kg,
|
|
'now': timezone.now()
|
|
}
|
|
|
|
# Render HTML template
|
|
html_string = render_to_string('deutschepost/bulk_labels.html', context)
|
|
|
|
# Generate PDF from HTML
|
|
pdf_buffer = io.BytesIO()
|
|
HTML(string=html_string).write_pdf(pdf_buffer)
|
|
pdf_data = pdf_buffer.getvalue()
|
|
pdf_buffer.close()
|
|
|
|
# Save bulk label PDF
|
|
filename = f"deutschepost_bulk_labels_{self.bulk_order_id or self.id}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
self.bulk_label_pdf.save(
|
|
filename,
|
|
ContentFile(pdf_data),
|
|
save=True
|
|
)
|
|
|
|
self.metadata.update({
|
|
'bulk_labels_generated': True,
|
|
'labels_generated_at': timezone.now().isoformat()
|
|
})
|
|
|
|
return True
|
|
|
|
def __str__(self):
|
|
return f"Deutsche Post Bulk Order {self.bulk_order_id or 'Not Created'} - {self.deutschepost_orders.count()} orders - {self.status}"
|