Files
orion/docs/project-roadmap/slice2_doc.md

808 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Slice 2: Marketplace Product Import
## Vendor Imports Products from Letzshop
**Status**: 📋 NOT STARTED
**Timeline**: Week 2 (5 days)
**Prerequisites**: Slice 1 complete
## 🎯 Slice Objectives
Enable vendors to import product catalogs from Letzshop marketplace via CSV files.
### User Stories
- As a Vendor Owner, I can configure my Letzshop CSV URL
- As a Vendor Owner, I can trigger product imports from Letzshop
- As a Vendor Owner, I can view import job status and history
- The system processes CSV data in the background
- As a Vendor Owner, I can see real-time import progress
### Success Criteria
- [ ] Vendor can configure Letzshop CSV URL (FR, EN, DE)
- [ ] Vendor can trigger import jobs manually
- [ ] System downloads and processes CSV files
- [ ] Import status updates in real-time (Alpine.js)
- [ ] Import history is properly tracked
- [ ] Error handling for failed imports
- [ ] Products stored in staging area (MarketplaceProduct table)
- [ ] Large CSV files process without timeout
## 📋 Backend Implementation
### Database Models
#### MarketplaceProduct Model (`models/database/marketplace_product.py`)
```python
class MarketplaceProduct(Base, TimestampMixin):
"""
Staging table for imported marketplace products
Products stay here until vendor publishes them to catalog
"""
__tablename__ = "marketplace_products"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
import_job_id = Column(Integer, ForeignKey("marketplace_import_jobs.id"))
# External identifiers
external_sku = Column(String, nullable=False, index=True)
marketplace = Column(String, default="letzshop") # Future: other marketplaces
# Product information (from CSV)
title = Column(String, nullable=False)
description = Column(Text)
price = Column(Numeric(10, 2))
currency = Column(String(3), default="EUR")
# Categories and attributes
category = Column(String)
brand = Column(String)
attributes = Column(JSON, default=dict) # Store all CSV columns
# Images
image_urls = Column(JSON, default=list) # List of image URLs
# Inventory
stock_quantity = Column(Integer)
is_in_stock = Column(Boolean, default=True)
# Status
is_selected = Column(Boolean, default=False) # Ready to publish?
is_published = Column(Boolean, default=False) # Already in catalog?
published_product_id = Column(Integer, ForeignKey("products.id"), nullable=True)
# Metadata
language = Column(String(2)) # 'fr', 'en', 'de'
raw_data = Column(JSON) # Store complete CSV row
# Relationships
vendor = relationship("Vendor", back_populates="marketplace_products")
import_job = relationship("MarketplaceImportJob", back_populates="products")
published_product = relationship("Product", back_populates="marketplace_source")
# Indexes
__table_args__ = (
Index('ix_marketplace_vendor_sku', 'vendor_id', 'external_sku'),
Index('ix_marketplace_selected', 'vendor_id', 'is_selected'),
)
```
#### MarketplaceImportJob Model (`models/database/marketplace.py`)
```python
class MarketplaceImportJob(Base, TimestampMixin):
"""Track CSV import jobs"""
__tablename__ = "marketplace_import_jobs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
# Job details
marketplace = Column(String, default="letzshop")
csv_url = Column(String, nullable=False)
language = Column(String(2)) # 'fr', 'en', 'de'
# Status tracking
status = Column(
String,
default="pending"
) # pending, processing, completed, failed
# Progress
total_rows = Column(Integer, default=0)
processed_rows = Column(Integer, default=0)
imported_count = Column(Integer, default=0)
updated_count = Column(Integer, default=0)
error_count = Column(Integer, default=0)
# Timing
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
# Error handling
error_message = Column(Text, nullable=True)
error_details = Column(JSON, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="import_jobs")
products = relationship("MarketplaceProduct", back_populates="import_job")
```
### Pydantic Schemas
#### Import Schemas (`models/schema/marketplace.py`)
```python
from pydantic import BaseModel, HttpUrl
from typing import Optional, List
from datetime import datetime
class MarketplaceImportCreate(BaseModel):
"""Create new import job"""
csv_url: HttpUrl
language: str = Field(..., regex="^(fr|en|de)$")
marketplace: str = "letzshop"
class MarketplaceImportJobResponse(BaseModel):
"""Import job details"""
id: int
vendor_id: int
marketplace: str
csv_url: str
language: str
status: str
total_rows: int
processed_rows: int
imported_count: int
updated_count: int
error_count: int
started_at: Optional[datetime]
completed_at: Optional[datetime]
error_message: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class MarketplaceProductResponse(BaseModel):
"""Marketplace product in staging"""
id: int
vendor_id: int
external_sku: str
title: str
description: Optional[str]
price: float
currency: str
category: Optional[str]
brand: Optional[str]
stock_quantity: Optional[int]
is_in_stock: bool
is_selected: bool
is_published: bool
image_urls: List[str]
language: str
created_at: datetime
class Config:
from_attributes = True
```
### Service Layer
#### Marketplace Service (`app/services/marketplace_service.py`)
```python
from typing import List, Dict, Any
import csv
import requests
from io import StringIO
from sqlalchemy.orm import Session
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace import MarketplaceImportJob
class MarketplaceService:
"""Handle marketplace product imports"""
async def create_import_job(
self,
vendor_id: int,
csv_url: str,
language: str,
db: Session
) -> MarketplaceImportJob:
"""Create new import job and start processing"""
# Create job record
job = MarketplaceImportJob(
vendor_id=vendor_id,
csv_url=csv_url,
language=language,
marketplace="letzshop",
status="pending"
)
db.add(job)
db.commit()
db.refresh(job)
# Trigger background processing
from tasks.marketplace_import import process_csv_import
process_csv_import.delay(job.id)
return job
def process_csv_import(self, job_id: int, db: Session):
"""
Process CSV import (called by Celery task)
This is a long-running operation
"""
job = db.query(MarketplaceImportJob).get(job_id)
if not job:
return
try:
# Update status
job.status = "processing"
job.started_at = datetime.utcnow()
db.commit()
# Download CSV
response = requests.get(job.csv_url, timeout=30)
response.raise_for_status()
# Parse CSV
csv_content = StringIO(response.text)
reader = csv.DictReader(csv_content)
# Count total rows
rows = list(reader)
job.total_rows = len(rows)
db.commit()
# Process each row
for idx, row in enumerate(rows):
try:
self._process_csv_row(job, row, db)
job.processed_rows = idx + 1
# Commit every 100 rows
if idx % 100 == 0:
db.commit()
except Exception as e:
job.error_count += 1
# Log error but continue
# Final commit
job.status = "completed"
job.completed_at = datetime.utcnow()
db.commit()
except Exception as e:
job.status = "failed"
job.error_message = str(e)
job.completed_at = datetime.utcnow()
db.commit()
def _process_csv_row(
self,
job: MarketplaceImportJob,
row: Dict[str, Any],
db: Session
):
"""Process single CSV row"""
# Extract fields from CSV
external_sku = row.get('SKU') or row.get('sku')
if not external_sku:
raise ValueError("Missing SKU in CSV row")
# Check if product already exists
existing = db.query(MarketplaceProduct).filter(
MarketplaceProduct.vendor_id == job.vendor_id,
MarketplaceProduct.external_sku == external_sku
).first()
# Parse image URLs
image_urls = []
for i in range(1, 6): # Support up to 5 images
img_url = row.get(f'Image{i}') or row.get(f'image_{i}')
if img_url:
image_urls.append(img_url)
if existing:
# Update existing product
existing.title = row.get('Title') or row.get('title')
existing.description = row.get('Description')
existing.price = float(row.get('Price', 0))
existing.stock_quantity = int(row.get('Stock', 0))
existing.is_in_stock = existing.stock_quantity > 0
existing.category = row.get('Category')
existing.brand = row.get('Brand')
existing.image_urls = image_urls
existing.raw_data = row
existing.import_job_id = job.id
job.updated_count += 1
else:
# Create new product
product = MarketplaceProduct(
vendor_id=job.vendor_id,
import_job_id=job.id,
external_sku=external_sku,
marketplace="letzshop",
title=row.get('Title') or row.get('title'),
description=row.get('Description'),
price=float(row.get('Price', 0)),
currency="EUR",
category=row.get('Category'),
brand=row.get('Brand'),
stock_quantity=int(row.get('Stock', 0)),
is_in_stock=int(row.get('Stock', 0)) > 0,
image_urls=image_urls,
language=job.language,
raw_data=row,
is_selected=False,
is_published=False
)
db.add(product)
job.imported_count += 1
def get_import_jobs(
self,
vendor_id: int,
db: Session,
skip: int = 0,
limit: int = 20
) -> List[MarketplaceImportJob]:
"""Get import job history for vendor"""
return db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id
).order_by(
MarketplaceImportJob.created_at.desc()
).offset(skip).limit(limit).all()
def get_marketplace_products(
self,
vendor_id: int,
db: Session,
import_job_id: Optional[int] = None,
is_selected: Optional[bool] = None,
skip: int = 0,
limit: int = 100
) -> List[MarketplaceProduct]:
"""Get marketplace products in staging"""
query = db.query(MarketplaceProduct).filter(
MarketplaceProduct.vendor_id == vendor_id,
MarketplaceProduct.is_published == False # Only unpublished
)
if import_job_id:
query = query.filter(MarketplaceProduct.import_job_id == import_job_id)
if is_selected is not None:
query = query.filter(MarketplaceProduct.is_selected == is_selected)
return query.order_by(
MarketplaceProduct.created_at.desc()
).offset(skip).limit(limit).all()
```
### API Endpoints
#### Marketplace Endpoints (`app/api/v1/vendor/marketplace.py`)
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
router = APIRouter()
@router.post("/import", response_model=MarketplaceImportJobResponse)
async def trigger_import(
import_data: MarketplaceImportCreate,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Trigger CSV import from marketplace"""
service = MarketplaceService()
job = await service.create_import_job(
vendor_id=vendor.id,
csv_url=str(import_data.csv_url),
language=import_data.language,
db=db
)
return job
@router.get("/jobs", response_model=List[MarketplaceImportJobResponse])
async def get_import_jobs(
skip: int = 0,
limit: int = 20,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Get import job history"""
service = MarketplaceService()
jobs = service.get_import_jobs(vendor.id, db, skip, limit)
return jobs
@router.get("/jobs/{job_id}", response_model=MarketplaceImportJobResponse)
async def get_import_job(
job_id: int,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Get specific import job status"""
job = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.id == job_id,
MarketplaceImportJob.vendor_id == vendor.id
).first()
if not job:
raise HTTPException(status_code=404, detail="Import job not found")
return job
@router.get("/products", response_model=List[MarketplaceProductResponse])
async def get_marketplace_products(
import_job_id: Optional[int] = None,
is_selected: Optional[bool] = None,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Get products in marketplace staging area"""
service = MarketplaceService()
products = service.get_marketplace_products(
vendor.id, db, import_job_id, is_selected, skip, limit
)
return products
```
### Background Tasks
#### Celery Task (`tasks/marketplace_import.py`)
```python
from celery import shared_task
from app.core.database import SessionLocal
from app.services.marketplace_service import MarketplaceService
@shared_task(bind=True, max_retries=3)
def process_csv_import(self, job_id: int):
"""
Process CSV import in background
This can take several minutes for large files
"""
db = SessionLocal()
try:
service = MarketplaceService()
service.process_csv_import(job_id, db)
except Exception as e:
# Retry on failure
raise self.retry(exc=e, countdown=60)
finally:
db.close()
```
## 🎨 Frontend Implementation
### Templates
#### Import Dashboard (`templates/vendor/marketplace/imports.html`)
```html
{% extends "vendor/base_vendor.html" %}
{% block title %}Product Import{% endblock %}
{% block content %}
<div x-data="marketplaceImport()" x-init="loadJobs()">
<!-- Page Header -->
<div class="page-header">
<h1>Marketplace Import</h1>
<button @click="showImportModal = true" class="btn btn-primary">
New Import
</button>
</div>
<!-- Import Configuration Card -->
<div class="card mb-3">
<h3>Letzshop CSV URLs</h3>
<div class="config-grid">
<div>
<label>French (FR)</label>
<input
type="text"
x-model="vendor.letzshop_csv_url_fr"
class="form-control"
readonly
>
</div>
<div>
<label>English (EN)</label>
<input
type="text"
x-model="vendor.letzshop_csv_url_en"
class="form-control"
readonly
>
</div>
<div>
<label>German (DE)</label>
<input
type="text"
x-model="vendor.letzshop_csv_url_de"
class="form-control"
readonly
>
</div>
</div>
<p class="text-muted mt-2">
Configure these URLs in vendor settings
</p>
</div>
<!-- Import Jobs List -->
<div class="card">
<h3>Import History</h3>
<template x-if="jobs.length === 0 && !loading">
<p class="text-muted">No imports yet. Start your first import!</p>
</template>
<template x-if="jobs.length > 0">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Language</th>
<th>Status</th>
<th>Progress</th>
<th>Results</th>
<th>Started</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="job in jobs" :key="job.id">
<tr>
<td><strong x-text="`#${job.id}`"></strong></td>
<td x-text="job.language.toUpperCase()"></td>
<td>
<span
class="badge"
:class="{
'badge-warning': job.status === 'pending' || job.status === 'processing',
'badge-success': job.status === 'completed',
'badge-danger': job.status === 'failed'
}"
x-text="job.status"
></span>
</td>
<td>
<template x-if="job.status === 'processing'">
<div class="progress-bar">
<div
class="progress-fill"
:style="`width: ${(job.processed_rows / job.total_rows * 100)}%`"
></div>
</div>
<small x-text="`${job.processed_rows} / ${job.total_rows}`"></small>
</template>
<template x-if="job.status === 'completed'">
<span x-text="`${job.total_rows} rows`"></span>
</template>
</td>
<td>
<template x-if="job.status === 'completed'">
<div class="text-sm">
<div><span x-text="job.imported_count"></span> imported</div>
<div><span x-text="job.updated_count"></span> updated</div>
<template x-if="job.error_count > 0">
<div class="text-danger"><span x-text="job.error_count"></span> errors</div>
</template>
</div>
</template>
</td>
<td x-text="formatDate(job.started_at)"></td>
<td>
<button
@click="viewProducts(job.id)"
class="btn btn-sm"
:disabled="job.status !== 'completed'"
>
View Products
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- New Import Modal -->
<div x-show="showImportModal" class="modal-overlay" @click.self="showImportModal = false">
<div class="modal">
<div class="modal-header">
<h3>New Import</h3>
<button @click="showImportModal = false" class="modal-close">×</button>
</div>
<div class="modal-body">
<form @submit.prevent="triggerImport()">
<div class="form-group">
<label>Language</label>
<select x-model="newImport.language" class="form-control" required>
<option value="">Select language</option>
<option value="fr">French (FR)</option>
<option value="en">English (EN)</option>
<option value="de">German (DE)</option>
</select>
</div>
<div class="form-group">
<label>CSV URL</label>
<input
type="url"
x-model="newImport.csv_url"
class="form-control"
placeholder="https://..."
required
>
<div class="form-help">
Or use configured URL:
<button
type="button"
@click="newImport.csv_url = vendor.letzshop_csv_url_fr"
class="btn-link"
x-show="newImport.language === 'fr'"
>
Use FR URL
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" @click="showImportModal = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="importing">
<span x-show="!importing">Start Import</span>
<span x-show="importing" class="loading-spinner"></span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
window.vendorData = {{ vendor|tojson }};
</script>
{% endblock %}
{% block extra_scripts %}
<script>
function marketplaceImport() {
return {
vendor: window.vendorData,
jobs: [],
loading: false,
importing: false,
showImportModal: false,
newImport: {
language: '',
csv_url: ''
},
async loadJobs() {
this.loading = true;
try {
this.jobs = await apiClient.get('/api/v1/vendor/marketplace/jobs');
// Poll for active jobs
const activeJobs = this.jobs.filter(j =>
j.status === 'pending' || j.status === 'processing'
);
if (activeJobs.length > 0) {
setTimeout(() => this.loadJobs(), 3000); // Poll every 3 seconds
}
} catch (error) {
showNotification('Failed to load imports', 'error');
} finally {
this.loading = false;
}
},
async triggerImport() {
this.importing = true;
try {
const job = await apiClient.post('/api/v1/vendor/marketplace/import', {
csv_url: this.newImport.csv_url,
language: this.newImport.language
});
this.jobs.unshift(job);
this.showImportModal = false;
this.newImport = { language: '', csv_url: '' };
showNotification('Import started successfully', 'success');
// Start polling
setTimeout(() => this.loadJobs(), 3000);
} catch (error) {
showNotification(error.message || 'Import failed', 'error');
} finally {
this.importing = false;
}
},
viewProducts(jobId) {
window.location.href = `/vendor/marketplace/products?job_id=${jobId}`;
},
formatDate(dateString) {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
}
}
}
</script>
{% endblock %}
```
## ✅ Testing Checklist
### Backend Tests
- [ ] CSV download works with valid URL
- [ ] CSV parsing handles various formats
- [ ] Products created in staging table
- [ ] Duplicate SKUs are updated, not duplicated
- [ ] Import job status updates correctly
- [ ] Progress tracking is accurate
- [ ] Error handling works for invalid CSV
- [ ] Large CSV files (10,000+ rows) process successfully
- [ ] Celery tasks execute correctly
### Frontend Tests
- [ ] Import page loads correctly
- [ ] New import modal works
- [ ] Import jobs display in table
- [ ] Real-time progress updates (polling)
- [ ] Completed imports show results
- [ ] Can view products from import
- [ ] Error states display correctly
- [ ] Loading states work correctly
### Integration Tests
- [ ] Complete import workflow works end-to-end
- [ ] Vendor isolation maintained
- [ ] API endpoints require authentication
- [ ] Vendor can only see their own imports
## 🚀 Deployment Checklist
- [ ] Celery worker running
- [ ] Redis/RabbitMQ configured for Celery
- [ ] Database migrations applied
- [ ] CSV download timeout configured
- [ ] Error logging configured
- [ ] Background task monitoring set up
## ➡️ Next Steps
After completing Slice 2, move to **Slice 3: Product Catalog Management** to enable vendors to publish imported products to their catalog.
---
**Slice 2 Status**: 📋 Not Started
**Dependencies**: Slice 1 must be complete
**Estimated Duration**: 5 days