3PL Fulfillment Integration Plan

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.


1. Core Architecture: Multi-Tenancy Roles & Cross-Tenant Access

The Challenge

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).

The Solution: Partnership Relations & Role Hierarchy

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.

[Diagram]

Database Changes

  1. 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')
  2. 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')

Query Filtering Strategy (Backend Views)

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.


2. Inventory System: Shared Stock & Warehouse Locations

The Challenge

  1. Shared Physical Pool: Multiple sellers might sell the exact same physical product stored in the 3PL's warehouse. Pushing different stock quantities to different sellers is a business risk.
  2. Physical Warehouse Details: The 3PL needs to track where in the warehouse (deposit, aisle, shelf) a product is located, while the seller should not worry about these logistics.

The Solution: Master Inventory Catalog

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.

[Diagram]

Database Changes

  1. 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')
  2. 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"
        )

3. Real-Time Stock Deduction & Propagation Flow

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.

Recursion Prevention: Thread-Local State Flag

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.

[Diagram]

Signal Implementation Code Sketch

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.


4. Order Processing & AWB Concatenation

The Challenge

Operators in the warehouse cannot print 50 shipping label PDFs one-by-one. They need to print them in bulk easily.

The Solution: AWB Concatenation (PDF Merging)

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.

Backend Implementation

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.


5. Courier Integrations (DPD, Cargus, Fan Courier, Sameday)

The Challenge

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.

Database Changes: Carrier Account Configurations

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)

Address Validation & Verification Flow

  1. Database update: Add phone number and validation flag to 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"]
  2. Address Validation Engine: Run validation checks when non-marketplace orders are ingested:
    • Check if locality exists in official lists.
    • Check if street details or numbers are present.
    • Check if the ZIP code matches the county/locality.
    • Flag orders with errors in the UI, requiring manual editing before AWB generation.
  3. Frontend Action: Add an "Edit Address" modal for the 3PL on the order detail page to modify the shipping address manually and call a save endpoint.

Complexity: Medium-High. Requires writing API client wrappers for each courier (managing their distinct authentication protocols and payload schemas) and maintaining address verification libraries.


6. Warehouse Scanner Tool (PWA Picking/Packing Flow)

High-Level Architecture

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.

[Diagram]

1. Scanning Input Support (Frontend)

2. Operations Flow (Backend)

Complexity: Medium. Frontend handles device camera interaction and input focus events. Backend handles standard, simple database validations.


7. Implementation Estimation Matrix

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.