808 lines
27 KiB
Markdown
808 lines
27 KiB
Markdown
# 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 |