Compare commits
2 Commits
27b346c1f6
...
8f6d864b4b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f6d864b4b | |||
| 3a7044d551 |
775
backend/thirdparty/deutschepost/models.py
vendored
775
backend/thirdparty/deutschepost/models.py
vendored
@@ -19,6 +19,7 @@ Edge cases handled:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -31,7 +32,19 @@ from configuration.models import SiteConfiguration
|
|||||||
# API client imports - direct references as requested
|
# API client imports - direct references as requested
|
||||||
# Note: We import only what's absolutely necessary to avoid import errors
|
# Note: We import only what's absolutely necessary to avoid import errors
|
||||||
try:
|
try:
|
||||||
from .client.deutsche_post_international_shipping_api_client import AuthenticatedClient
|
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
|
DEUTSCHE_POST_CLIENT_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
DEUTSCHE_POST_CLIENT_AVAILABLE = False
|
DEUTSCHE_POST_CLIENT_AVAILABLE = False
|
||||||
@@ -96,6 +109,16 @@ class DeutschePostOrder(models.Model):
|
|||||||
# Error tracking
|
# Error tracking
|
||||||
last_error = models.TextField(blank=True, help_text="Last API error message")
|
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:
|
def get_api_client(self) -> AuthenticatedClient:
|
||||||
"""Get authenticated API client using configuration."""
|
"""Get authenticated API client using configuration."""
|
||||||
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
||||||
@@ -106,84 +129,211 @@ class DeutschePostOrder(models.Model):
|
|||||||
if not all([config.deutschepost_client_id, config.deutschepost_client_secret]):
|
if not all([config.deutschepost_client_id, config.deutschepost_client_secret]):
|
||||||
raise ValidationError("Deutsche Post API credentials not configured")
|
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(
|
client = AuthenticatedClient(
|
||||||
base_url=config.deutschepost_api_url,
|
base_url=config.deutschepost_api_url or "https://api-sandbox.deutschepost.com",
|
||||||
token="" # Token management to be implemented
|
token=access_token,
|
||||||
|
raise_on_unexpected_status=True
|
||||||
)
|
)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(f"Deutsche Post authentication error: {str(e)}")
|
||||||
|
|
||||||
def create_remote_order(self):
|
def create_remote_order(self):
|
||||||
"""Create order via Deutsche Post API."""
|
"""Create order via Deutsche Post API."""
|
||||||
if self.order_id:
|
if self.order_id:
|
||||||
raise ValidationError("Order already created remotely")
|
raise ValidationError(detail="Order already created remotely")
|
||||||
|
|
||||||
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
||||||
raise ValidationError("Deutsche Post API client is not available")
|
raise ValidationError(detail="Deutsche Post API client is not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import API functions only when needed
|
|
||||||
from .client.deutsche_post_international_shipping_api_client.api.orders import create_order
|
|
||||||
from .client.deutsche_post_international_shipping_api_client.models import OrderData
|
|
||||||
|
|
||||||
client = self.get_api_client()
|
client = self.get_api_client()
|
||||||
config = SiteConfiguration.get_solo()
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
# Prepare order data - simplified for now
|
# Create content pieces if we have shipment data
|
||||||
order_data = {
|
contents = []
|
||||||
'customerEkp': config.deutschepost_customer_ekp or self.customer_ekp,
|
if self.shipment_amount > 0:
|
||||||
'orderStatus': 'OPEN',
|
contents.append(Content(
|
||||||
'items': [{
|
content_piece_hs_code=1234567890, # Default HS code, should be configurable
|
||||||
'product': self.product_type,
|
content_piece_description="E-commerce goods",
|
||||||
'serviceLevel': self.service_level,
|
content_piece_value=str(self.shipment_amount),
|
||||||
'recipient': self.recipient_name,
|
content_piece_netweight=max(100, self.shipment_gross_weight - 50), # Estimate net weight
|
||||||
'addressLine1': self.address_line1,
|
content_piece_origin=config.deutschepost_origin_country or "DE",
|
||||||
'city': self.city,
|
content_piece_amount=1
|
||||||
'postalCode': self.postal_code,
|
))
|
||||||
'destinationCountry': self.destination_country,
|
|
||||||
'shipmentGrossWeight': self.shipment_gross_weight,
|
|
||||||
'custRef': self.cust_ref or f"ORDER-{self.id}",
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
# For now, we'll simulate a successful API call
|
# Create item data
|
||||||
# TODO: Implement actual API call when client is properly configured
|
item_data = ItemData(
|
||||||
self.order_id = f"SIMULATED-{self.id}-{timezone.now().strftime('%Y%m%d%H%M%S')}"
|
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.state = self.STATE.CREATED
|
||||||
self.metadata = {'simulated': True, 'order_data': order_data}
|
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 = ""
|
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()
|
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:
|
except Exception as e:
|
||||||
self.state = self.STATE.ERROR
|
self.state = self.STATE.ERROR
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
self.save()
|
self.save()
|
||||||
raise ValidationError(f"Deutsche Post API error: {str(e)}")
|
raise ValidationError(detail=f"Deutsche Post API error: {str(e)}")
|
||||||
|
|
||||||
def finalize_remote_order(self):
|
def finalize_remote_order(self):
|
||||||
"""Finalize order via Deutsche Post API."""
|
"""Finalize order via Deutsche Post API."""
|
||||||
if not self.order_id:
|
if not self.order_id:
|
||||||
raise ValidationError("Order not created remotely yet")
|
raise ValidationError(detail="Order not created remotely yet")
|
||||||
|
|
||||||
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
if not DEUTSCHE_POST_CLIENT_AVAILABLE:
|
||||||
raise ValidationError("Deutsche Post API client is not available")
|
raise ValidationError(detail="Deutsche Post API client is not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import API functions only when needed
|
|
||||||
from .client.deutsche_post_international_shipping_api_client.api.orders import finalize_order
|
|
||||||
|
|
||||||
client = self.get_api_client()
|
client = self.get_api_client()
|
||||||
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
# For now, simulate finalization
|
# Create paperwork for finalization
|
||||||
# TODO: Implement actual API call
|
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
|
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({
|
self.metadata.update({
|
||||||
'finalized': True,
|
'finalized': True,
|
||||||
'finalized_at': timezone.now().isoformat()
|
'finalized_at': timezone.now().isoformat(),
|
||||||
|
'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response)
|
||||||
})
|
})
|
||||||
self.last_error = ""
|
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()
|
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:
|
except Exception as e:
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
self.save()
|
self.save()
|
||||||
@@ -198,28 +348,250 @@ class DeutschePostOrder(models.Model):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import API functions only when needed
|
|
||||||
from .client.deutsche_post_international_shipping_api_client.api.orders import get_order
|
|
||||||
|
|
||||||
client = self.get_api_client()
|
client = self.get_api_client()
|
||||||
|
|
||||||
# For now, simulate tracking update
|
# Make API call to get current order status
|
||||||
# TODO: Implement actual API call
|
response = get_order.sync_detailed(
|
||||||
if not self.awb_number:
|
client=client,
|
||||||
self.awb_number = f"AWB{self.id}{timezone.now().strftime('%Y%m%d')}"
|
order_id=self.order_id
|
||||||
if not self.barcode:
|
)
|
||||||
self.barcode = f"RX{self.id}DE"
|
|
||||||
|
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({
|
self.metadata.update({
|
||||||
'tracking_refreshed': True,
|
'tracking_refreshed': True,
|
||||||
'last_refresh': timezone.now().isoformat()
|
'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()
|
self.save()
|
||||||
|
|
||||||
|
except UnexpectedStatus as e:
|
||||||
|
self.last_error = f"API Error {e.status_code}: {e.content}"
|
||||||
|
self.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
self.save()
|
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):
|
def order_shippment(self):
|
||||||
"""Create order via Deutsche Post API, importing address data from commerce order.
|
"""Create order via Deutsche Post API, importing address data from commerce order.
|
||||||
|
|
||||||
@@ -227,8 +599,8 @@ class DeutschePostOrder(models.Model):
|
|||||||
the Carrier model and imports all necessary address data.
|
the Carrier model and imports all necessary address data.
|
||||||
"""
|
"""
|
||||||
# Get related order through Carrier model (same pattern as zasilkovna)
|
# Get related order through Carrier model (same pattern as zasilkovna)
|
||||||
Carrier = apps.get_model('commerce', 'Carrier')
|
Carrier = apps.get_model(app_label='commerce', model_name='Carrier')
|
||||||
Order = apps.get_model('commerce', 'Order')
|
Order = apps.get_model(app_label='commerce', model_name='Order')
|
||||||
|
|
||||||
carrier = Carrier.objects.get(deutschepost=self)
|
carrier = Carrier.objects.get(deutschepost=self)
|
||||||
order = Order.objects.get(carrier=carrier)
|
order = Order.objects.get(carrier=carrier)
|
||||||
@@ -247,8 +619,25 @@ class DeutschePostOrder(models.Model):
|
|||||||
if not self.postal_code:
|
if not self.postal_code:
|
||||||
self.postal_code = order.postal_code
|
self.postal_code = order.postal_code
|
||||||
if not self.destination_country:
|
if not self.destination_country:
|
||||||
# Map country name to ISO code (simplified)
|
# Map country name to ISO code (comprehensive mapping)
|
||||||
country_map = {"Czech Republic": "CZ", "Germany": "DE", "Austria": "AT"}
|
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")
|
self.destination_country = country_map.get(order.country, "CZ")
|
||||||
if not self.cust_ref:
|
if not self.cust_ref:
|
||||||
self.cust_ref = f"ORDER-{order.id}"
|
self.cust_ref = f"ORDER-{order.id}"
|
||||||
@@ -270,8 +659,55 @@ class DeutschePostOrder(models.Model):
|
|||||||
raise ValidationError("Deutsche Post order already created remotely.")
|
raise ValidationError("Deutsche Post order already created remotely.")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
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)
|
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):
|
def __str__(self):
|
||||||
return f"Deutsche Post Order {self.order_id or 'Not Created'} - {self.recipient_name}"
|
return f"Deutsche Post Order {self.order_id or 'Not Created'} - {self.recipient_name}"
|
||||||
|
|
||||||
@@ -305,6 +741,10 @@ class DeutschePostBulkOrder(models.Model):
|
|||||||
metadata = models.JSONField(default=dict, blank=True, help_text="Raw API response data")
|
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")
|
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):
|
def create_remote_bulk_order(self):
|
||||||
"""Create bulk order via Deutsche Post API."""
|
"""Create bulk order via Deutsche Post API."""
|
||||||
if self.bulk_order_id:
|
if self.bulk_order_id:
|
||||||
@@ -333,24 +773,75 @@ class DeutschePostBulkOrder(models.Model):
|
|||||||
raise ValidationError(f"Invalid orders for bulk: {', '.join(invalid_orders)}")
|
raise ValidationError(f"Invalid orders for bulk: {', '.join(invalid_orders)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import API functions only when needed
|
|
||||||
from .client.deutsche_post_international_shipping_api_client.api.bulk_orders import create_bulk_order
|
|
||||||
|
|
||||||
client = self.deutschepost_orders.first().get_api_client()
|
client = self.deutschepost_orders.first().get_api_client()
|
||||||
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
# For now, simulate bulk order creation
|
# Calculate totals from all orders
|
||||||
# TODO: Implement actual API call
|
total_count = self.deutschepost_orders.count()
|
||||||
self.bulk_order_id = f"BULK-{self.id}-{timezone.now().strftime('%Y%m%d%H%M%S')}"
|
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.status = self.STATUS.PROCESSING
|
||||||
self.metadata = {
|
self.metadata = {
|
||||||
'simulated': True,
|
'api_response': bulk_response.to_dict() if hasattr(bulk_response, 'to_dict') else str(bulk_response),
|
||||||
'bulk_order_type': self.bulk_order_type,
|
'bulk_order_type': self.bulk_order_type,
|
||||||
'order_count': self.deutschepost_orders.count(),
|
'order_count': total_count,
|
||||||
|
'total_weight_kg': total_weight,
|
||||||
'created_at': timezone.now().isoformat()
|
'created_at': timezone.now().isoformat()
|
||||||
}
|
}
|
||||||
self.last_error = ""
|
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()
|
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:
|
except Exception as e:
|
||||||
self.status = self.STATUS.ERROR
|
self.status = self.STATUS.ERROR
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
@@ -369,26 +860,109 @@ class DeutschePostBulkOrder(models.Model):
|
|||||||
raise ValidationError("Deutsche Post API client is not available")
|
raise ValidationError("Deutsche Post API client is not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import API functions only when needed
|
|
||||||
from .client.deutsche_post_international_shipping_api_client.api.bulk_orders import cancel_bulk_order
|
|
||||||
|
|
||||||
client = self.deutschepost_orders.first().get_api_client()
|
client = self.deutschepost_orders.first().get_api_client()
|
||||||
|
|
||||||
# For now, simulate bulk order cancellation
|
# Note: Deutsche Post API might not have a direct cancel endpoint for bulk orders
|
||||||
# TODO: Implement actual API call
|
# 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.status = self.STATUS.ERROR # Use ERROR status for cancelled
|
||||||
self.metadata.update({
|
self.metadata.update({
|
||||||
'cancelled': True,
|
'cancelled': True,
|
||||||
'cancelled_at': timezone.now().isoformat()
|
'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.last_error = "Bulk order cancelled by user"
|
||||||
self.save()
|
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:
|
except Exception as e:
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
self.save()
|
self.save()
|
||||||
raise ValidationError(f"Deutsche Post Bulk API error: {str(e)}")
|
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):
|
def get_tracking_url(self):
|
||||||
"""Get tracking URL for bulk order if available."""
|
"""Get tracking URL for bulk order if available."""
|
||||||
if self.bulk_order_id:
|
if self.bulk_order_id:
|
||||||
@@ -402,5 +976,74 @@ class DeutschePostBulkOrder(models.Model):
|
|||||||
return f"https://www.deutschepost.de/de/b/bulk-status.html?bulk_id={self.bulk_order_id}"
|
return f"https://www.deutschepost.de/de/b/bulk-status.html?bulk_id={self.bulk_order_id}"
|
||||||
return None
|
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):
|
def __str__(self):
|
||||||
return f"Deutsche Post Bulk Order {self.bulk_order_id or 'Not Created'} - {self.deutschepost_orders.count()} orders - {self.status}"
|
return f"Deutsche Post Bulk Order {self.bulk_order_id or 'Not Created'} - {self.deutschepost_orders.count()} orders - {self.status}"
|
||||||
|
|||||||
75
backend/thirdparty/deutschepost/serializers.py
vendored
75
backend/thirdparty/deutschepost/serializers.py
vendored
@@ -5,13 +5,19 @@ from .models import DeutschePostOrder, DeutschePostBulkOrder
|
|||||||
|
|
||||||
|
|
||||||
class DeutschePostOrderSerializer(serializers.ModelSerializer):
|
class DeutschePostOrderSerializer(serializers.ModelSerializer):
|
||||||
commerce_order_id = serializers.IntegerField(write_only=True, required=False)
|
|
||||||
state_display = serializers.CharField(source='get_state_display', read_only=True)
|
state_display = serializers.CharField(source='get_state_display', read_only=True)
|
||||||
|
label_size_display = serializers.CharField(source='get_label_size_display', read_only=True)
|
||||||
|
tracking_url = serializers.SerializerMethodField(read_only=True)
|
||||||
|
estimated_delivery_days = serializers.SerializerMethodField(read_only=True)
|
||||||
|
shipping_cost_estimate = serializers.SerializerMethodField(read_only=True)
|
||||||
|
can_be_finalized = serializers.SerializerMethodField(read_only=True)
|
||||||
|
can_be_cancelled = serializers.SerializerMethodField(read_only=True)
|
||||||
|
is_trackable = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeutschePostOrder
|
model = DeutschePostOrder
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'created_at', 'state', 'state_display', 'commerce_order', 'commerce_order_id',
|
'id', 'created_at', 'state', 'state_display',
|
||||||
'order_id', 'customer_ekp',
|
'order_id', 'customer_ekp',
|
||||||
'recipient_name', 'recipient_phone', 'recipient_email',
|
'recipient_name', 'recipient_phone', 'recipient_email',
|
||||||
'address_line1', 'address_line2', 'address_line3',
|
'address_line1', 'address_line2', 'address_line3',
|
||||||
@@ -20,31 +26,33 @@ class DeutschePostOrderSerializer(serializers.ModelSerializer):
|
|||||||
'shipment_amount', 'shipment_currency',
|
'shipment_amount', 'shipment_currency',
|
||||||
'sender_tax_id', 'importer_tax_id', 'return_item_wanted',
|
'sender_tax_id', 'importer_tax_id', 'return_item_wanted',
|
||||||
'cust_ref', 'awb_number', 'barcode', 'tracking_url',
|
'cust_ref', 'awb_number', 'barcode', 'tracking_url',
|
||||||
'metadata', 'last_error'
|
'label_pdf', 'label_size', 'label_size_display',
|
||||||
|
'metadata', 'last_error',
|
||||||
|
'estimated_delivery_days', 'shipping_cost_estimate',
|
||||||
|
'can_be_finalized', 'can_be_cancelled', 'is_trackable'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'id', 'created_at', 'order_id', 'awb_number', 'barcode',
|
'id', 'created_at', 'order_id', 'awb_number', 'barcode',
|
||||||
'tracking_url', 'metadata', 'last_error'
|
'tracking_url', 'metadata', 'last_error', 'label_pdf'
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate_commerce_order_id(self, value):
|
def get_tracking_url(self, obj):
|
||||||
"""Validate that commerce order exists and uses Deutsche Post delivery."""
|
return obj.get_tracking_url()
|
||||||
try:
|
|
||||||
from commerce.models import Order
|
|
||||||
order = Order.objects.get(id=value)
|
|
||||||
|
|
||||||
if not order.carrier:
|
def get_estimated_delivery_days(self, obj):
|
||||||
raise ValidationError("Commerce order must have a carrier assigned")
|
return obj.get_estimated_delivery_days()
|
||||||
|
|
||||||
if order.carrier.shipping_method != "deutschepost":
|
def get_shipping_cost_estimate(self, obj):
|
||||||
raise ValidationError("Commerce order must use Deutsche Post delivery method")
|
return float(obj.get_shipping_cost_estimate())
|
||||||
|
|
||||||
if order.status != Order.OrderStatus.COMPLETED:
|
def get_can_be_finalized(self, obj):
|
||||||
raise ValidationError("Commerce order must be completed before creating Deutsche Post order")
|
return obj.can_be_finalized()
|
||||||
|
|
||||||
return value
|
def get_can_be_cancelled(self, obj):
|
||||||
except Order.DoesNotExist:
|
return obj.can_be_cancelled()
|
||||||
raise ValidationError("Commerce order does not exist")
|
|
||||||
|
def get_is_trackable(self, obj):
|
||||||
|
return obj.is_trackable()
|
||||||
|
|
||||||
def validate_destination_country(self, value):
|
def validate_destination_country(self, value):
|
||||||
"""Validate country code format."""
|
"""Validate country code format."""
|
||||||
@@ -60,14 +68,20 @@ class DeutschePostOrderSerializer(serializers.ModelSerializer):
|
|||||||
raise ValidationError("Shipment weight cannot exceed 30kg (30000g)")
|
raise ValidationError("Shipment weight cannot exceed 30kg (30000g)")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def create(self, validated_data):
|
def validate(self, attrs):
|
||||||
commerce_order_id = validated_data.pop('commerce_order_id', None)
|
"""Validate the complete order data."""
|
||||||
|
# Check required fields for shipping
|
||||||
|
required_fields = ['recipient_name', 'address_line1', 'city', 'postal_code', 'destination_country']
|
||||||
|
missing_fields = [field for field in required_fields if not attrs.get(field)]
|
||||||
|
|
||||||
if commerce_order_id:
|
if missing_fields:
|
||||||
from commerce.models import Order
|
raise ValidationError(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
validated_data['commerce_order'] = Order.objects.get(id=commerce_order_id)
|
|
||||||
|
|
||||||
return super().create(validated_data)
|
# Check contact information
|
||||||
|
if not attrs.get('recipient_email') and not attrs.get('recipient_phone'):
|
||||||
|
raise ValidationError("Either recipient email or phone is required for delivery notifications")
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class DeutschePostBulkOrderSerializer(serializers.ModelSerializer):
|
class DeutschePostBulkOrderSerializer(serializers.ModelSerializer):
|
||||||
@@ -79,8 +93,10 @@ class DeutschePostBulkOrderSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
orders_count = serializers.SerializerMethodField(read_only=True)
|
orders_count = serializers.SerializerMethodField(read_only=True)
|
||||||
|
total_weight_kg = serializers.SerializerMethodField(read_only=True)
|
||||||
tracking_url = serializers.SerializerMethodField(read_only=True)
|
tracking_url = serializers.SerializerMethodField(read_only=True)
|
||||||
status_url = serializers.SerializerMethodField(read_only=True)
|
status_url = serializers.SerializerMethodField(read_only=True)
|
||||||
|
can_be_cancelled = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeutschePostBulkOrder
|
model = DeutschePostBulkOrder
|
||||||
@@ -88,23 +104,30 @@ class DeutschePostBulkOrderSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'created_at', 'status', 'status_display',
|
'id', 'created_at', 'status', 'status_display',
|
||||||
'bulk_order_id', 'bulk_order_type', 'description',
|
'bulk_order_id', 'bulk_order_type', 'description',
|
||||||
'deutschepost_orders', 'deutschepost_order_ids', 'orders_count',
|
'deutschepost_orders', 'deutschepost_order_ids', 'orders_count',
|
||||||
'tracking_url', 'status_url',
|
'total_weight_kg', 'tracking_url', 'status_url', 'can_be_cancelled',
|
||||||
|
'bulk_label_pdf', 'paperwork_pdf',
|
||||||
'metadata', 'last_error'
|
'metadata', 'last_error'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'id', 'created_at', 'bulk_order_id', 'deutschepost_orders',
|
'id', 'created_at', 'bulk_order_id', 'deutschepost_orders',
|
||||||
'metadata', 'last_error'
|
'bulk_label_pdf', 'paperwork_pdf', 'metadata', 'last_error'
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_orders_count(self, obj):
|
def get_orders_count(self, obj):
|
||||||
return obj.deutschepost_orders.count()
|
return obj.deutschepost_orders.count()
|
||||||
|
|
||||||
|
def get_total_weight_kg(self, obj):
|
||||||
|
return obj.get_total_weight_kg()
|
||||||
|
|
||||||
def get_tracking_url(self, obj):
|
def get_tracking_url(self, obj):
|
||||||
return obj.get_tracking_url()
|
return obj.get_tracking_url()
|
||||||
|
|
||||||
def get_status_url(self, obj):
|
def get_status_url(self, obj):
|
||||||
return obj.get_bulk_status_url()
|
return obj.get_bulk_status_url()
|
||||||
|
|
||||||
|
def get_can_be_cancelled(self, obj):
|
||||||
|
return obj.can_be_cancelled()
|
||||||
|
|
||||||
def validate_deutschepost_order_ids(self, value):
|
def validate_deutschepost_order_ids(self, value):
|
||||||
"""Validate that all orders exist and are eligible for bulk processing."""
|
"""Validate that all orders exist and are eligible for bulk processing."""
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
252
backend/thirdparty/deutschepost/templates/deutschepost/bulk_labels.html
vendored
Normal file
252
backend/thirdparty/deutschepost/templates/deutschepost/bulk_labels.html
vendored
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Deutsche Post Bulk Shipping Labels</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 1cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #FF0000;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
color: #FF0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-info {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-info-left,
|
||||||
|
.bulk-info-right {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-header {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 8px;
|
||||||
|
margin: -10px -10px 10px -10px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-left,
|
||||||
|
.order-right {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-line {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FF0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background-color: #FF0000;
|
||||||
|
color: white;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority {
|
||||||
|
background-color: #FF6600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard {
|
||||||
|
background-color: #0066CC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-number {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-per-page {
|
||||||
|
/* Adjust based on content - roughly 4-5 orders per page */
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-item:nth-of-type(5n+1) {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-item:nth-of-type(1) {
|
||||||
|
page-break-before: auto; /* Don't break before first item */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bulk-header">
|
||||||
|
Deutsche Post - Bulk Shipment Labels
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bulk-info">
|
||||||
|
<div class="bulk-info-left">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Bulk Order ID:</span>
|
||||||
|
<span>{{ bulk_order.bulk_order_id|default:'Pending' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Order Type:</span>
|
||||||
|
<span>{{ bulk_order.bulk_order_type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Description:</span>
|
||||||
|
<span>{{ bulk_order.description|default:'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bulk-info-right">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Total Orders:</span>
|
||||||
|
<span>{{ orders.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Total Weight:</span>
|
||||||
|
<span>{{ total_weight_kg|floatformat:2 }} kg</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Created:</span>
|
||||||
|
<span>{{ bulk_order.created_at|date:"d.m.Y H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for order in orders %}
|
||||||
|
<div class="order-item">
|
||||||
|
<div class="order-header">
|
||||||
|
Order #{{ forloop.counter }} - {{ order.order_id|default:'Pending' }}
|
||||||
|
<span style="float: right;">
|
||||||
|
<span class="service-badge {% if order.service_level == 'PRIORITY' %}priority{% else %}standard{% endif %}">
|
||||||
|
{{ order.service_level }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-content">
|
||||||
|
<div class="order-left">
|
||||||
|
<div class="recipient-name">{{ order.recipient_name }}</div>
|
||||||
|
<div class="address-line">{{ order.address_line1 }}</div>
|
||||||
|
{% if order.address_line2 %}
|
||||||
|
<div class="address-line">{{ order.address_line2 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="address-line">{{ order.postal_code }} {{ order.city }}</div>
|
||||||
|
{% if order.address_state %}
|
||||||
|
<div class="address-line">{{ order.address_state }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="address-line country">{{ order.destination_country }}</div>
|
||||||
|
|
||||||
|
{% if order.recipient_phone %}
|
||||||
|
<div style="margin-top: 5px; font-size: 10px;">Tel: {{ order.recipient_phone }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-right">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Weight:</span>
|
||||||
|
<span>{{ order.shipment_gross_weight }}g</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Value:</span>
|
||||||
|
<span>{{ order.shipment_amount }} {{ order.shipment_currency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Product:</span>
|
||||||
|
<span>{{ order.product_type }}</span>
|
||||||
|
</div>
|
||||||
|
{% if order.awb_number %}
|
||||||
|
<div class="info-row" style="margin-top: 8px;">
|
||||||
|
<span class="label">AWB:</span><br>
|
||||||
|
<span class="tracking-number">{{ order.awb_number }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif order.barcode %}
|
||||||
|
<div class="info-row" style="margin-top: 8px;">
|
||||||
|
<span class="label">Barcode:</span><br>
|
||||||
|
<span class="tracking-number">{{ order.barcode }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="summary-footer">
|
||||||
|
<div>Total: {{ orders.count }} orders | {{ total_weight_kg|floatformat:2 }} kg</div>
|
||||||
|
<div>Generated: {{ now|date:"d.m.Y H:i:s" }} | Deutsche Post International Shipping API</div>
|
||||||
|
<div>Bulk Order: {{ bulk_order.bulk_order_id|default:'Pending' }}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
264
backend/thirdparty/deutschepost/templates/deutschepost/shipping_label.html
vendored
Normal file
264
backend/thirdparty/deutschepost/templates/deutschepost/shipping_label.html
vendored
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Deutsche Post Shipping Label</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 1cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
color: #FF0000;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-address {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-column {
|
||||||
|
width: 48%;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipment-column h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #333;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background-color: #FF0000;
|
||||||
|
color: white;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority {
|
||||||
|
background-color: #FF6600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard {
|
||||||
|
background-color: #0066CC;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">Deutsche Post</div>
|
||||||
|
<div>International Shipping Label</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-info">
|
||||||
|
<h3>Informace o objednávce / Order Information</h3>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Order ID:</span>
|
||||||
|
<span>{{ order.order_id|default:'Pending' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">AWB Number:</span>
|
||||||
|
<span>{{ order.awb_number|default:'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Barcode:</span>
|
||||||
|
<span>{{ order.barcode|default:'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Customer Ref:</span>
|
||||||
|
<span>{{ order.cust_ref|default:'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Service Level:</span>
|
||||||
|
<span class="service-badge {% if order.service_level == 'PRIORITY' %}priority{% else %}standard{% endif %}">
|
||||||
|
{{ order.service_level }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recipient-section">
|
||||||
|
<h3>Příjemce / Recipient</h3>
|
||||||
|
<div class="recipient-address">
|
||||||
|
<div class="recipient-name">{{ order.recipient_name }}</div>
|
||||||
|
<div>{{ order.address_line1 }}</div>
|
||||||
|
{% if order.address_line2 %}
|
||||||
|
<div>{{ order.address_line2 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if order.address_line3 %}
|
||||||
|
<div>{{ order.address_line3 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>{{ order.postal_code }} {{ order.city }}</div>
|
||||||
|
{% if order.address_state %}
|
||||||
|
<div>{{ order.address_state }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div><strong>{{ order.destination_country }}</strong></div>
|
||||||
|
{% if order.recipient_phone %}
|
||||||
|
<div>Tel: {{ order.recipient_phone }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if order.recipient_email %}
|
||||||
|
<div>Email: {{ order.recipient_email }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shipment-details">
|
||||||
|
<div class="shipment-column">
|
||||||
|
<h4>Detaily zásilky / Shipment Details</h4>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Product:</span>
|
||||||
|
<span>{{ order.product_type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Weight:</span>
|
||||||
|
<span>{{ order.shipment_gross_weight }}g</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Value:</span>
|
||||||
|
<span>{{ order.shipment_amount }} {{ order.shipment_currency }}</span>
|
||||||
|
</div>
|
||||||
|
{% if order.sender_tax_id %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">IOSS:</span>
|
||||||
|
<span>{{ order.sender_tax_id }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shipment-column">
|
||||||
|
<h4>Doručení / Delivery</h4>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Est. Days:</span>
|
||||||
|
<span>{{ estimated_delivery_days }} days</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Return:</span>
|
||||||
|
<span>{% if order.return_item_wanted %}Yes{% else %}No{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Created:</span>
|
||||||
|
<span>{{ order.created_at|date:"d.m.Y H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if order.awb_number or order.barcode %}
|
||||||
|
<div class="barcode-section">
|
||||||
|
<div>Tracking Number / Sledovací číslo</div>
|
||||||
|
<div class="barcode">{{ order.awb_number|default:order.barcode }}</div>
|
||||||
|
<div class="tracking-info">
|
||||||
|
Track at: https://www.deutschepost.de/de/sendungsverfolgung.html
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="tracking-info">
|
||||||
|
Generated: {{ now|date:"d.m.Y H:i:s" }} | Deutsche Post International Shipping API
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
197
backend/thirdparty/deutschepost/views.py
vendored
197
backend/thirdparty/deutschepost/views.py
vendored
@@ -31,11 +31,48 @@ from .serializers import (
|
|||||||
description="Returns detailed information for a single Deutsche Post order including tracking data. Orders are managed through Carrier model.",
|
description="Returns detailed information for a single Deutsche Post order including tracking data. Orders are managed through Carrier model.",
|
||||||
responses=DeutschePostOrderSerializer,
|
responses=DeutschePostOrderSerializer,
|
||||||
),
|
),
|
||||||
|
create=extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Create Deutsche Post Order",
|
||||||
|
description="Create a new Deutsche Post order with recipient and shipment details. Order will automatically be created remotely if validation passes.",
|
||||||
|
request=DeutschePostOrderSerializer,
|
||||||
|
responses={
|
||||||
|
201: DeutschePostOrderSerializer,
|
||||||
|
400: OpenApiResponse(description="Validation error")
|
||||||
|
},
|
||||||
|
),
|
||||||
|
update=extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Update Deutsche Post Order",
|
||||||
|
description="Update Deutsche Post order details. Cannot update orders that are already created remotely.",
|
||||||
|
request=DeutschePostOrderSerializer,
|
||||||
|
responses={
|
||||||
|
200: DeutschePostOrderSerializer,
|
||||||
|
400: OpenApiResponse(description="Validation error or order already remote")
|
||||||
|
},
|
||||||
|
),
|
||||||
|
partial_update=extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Partially Update Deutsche Post Order",
|
||||||
|
description="Partially update Deutsche Post order details. Cannot update orders that are already created remotely.",
|
||||||
|
request=DeutschePostOrderSerializer,
|
||||||
|
responses={
|
||||||
|
200: DeutschePostOrderSerializer,
|
||||||
|
400: OpenApiResponse(description="Validation error or order already remote")
|
||||||
|
},
|
||||||
|
),
|
||||||
|
destroy=extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Delete Deutsche Post Order",
|
||||||
|
description="Delete Deutsche Post order. If order is created remotely, it will attempt to cancel it first.",
|
||||||
|
responses={
|
||||||
|
204: OpenApiResponse(description="Order deleted successfully"),
|
||||||
|
400: OpenApiResponse(description="Cannot delete order")
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
class DeutschePostOrderViewSet(
|
class DeutschePostOrderViewSet(
|
||||||
mixins.ListModelMixin,
|
viewsets.ModelViewSet # Changed to ModelViewSet for full CRUD
|
||||||
mixins.RetrieveModelMixin,
|
|
||||||
viewsets.GenericViewSet
|
|
||||||
):
|
):
|
||||||
queryset = DeutschePostOrder.objects.all().order_by("-created_at")
|
queryset = DeutschePostOrder.objects.all().order_by("-created_at")
|
||||||
serializer_class = DeutschePostOrderSerializer
|
serializer_class = DeutschePostOrderSerializer
|
||||||
@@ -112,7 +149,7 @@ class DeutschePostOrderViewSet(
|
|||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
order.cancel_order()
|
order.cancel_remote_order()
|
||||||
serializer = self.get_serializer(order)
|
serializer = self.get_serializer(order)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -121,6 +158,83 @@ class DeutschePostOrderViewSet(
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Finalize Deutsche Post Order",
|
||||||
|
description="""
|
||||||
|
Finalize the order in Deutsche Post API system. This will:
|
||||||
|
- Call Deutsche Post API to finalize the order
|
||||||
|
- Update the order state to FINALIZED
|
||||||
|
- Retrieve AWB number and barcode for tracking
|
||||||
|
- Enable label generation
|
||||||
|
- Required before the order can be shipped
|
||||||
|
""",
|
||||||
|
request=None,
|
||||||
|
responses={
|
||||||
|
200: DeutschePostOrderSerializer,
|
||||||
|
400: OpenApiResponse(description="Order cannot be finalized or API error"),
|
||||||
|
500: OpenApiResponse(description="Deutsche Post API error")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def finalize_order(self, request, pk=None):
|
||||||
|
"""Finalize order in Deutsche Post API."""
|
||||||
|
order = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
order.finalize_remote_order()
|
||||||
|
serializer = self.get_serializer(order)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Generate Shipping Label",
|
||||||
|
description="""
|
||||||
|
Generate and download shipping label PDF. This will:
|
||||||
|
- Attempt to download label from Deutsche Post API
|
||||||
|
- If API label not available, generate simple label with order details
|
||||||
|
- Save label PDF to order.label_pdf field
|
||||||
|
- Include barcode and tracking information
|
||||||
|
- Requires order to be created remotely first
|
||||||
|
""",
|
||||||
|
request=None,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Label generated successfully", examples={
|
||||||
|
"application/json": {
|
||||||
|
"message": "Label generated successfully",
|
||||||
|
"label_url": "/media/deutschepost/labels/label_123.pdf"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
400: OpenApiResponse(description="Order not ready for label generation or API error"),
|
||||||
|
500: OpenApiResponse(description="Deutsche Post API error")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def generate_label(self, request, pk=None):
|
||||||
|
"""Generate shipping label PDF."""
|
||||||
|
order = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
order.generate_shipping_label()
|
||||||
|
|
||||||
|
label_url = order.label_pdf.url if order.label_pdf else None
|
||||||
|
return Response({
|
||||||
|
'message': 'Label generated successfully',
|
||||||
|
'label_url': label_url,
|
||||||
|
'order_id': order.order_id,
|
||||||
|
'awb_number': order.awb_number
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["deutschepost"],
|
tags=["deutschepost"],
|
||||||
summary="Get Tracking URL",
|
summary="Get Tracking URL",
|
||||||
@@ -298,3 +412,78 @@ class DeutschePostBulkOrderViewSet(
|
|||||||
{'error': 'Bulk order not created remotely yet'},
|
{'error': 'Bulk order not created remotely yet'},
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Refresh Bulk Order Status",
|
||||||
|
description="""
|
||||||
|
Refresh bulk order status from Deutsche Post API. This will:
|
||||||
|
- Fetch latest bulk order status from Deutsche Post
|
||||||
|
- Update bulk order status (PROCESSING, COMPLETED, etc.)
|
||||||
|
- Store raw API response data in metadata
|
||||||
|
- Update individual order statuses if needed
|
||||||
|
""",
|
||||||
|
request=None,
|
||||||
|
responses={
|
||||||
|
200: DeutschePostBulkOrderSerializer,
|
||||||
|
400: OpenApiResponse(description="Bulk order not created remotely"),
|
||||||
|
500: OpenApiResponse(description="Deutsche Post API error")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def refresh_status(self, request, pk=None):
|
||||||
|
"""Refresh bulk order status from Deutsche Post API."""
|
||||||
|
bulk_order = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
bulk_order.refresh_bulk_status()
|
||||||
|
serializer = self.get_serializer(bulk_order)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["deutschepost"],
|
||||||
|
summary="Generate Bulk Labels",
|
||||||
|
description="""
|
||||||
|
Generate combined PDF with all order labels for bulk shipment. This will:
|
||||||
|
- Create multi-page PDF with all order details
|
||||||
|
- Include recipient addresses and tracking information
|
||||||
|
- Format for warehouse processing
|
||||||
|
- Save PDF to bulk_order.bulk_label_pdf field
|
||||||
|
""",
|
||||||
|
request=None,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Bulk labels generated successfully", examples={
|
||||||
|
"application/json": {
|
||||||
|
"message": "Bulk labels generated successfully",
|
||||||
|
"labels_url": "/media/deutschepost/bulk_labels/bulk_labels_123.pdf",
|
||||||
|
"orders_count": 5
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
400: OpenApiResponse(description="No orders in bulk shipment or generation error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def generate_labels(self, request, pk=None):
|
||||||
|
"""Generate bulk shipping labels PDF."""
|
||||||
|
bulk_order = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
bulk_order.generate_bulk_labels()
|
||||||
|
|
||||||
|
labels_url = bulk_order.bulk_label_pdf.url if bulk_order.bulk_label_pdf else None
|
||||||
|
return Response({
|
||||||
|
'message': 'Bulk labels generated successfully',
|
||||||
|
'labels_url': labels_url,
|
||||||
|
'bulk_order_id': bulk_order.bulk_order_id,
|
||||||
|
'orders_count': bulk_order.deutschepost_orders.count()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user