This document outlines the architecture, database migrations, API changes, and front-end requirements for integrating a Third-Party Logistics (3PL) Fulfillment Provider workflow into the multi-tenant AtlasAI eCommerce platform.
Currently, every Product and Order belongs to a single Django User (seller). A 3PL fulfillment company needs access to the orders and products of multiple separate sellers (their clients).
We will introduce a Role System and a Partnership Link model. This allows the 3PL fulfillment provider to query data across multiple client scopes while keeping standard sellers locked into their single-tenant view.
Extend CompanyProfile:
Add a role field to differentiate standard accounts from logistics providers.
class CompanyProfile(models.Model):
# ... existing fields ...
ROLE_CHOICES = [
('seller', 'Seller (Client)'),
('fulfillment', 'Fulfillment Provider (3PL)'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='seller')
Add FulfillmentPartnership:
Represents a contractual relationship allowing the 3PL read/write access to the seller's catalog and orders.
class FulfillmentPartnership(models.Model):
fulfillment_company = models.ForeignKey(
User, data-removed=models.CASCADE, related_name="seller_partnerships"
)
seller = models.ForeignKey(
User, data-removed=models.CASCADE, related_name="fulfillment_partnerships"
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('fulfillment_company', 'seller')
Modify get_queryset() in ProductViewSet and OrderViewSet to fetch client data if the request user is a fulfillment company:
def get_queryset(self):
user = self.request.user
profile = getattr(user, 'company_profile', None)
if profile and profile.role == 'fulfillment':
# Retrieve IDs of all sellers associated with this 3PL
seller_ids = FulfillmentPartnership.objects.filter(
fulfillment_company=user, is_active=True
).values_list('seller_id', flat=True)
# Fetch records belonging to the 3PL itself OR any of its active sellers
return Order.objects.filter(account_id__in=list(seller_ids) + [user.id])
# Standard single-tenant view for regular sellers
return Order.objects.filter(account=user)
Complexity: Low-Medium. Modifies query boundaries but preserves existing database structural isolation.
Introduce a WarehouseItem (physical asset) owned by the 3PL. The seller's PIM Product (virtual asset) links to this physical item. If a link exists, the seller's product reads its stock directly from the shared warehouse item.
Create WarehouseItem:
class WarehouseItem(models.Model):
fulfillment_provider = models.ForeignKey(User, data-removed=models.CASCADE, related_name="warehouse_items")
sku = models.CharField(max_length=100, db_index=True)
ean = models.CharField(max_length=50, blank=True)
name = models.CharField(max_length=255)
stock = models.PositiveIntegerField(default=0)
location = models.CharField(max_length=100, blank=True, help_text="Deposit Location (e.g., Aisle-Shelf-Bin)")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('fulfillment_provider', 'sku')
Add FK to Product:
class Product(models.Model):
# ... existing fields ...
warehouse_item = models.ForeignKey(
WarehouseItem, null=True, blank=True, data-removed=models.SET_NULL, related_name="pim_products"
)
To ensure we do not miss sales, order ingestion (webhooks and polling tasks) must deduct stock immediately upon arrival.
When a marketplace order is received for a virtual product linked to a shared WarehouseItem, the stock deduction must propagate up to the parent WarehouseItem and then down to all other sibling products sharing that item, ultimately updating listings across all active marketplaces.
Because updating sibling products triggers post_save signals on Product which could theoretically trigger stock adjustments back up to WarehouseItem, we use a Thread-Local State Flag to lock loop executions.
import threading
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
# Thread-local guard to prevent recursion loops
_sync_state = threading.local()
@receiver(pre_save, sender=Product)
def product_pre_save_deduction(sender, instance, **kwargs):
# Retrieve previous stock state
try:
old_instance = Product.objects.get(pk=instance.pk)
instance._old_stock = old_instance.stock
except Product.DoesNotExist:
instance._old_stock = None
@receiver(post_save, sender=Product)
def propagate_order_deduction_up(sender, instance, created, **kwargs):
# Only run if linked to a shared WarehouseItem and NOT triggered by propagation downwards
if instance.warehouse_item and not getattr(_sync_state, 'prevent_warehouse_update', False):
old_stock = getattr(instance, '_old_stock', None)
if old_stock is not None and old_stock != instance.stock:
delta = instance.stock - old_stock
# Adjust the parent WarehouseItem stock level
w_item = instance.warehouse_item
w_item.stock = max(0, w_item.stock + delta)
w_item.save(update_fields=['stock'])
@receiver(post_save, sender=WarehouseItem)
def propagate_stock_down_to_siblings(sender, instance, **kwargs):
# Lock the thread state to prevent child signals from writing back to parent
_sync_state.prevent_warehouse_update = True
try:
siblings = instance.pim_products.all()
for product in siblings:
if product.stock != instance.stock:
product.stock = instance.stock
product.save(update_fields=['stock', 'updated_at'])
# Updates the listings on external marketplaces for all other sellers
finally:
_sync_state.prevent_warehouse_update = False
Complexity: Medium. Uses standard thread-local guards. Minimizes performance impacts and integrates natively into the existing signals structure.
Operators in the warehouse cannot print 50 shipping label PDFs one-by-one. They need to print them in bulk easily.
Instead of trying to connect to a local physical printer directly from our web server (which requires local print agents like PrintNode and introduces network security issues), the backend will dynamically fetch and merge all labels into a single multi-page PDF.
Use pypdf (already in requirements.txt) to merge PDF binary content:
import io
import requests
from pypdf import PdfMerger
from django.http import HttpResponse
def download_bulk_awbs(request):
order_ids = request.GET.getlist('order_ids')
orders = Order.objects.filter(id__in=order_ids, account=request.user) # Or 3PL checked scope
merger = PdfMerger()
for order in orders:
if order.awb_url:
try:
# Fetch PDF from S3 or Courier API
response = requests.get(order.awb_url, timeout=10)
if response.status_code == 200:
merger.append(io.BytesIO(response.content))
except Exception as e:
# Log fetch error and continue
continue
output = io.BytesIO()
merger.write(output)
merger.close()
response = HttpResponse(output.getvalue(), content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="bulk_labels.pdf"'
return response
Complexity: Low. Simple utility endpoint. Fully compatible with any generic office or thermal printer.
For direct orders from Woo/Shopify, AWBs are not generated by the marketplace. The system must fetch shipping details from courier APIs using credentials stored in the user profile.
class CourierAccount(models.Model):
class CarrierType(models.TextChoices):
SAMEDAY = 'sameday', 'Sameday'
FAN_COURIER = 'fan_courier', 'FAN Courier'
CARGUS = 'cargus', 'Cargus'
DPD = 'dpd', 'DPD'
user = models.ForeignKey(User, data-removed=models.CASCADE, related_name="courier_accounts")
carrier = models.CharField(max_length=20, choices=CarrierType.choices)
account_name = models.CharField(max_length=100)
api_username = models.CharField(max_length=100)
api_password = models.TextField() # Crypted in production
client_contract_id = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
Order.class Order(models.Model):
# ... existing fields ...
customer_phone = models.CharField(max_length=50, blank=True)
address_is_verified = models.BooleanField(default=False)
address_issues_flagged = models.JSONField(default=list, blank=True) # E.g., ["missing_street_number", "zipcode_mismatch"]
Complexity: Medium-High. Requires writing API client wrappers for each courier (managing their distinct authentication protocols and payload schemas) and maintaining address verification libraries.
The scanner tool is designed to run in a standard web browser (as a mobile-responsive Progressive Web App or PWA). Workers can run the interface directly on tablets or mobile phones.
html5-qrcode or QuaggaJS). It accesses the device camera in-browser without requiring native iOS or Android app wrappers.Carriage Return (Enter key event). The frontend code simply listens to keyboard inputs on a focused textbox element to capture the scan instantly.GET /api/v2/ecommerce/fulfillment/orders/<id>/pick-details/: Exposes picking details, warehouse location, target quantities, and item images.POST /api/v2/ecommerce/fulfillment/orders/<id>/pack-item/: Receives the scanned EAN code. If it matches an item in the order, it increments the packed quantity. Once all quantities match, the order is verified, and AWB generation is scheduled.Complexity: Medium. Frontend handles device camera interaction and input focus events. Backend handles standard, simple database validations.
| Feature | Backend Complexity | Frontend Complexity | Estimated Effort (Sprints) | Risk / Dependencies |
|---|---|---|---|---|
| Multi-Tenancy Roles & Links | Low-Medium | Low | 1 Sprint | Ensure clean separation of role permissions to prevent data leakage. |
| Shared Stock Catalog & Locations | Medium | Medium | 1.5 Sprints | Avoid infinite signal loop recurrences on stock changes. |
| AWB Merging (PDF) | Low | Low | 0.5 Sprints | Depends on library performance (pypdf) with various label styles. |
| Courier Integrations (4 carriers) | High | Medium | 2 Sprints | Integrates with external APIs (Sameday, Fan, DPD, Cargus) and handles credential storage. |
| Address Verification & Edit | Medium | Low-Medium | 1 Sprint | Needs reliable postcode datasets for validation checks. |
| Scanner Tool API & Layout | Medium | High | 1.5 Sprints | Requires mobile/tablet design and hardware scanner compatibility testing. |